diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcfa822e1..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 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 diff --git a/src/bindings b/src/bindings index 1beb2fec84..a7ade0db48 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 1beb2fec847e18225adf6dd3687c3459550fe676 +Subproject commit a7ade0db4879afeb603c246c967098e0ca6170b5 diff --git a/src/examples/zkapps/dex/run-live.ts b/src/examples/zkapps/dex/run-live.ts index 6db2631acc..554b77b85b 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,13 +285,15 @@ 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: ' + (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/dex/run.ts b/src/examples/zkapps/dex/run.ts index 0244ae541e..e053a6463e 100644 --- a/src/examples/zkapps/dex/run.ts +++ b/src/examples/zkapps/dex/run.ts @@ -237,7 +237,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/ + ); } /** @@ -252,14 +254,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; @@ -278,7 +280,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, () => { @@ -289,7 +291,7 @@ async function main({ withVesting }: { withVesting: boolean }) { }); await tx.prove(); tx.sign([keys.tokenX]); - await tx.send(); + await tx.sendOrThrowIfError(); }).rejects.toThrow(); /** @@ -316,7 +318,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/ ); @@ -343,7 +345,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); @@ -452,7 +456,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/ ); @@ -487,7 +491,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 366ca2c467..70ba253967 100644 --- a/src/examples/zkapps/dex/upgradability.ts +++ b/src/examples/zkapps/dex/upgradability.ts @@ -123,9 +123,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'); @@ -185,9 +185,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 @@ -461,9 +461,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/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 }); diff --git a/src/examples/zkapps/hello-world/run.ts b/src/examples/zkapps/hello-world/run.ts index 272b4ea8f0..da37518357 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'); } diff --git a/src/index.ts b/src/index.ts index 14cf5b4181..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, @@ -82,7 +88,7 @@ export { export { TokenAccountUpdateIterator } from './lib/mina/token/forest-iterator.js'; export { TokenContract } from './lib/mina/token/token-contract.js'; -export type { TransactionStatus } from './lib/fetch.js'; +export type { TransactionStatus } from './lib/mina/graphql.js'; export { fetchAccount, fetchLastBlock, 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' ); diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 02cf518f35..73d324770d 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -9,17 +9,39 @@ import { ActionStates } from './mina.js'; import { LedgerHash, EpochSeed, StateHash } from './base58-encodings.js'; import { Account, - accountQuery, - FetchedAccount, fillPartialAccount, parseFetchedAccount, PartialAccount, } from './mina/account.js'; +import { + type LastBlockQueryResponse, + type GenesisConstantsResponse, + type LastBlockQueryFailureCheckResponse, + type FetchedBlock, + type TransactionStatus, + type TransactionStatusQueryResponse, + type EventQueryResponse, + type ActionQueryResponse, + type EventActionFilterOptions, + type SendZkAppResponse, + type FetchedAccount, + type CurrentSlotResponse, + sendZkappQuery, + lastBlockQuery, + lastBlockQueryFailureCheck, + transactionStatusQuery, + getEventsQuery, + getActionsQuery, + genesisConstantsQuery, + accountQuery, + currentSlotQuery, +} from './mina/graphql.js'; export { fetchAccount, fetchLastBlock, fetchGenesisConstants, + fetchCurrentSlot, checkZkappTransaction, parseFetchedAccount, markAccountToBeFetched, @@ -27,8 +49,6 @@ export { markActionsToBeFetched, fetchMissingData, fetchTransactionStatus, - TransactionStatus, - EventActionFilterOptions, getCachedAccount, getCachedNetwork, getCachedActions, @@ -41,13 +61,12 @@ export { setArchiveGraphqlEndpoint, setArchiveGraphqlFallbackEndpoints, setLightnetAccountManagerEndpoint, - sendZkappQuery, sendZkapp, - removeJsonQuotes, fetchEvents, fetchActions, Lightnet, type GenesisConstants, + type ActionStatesStringified, }; type NetworkConfig = { @@ -183,16 +202,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: { @@ -211,7 +229,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; @@ -219,16 +237,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; @@ -270,6 +278,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( @@ -431,7 +448,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 @@ -450,83 +467,34 @@ async function fetchLastBlock(graphqlEndpoint = networkConfig.minaEndpoint) { return network; } -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 LastBlockQueryFailureCheckResponse = { - bestChain: { - transactions: { - zkappCommands: { - hash: string; - failureReason: { - failures: string[]; - index: number; - }[]; - }[]; - }; - }[]; -}; - -const lastBlockQueryFailureCheck = (length: number) => `{ - bestChain(maxLength: ${length}) { - transactions { - zkappCommands { - hash - failureReason { - failures - index - } - } - } +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 ) { - 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.' @@ -535,18 +503,19 @@ 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() .map((failure) => { - return ` AccountUpdate #${ - failure.index - } failed. Reason: "${failure.failures.join(', ')}"`; + return [failure.failures.map((failureItem) => failureItem)]; }); return { success: false, @@ -567,48 +536,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 }, @@ -654,10 +581,6 @@ function parseEpochData({ }; } -const transactionStatusQuery = (txId: string) => `query { - transactionStatus(zkappTransaction:"${txId}") -}`; - /** * Fetches the status of a transaction. */ @@ -665,7 +588,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 @@ -678,16 +601,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'; - /** * Sends a zkApp command (transaction) to the specified GraphQL endpoint. */ @@ -696,7 +609,7 @@ function sendZkapp( graphqlEndpoint = networkConfig.minaEndpoint, { timeout = defaultTimeout } = {} ) { - return makeGraphqlRequest( + return makeGraphqlRequest( sendZkappQuery(json), graphqlEndpoint, networkConfig.minaFallbackEndpoints, @@ -706,156 +619,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 FetchedEvents = { - blockInfo: { - distanceFromMaxBlockHeight: number; - globalSlotSinceGenesis: number; - height: number; - stateHash: string; - parentHash: string; - chainStatus: string; - }; - eventData: { - transactionInfo: { - hash: string; - memo: string; - status: string; - }; - data: string[]; - }[]; -}; -type FetchedActions = { - 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 @@ -881,7 +644,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), @@ -891,32 +654,13 @@ 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}` ); } - // 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 { @@ -953,13 +697,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: { @@ -969,25 +713,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 ( @@ -1051,7 +776,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 @@ -1206,14 +931,8 @@ 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( +async function makeGraphqlRequest( query: string, graphqlEndpoint = networkConfig.minaEndpoint, fallbackEndpoints: string[], @@ -1241,7 +960,7 @@ async function makeGraphqlRequest( body, signal: controller.signal, }); - return checkResponseStatus(response); + return checkResponseStatus(response); } finally { clearTimeouts(); } @@ -1284,9 +1003,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 +1029,7 @@ async function checkResponseStatus( } as FetchError, ]; } - return [jsonResponse as FetchResponse, undefined]; + return [jsonResponse as FetchResponse, undefined]; } else { return [ undefined, 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(); diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 44c7830517..309fc42e10 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -1,60 +1,64 @@ -import { Ledger } 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 { - addMissingProofs, - addMissingSignatures, - FeePayerUnsigned, - ZkappCommand, - AccountUpdate, - ZkappPublicInput, - TokenId, - CallForest, - Authorization, - Actions, - Events, - dummySignature, - AccountUpdateLayout, -} from './account-update.js'; +import { PublicKey } from './signature.js'; +import { ZkappCommand, TokenId, Authorization } 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 { 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 { Provable } from './provable.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 { FetchMode, currentTransaction } from './mina/transaction-context.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 { SimpleLedger } from './mina/transaction-logic/ledger.js'; -import { assert } from './gadgets/common.js'; +import { type EventActionFilterOptions } from './mina/graphql.js'; +import { + type Transaction, + type PendingTransaction, + type IncludedTransaction, + type RejectedTransaction, + createTransaction, + newTransaction, + transaction, + createIncludedOrRejectedTransaction, +} from './mina/transaction.js'; +import { + reportGetAccountError, + verifyTransactionLimits, + defaultNetworkState, + filterGroups, +} from './mina/transaction-validation.js'; +import { LocalBlockchain } from './mina/local-blockchain.js'; export { - createTransaction, BerkeleyQANet, - Network, LocalBlockchain, + Network, currentTransaction, Transaction, - TransactionId, + PendingTransaction, + IncludedTransaction, + RejectedTransaction, activeInstance, setActiveInstance, transaction, @@ -67,7 +71,6 @@ export { getNetworkConstants, getNetworkState, accountCreationFee, - sendTransaction, fetchEvents, fetchActions, getActions, @@ -89,49 +92,6 @@ setActiveInstance({ }, }); -interface TransactionId { - 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. - */ - transaction: ZkappCommand; - /** - * Returns a JSON representation of the {@link Transaction}. - */ - toJSON(): string; - /** - * Returns a pretty-printed JSON representation of the {@link Transaction}. - */ - toPretty(): any; - /** - * Returns the GraphQL query for the Mina daemon. - */ - 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} - */ - sign(additionalKeys?: PrivateKey[]): Transaction; - /** - * Generates proofs for the {@link Transaction}. - * - * This can take some time. - */ - prove(): Promise<(Proof | undefined)[]>; - /** - * Sends the {@link Transaction} to the network. - */ - send(): Promise; -}; - const Transaction = { fromJSON(json: Types.Json.ZkappCommand): Transaction { let transaction = ZkappCommand.fromJSON(json); @@ -139,495 +99,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}`; - } -} - -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 Fetch.sendZkappQuery(self.toJSON()); - }, - async send() { - try { - return await sendTransaction(self); - } catch (error) { - throw prettifyStacktrace(error); - } - }, - }; - return self; -} - -/** - * 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); - } - } - - try { - ledger.applyJsonTransaction( - JSON.stringify(zkappCommandJson), - defaultNetworkConstants.accountCreationFee.toString(), - 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, { - accountCreationFee: - defaultNetworkConstants.accountCreationFee.toString(), - }); - } finally { - throw err; - } - } - - // 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(), - }); - } - }); - return { - isSuccess: true, - 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.'; - console.log(message); - return message; - }, - }; - }, - 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 */ @@ -786,86 +257,121 @@ 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); 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))); } - let isSuccess = errors === undefined; - let maxAttempts: number; - let attempts = 0; - let interval: number; - - return { + const isSuccess = errors.length === 0; + const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); + const pendingTransaction: Omit< + PendingTransaction, + 'wait' | 'waitOrThrowIfError' + > = { isSuccess, data: response?.data, errors, - async wait(options?: { maxAttempts?: number; interval?: number }) { - if (!isSuccess) { - console.warn( - 'Transaction.wait(): returning immediately because the transaction was not successful.' + transaction: txn.transaction, + hash, + toJSON: txn.toJSON, + toPretty: txn.toPretty, + }; + + const pollTransactionStatus = async ( + transactionHash: string, + maxAttempts: number, + interval: number, + attempts: number = 0 + ): Promise => { + let res: Awaited>; + try { + res = await Fetch.checkZkappTransaction(transactionHash); + if (res.success) { + return createIncludedOrRejectedTransaction(pendingTransaction, []); + } else if (res.failureReason) { + const error = invalidTransactionError( + txn.transaction, + res.failureReason, + { + accountCreationFee: + defaultNetworkConstants.accountCreationFee.toString(), + } ); - return; + return createIncludedOrRejectedTransaction(pendingTransaction, [ + error, + ]); } - // 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; + } catch (error) { + return createIncludedOrRejectedTransaction(pendingTransaction, [ + (error as Error).message, + ]); + } - const executePoll = async ( - resolve: () => void, - reject: (err: Error) => void | Error - ) => { - let txId = response?.data?.sendZkapp?.zkapp?.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); - } - }; + if (maxAttempts && attempts >= maxAttempts) { + return createIncludedOrRejectedTransaction(pendingTransaction, [ + `Exceeded max attempts.\nTransactionId: ${transactionHash}\nAttempts: ${attempts}\nLast received status: ${res}`, + ]); + } - return new Promise(executePoll); - }, - hash() { - return response?.data?.sendZkapp?.zkapp?.hash; - }, + await new Promise((resolve) => setTimeout(resolve, interval)); + return pollTransactionStatus( + transactionHash, + maxAttempts, + interval, + attempts + 1 + ); + }; + + const wait = async (options?: { + maxAttempts?: number; + interval?: number; + }): Promise => { + if (!isSuccess) { + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ); + } + + // 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 + const maxAttempts = options?.maxAttempts ?? 45; + const interval = options?.interval ?? 20000; + return pollTransactionStatus( + pendingTransaction.hash, + maxAttempts, + interval + ); + }; + + const waitOrThrowIfError = async (options?: { + maxAttempts?: number; + interval?: number; + }): Promise => { + const pendingTransaction = await wait(options); + if (pendingTransaction.status === 'rejected') { + throw Error( + `Transaction failed with errors:\n${pendingTransaction.errors.join( + '\n' + )}` + ); + } + return pendingTransaction; + }; + + return { + ...pendingTransaction, + wait, + waitOrThrowIfError, }; }, async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { @@ -885,7 +391,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); @@ -962,57 +468,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. * @@ -1039,421 +494,12 @@ 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(); -} - -async function sendTransaction(txn: Transaction) { - return await activeInstance.sendTransaction(txn); -} - -/** - * @return A list of emitted events associated to the given public key. - */ -async function fetchEvents( - publicKey: PublicKey, - tokenId: Field, - filterOptions: Fetch.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; 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/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/errors.ts b/src/lib/mina/errors.ts index 261005dfbc..bdbfd16c04 100644 --- a/src/lib/mina/errors.ts +++ b/src/lib/mina/errors.ts @@ -60,23 +60,25 @@ function invalidTransactionError( ): string { let errorMessages = []; let rawErrors = JSON.stringify(errors); + let n = transaction.accountUpdates.length; - // 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 (errors.length > n) { + let errorsForFeePayer = errors.shift() ?? []; + 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 < errors.length; i++) { + let errorsForUpdate = errors[i]; for (let [error] of errorsForUpdate) { let message = ErrorHandlers[error as keyof typeof ErrorHandlers]?.({ transaction, diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts new file mode 100644 index 0000000000..9cff4b2217 --- /dev/null +++ b/src/lib/mina/graphql.ts @@ -0,0 +1,508 @@ +import { UInt32 } from '../int.js'; +import type { ZkappCommand } from '../account-update.js'; +import type { ActionStatesStringified } from '../fetch.js'; +import { Types } from '../../bindings/mina-transaction/types.js'; + +export { + type EpochData, + type LastBlockQueryResponse, + type GenesisConstantsResponse, + type FailureReasonResponse, + type LastBlockQueryFailureCheckResponse, + type FetchedBlock, + type TransactionStatus, + type TransactionStatusQueryResponse, + type EventQueryResponse, + type ActionQueryResponse, + type EventActionFilterOptions, + type SendZkAppResponse, + type FetchedAccount, + type CurrentSlotResponse, + getEventsQuery, + getActionsQuery, + sendZkappQuery, + transactionStatusQuery, + lastBlockQueryFailureCheck, + accountQuery, + 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 = { + 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 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) + }; + }; +}; + +type GenesisConstantsResponse = { + genesisConstants: { + genesisTimestamp: string; + coinbase: string; + accountCreationFee: string; + }; + daemonStatus: { + consensusConfiguration: { + epochDuration: string; + k: string; + slotDuration: string; + slotsPerEpoch: string; + }; + }; +}; + +type CurrentSlotResponse = { + bestChain: Array<{ + protocolState: { + consensusState: { + slot: 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 + } + } + } + stateHash + protocolState { + consensusState { + blockHeight + epoch + slotSinceGenesis + } + previousStateHash + } + } +}`; + +// 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 + } + } + } + } + } +} +`; +} + +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 + } +} +`; + +const currentSlotQuery = `{ + bestChain(maxLength: 1) { + protocolState { + consensusState { + slot + } + } + } +}`; diff --git a/src/lib/mina/local-blockchain.ts b/src/lib/mina/local-blockchain.ts new file mode 100644 index 0000000000..06dbf66911 --- /dev/null +++ b/src/lib/mina/local-blockchain.ts @@ -0,0 +1,394 @@ +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, +} from './mina-instance.js'; +import { + reportGetAccountError, + defaultNetworkState, + verifyTransactionLimits, + verifyAccountUpdate, +} from './transaction-validation.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, + hash, + toJSON: txn.toJSON, + toPretty: txn.toPretty, + }; + + const wait = async (_options?: { + maxAttempts?: number; + interval?: number; + }) => { + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ); + }; + + const waitOrThrowIfError = async (_options?: { + maxAttempts?: number; + interval?: number; + }) => { + const pendingTransaction = await wait(_options); + if (pendingTransaction.status === 'rejected') { + throw Error( + `Transaction failed with errors:\n${pendingTransaction.errors.join( + '\n' + )}` + ); + } + return pendingTransaction; + }; + + 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 e2a2252c77..324dfefaeb 100644 --- a/src/lib/mina/mina-instance.ts +++ b/src/lib/mina/mina-instance.ts @@ -1,14 +1,15 @@ /** * This module holds the global Mina instance and its interface. */ -import type { Field } from '../field.js'; +import { Field } from '../core.js'; import { UInt64, UInt32 } from '../int.js'; -import type { PublicKey, PrivateKey } from '../signature.js'; -import type { Transaction, TransactionId } from '../mina.js'; +import { PublicKey, PrivateKey } from '../signature.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 { NetworkId } from '../../mina-signer/src/types.js'; export { Mina, @@ -20,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; @@ -91,11 +104,11 @@ interface Mina { * @deprecated use {@link getNetworkConstants} */ accountCreationFee(): UInt64; - sendTransaction(transaction: Transaction): Promise; + sendTransaction(transaction: Transaction): Promise; fetchEvents: ( publicKey: PublicKey, tokenId?: Field, - filterOptions?: Fetch.EventActionFilterOptions + filterOptions?: EventActionFilterOptions ) => ReturnType; fetchActions: ( publicKey: PublicKey, @@ -137,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; +} 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 }; + } +} diff --git a/src/lib/mina/transaction.ts b/src/lib/mina/transaction.ts new file mode 100644 index 0000000000..9d74aa9bb6 --- /dev/null +++ b/src/lib/mina/transaction.ts @@ -0,0 +1,522 @@ +import { + ZkappCommand, + AccountUpdate, + ZkappPublicInput, + AccountUpdateLayout, + FeePayerUnsigned, + addMissingSignatures, + 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'; +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, + type FeePayerSpec, + 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, + transaction, + 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; + + /** + * Returns the transaction hash as a string identifier. + * @property {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; +} + +/** + * 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); +} + +/** + * @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, + }; +} 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 df138aefc1..aa5140b3ef 100644 --- a/src/lib/token.test.ts +++ b/src/lib/token.test.ts @@ -328,7 +328,7 @@ describe('Token', () => { tokenZkapp.requireSignature(); }) ).sign([zkAppBKey, feePayerKey, tokenZkappKey]); - await expect(tx.send()).rejects.toThrow(); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(); }); }); @@ -396,7 +396,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 () => { @@ -420,7 +420,7 @@ describe('Token', () => { tokenZkapp.requireSignature(); }) ).sign([zkAppBKey, feePayerKey, tokenZkappKey]); - await expect(tx.send()).rejects.toThrow(); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(); }); }); }); @@ -581,9 +581,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/); }); }); }); 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 diff --git a/src/snarky.d.ts b/src/snarky.d.ts index 3e7daca766..e11a2ccf63 100644 --- a/src/snarky.d.ts +++ b/src/snarky.d.ts @@ -696,6 +696,7 @@ declare const Test: { serializeCommon(common: string): { data: Uint8Array }; hashPayment(payment: string): string; hashPaymentV1(payment: string): string; + hashZkAppCommand(command: string): string; }; }; diff --git a/src/tests/transaction-flow.ts b/src/tests/transaction-flow.ts new file mode 100644 index 0000000000..90a3721d74 --- /dev/null +++ b/src/tests/transaction-flow.ts @@ -0,0 +1,311 @@ +import { + AccountUpdate, + Provable, + Field, + Lightnet, + Mina, + PrivateKey, + Struct, + PublicKey, + SmartContract, + State, + state, + method, + Reducer, + fetchAccount, + TokenId, +} from 'o1js'; +import assert from 'node:assert'; + +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() { + const counter = this.counter.get(); + this.counter.requireEquals(counter); + const actionState = this.actionState.get(); + this.actionState.requireEquals(actionState); + + const endActionState = this.account.actionState.getAndRequireEquals(); + + const pendingActions = this.reducer.getActions({ + fromActionState: actionState, + endActionState, + }); + + const { 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); + const 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, + throwOnFail = false +) { + await transaction.prove(); + 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; + +const 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 () => { + await assert.doesNotReject(async () => { + await Mina.transaction({ sender, fee: transactionFee }, () => { + Mina.getNetworkConstants(); + Mina.getNetworkState(); + Mina.getNetworkId(); + Mina.getProofsEnabled(); + }); + }); +}); +console.log(''); + +console.log( + `Test 'fetchAccount', 'getAccount', and 'hasAccount' match behavior using publicKey: ${zkAppAddress.toBase58()}` +); +await testLocalAndRemote(async () => { + await assert.doesNotReject(async () => { + 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), + }; + }); +}); +console.log(''); + +console.log('Test deploying zkApp for public key ' + zkAppAddress.toBase58()); +await testLocalAndRemote(async () => { + await assert.doesNotReject(async () => { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.deploy({ verificationKey }); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + }); +}); +console.log(''); + +console.log( + "Test calling successful 'update' method does not throw with throwOnFail is false" +); +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); + assert(includedTransaction.status === 'included'); + await Mina.fetchEvents(zkAppAddress, TokenId.default); + }); +}); +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 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 () => { + await assert.rejects(async () => { + 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, true); + }); +}); +console.log(''); + +console.log('Test emitting and fetching actions do not throw'); +await testLocalAndRemote(async () => { + try { + let transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.incrementCounter(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + + transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.rollupIncrements(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + + transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.incrementCounter(); + zkApp.incrementCounter(); + zkApp.incrementCounter(); + zkApp.incrementCounter(); + zkApp.incrementCounter(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + + transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.rollupIncrements(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + assert.ifError(error); + } +});