import { Test } from '../../../bindings.js'; import { Types } from '../../../bindings/mina-transaction/v1/types.js'; import { NetworkId } from '../../../mina-signer/src/types.js'; import { PublicKey } from '../../provable/crypto/signature.js'; import { UInt32, UInt64 } from '../../provable/int.js'; import { Field } from '../../provable/wrapped.js'; import { Authorization, TokenId } from './account-update.js'; import { Account } from './account.js'; import { humanizeErrors, invalidTransactionError } from './errors.js'; import * as Fetch from './fetch.js'; import { type EventActionFilterOptions } from './graphql.js'; import { LocalBlockchain, TestPublicKey } from './local-blockchain.js'; import { Mina, activeInstance, currentSlot, defaultNetworkConstants, fetchActions, fetchEvents, getAccount, getActions, getBalance, getNetworkConstants, getNetworkId, getNetworkState, getProofsEnabled, hasAccount, setActiveInstance, type ActionStates, type FeePayerSpec, type NetworkConstants, } from './mina-instance.js'; import { currentTransaction } from './transaction-context.js'; import { defaultNetworkState, filterGroups, reportGetAccountError, verifyTransactionLimits, } from './transaction-validation.js'; import { Transaction, createIncludedTransaction, createRejectedTransaction, createTransaction, toPendingTransactionPromise, toTransactionPromise, transaction, type IncludedTransaction, type PendingTransaction, type PendingTransactionPromise, type PendingTransactionStatus, type RejectedTransaction, } from './transaction.js'; export { ActionStates, FeePayerSpec, LocalBlockchain, Network, TestPublicKey, Transaction, activeInstance, currentSlot, currentTransaction, faucet, fetchActions, fetchEvents, // for internal testing only filterGroups, getAccount, getActions, getBalance, getNetworkConstants, getNetworkId, getNetworkState, getProofsEnabled, hasAccount, sender, setActiveInstance, transaction, waitForFunding, type IncludedTransaction, type NetworkConstants, type PendingTransaction, type PendingTransactionPromise, type PendingTransactionStatus, type RejectedTransaction, }; // patch active instance so that we can still create basic transactions without giving Mina network details setActiveInstance({ ...activeInstance, transaction(sender: FeePayerSpec, f: () => Promise) { return toTransactionPromise(() => createTransaction(sender, f, 0)); }, }); /** * Represents the Mina blockchain running on a real network */ function Network(graphqlEndpoint: string): Mina; function Network(options: { networkId?: NetworkId; mina: string | string[]; archive?: string | string[]; lightnetAccountManager?: string; bypassTransactionLimits?: boolean; minaDefaultHeaders?: HeadersInit; archiveDefaultHeaders?: HeadersInit; }): Mina; function Network( options: | { networkId?: NetworkId; mina: string | string[]; archive?: string | string[]; lightnetAccountManager?: string; bypassTransactionLimits?: boolean; minaDefaultHeaders?: HeadersInit; archiveDefaultHeaders?: HeadersInit; } | string ): Mina { let minaNetworkId: NetworkId = 'devnet'; let minaGraphqlEndpoint: string; let archiveEndpoint: string; let lightnetAccountManagerEndpoint: string; let enforceTransactionLimits: boolean = true; if (options && typeof options === 'string') { minaGraphqlEndpoint = options; Fetch.setGraphqlEndpoint(minaGraphqlEndpoint); } else if (options && typeof options === 'object') { if (options.networkId) { minaNetworkId = options.networkId; } if (!options.mina) throw new Error("Network: malformed input. Please provide an object with 'mina' endpoint."); if (Array.isArray(options.mina) && options.mina.length !== 0) { minaGraphqlEndpoint = options.mina[0]; Fetch.setGraphqlEndpoint(minaGraphqlEndpoint, options.minaDefaultHeaders); Fetch.setMinaGraphqlFallbackEndpoints(options.mina.slice(1)); } else if (typeof options.mina === 'string') { minaGraphqlEndpoint = options.mina; Fetch.setGraphqlEndpoint(minaGraphqlEndpoint, options.minaDefaultHeaders); } if (options.archive !== undefined) { if (Array.isArray(options.archive) && options.archive.length !== 0) { archiveEndpoint = options.archive[0]; Fetch.setArchiveGraphqlEndpoint(archiveEndpoint, options.archiveDefaultHeaders); Fetch.setArchiveGraphqlFallbackEndpoints(options.archive.slice(1)); } else if (typeof options.archive === 'string') { archiveEndpoint = options.archive; Fetch.setArchiveGraphqlEndpoint(archiveEndpoint, options.archiveDefaultHeaders); } } if ( options.lightnetAccountManager !== undefined && typeof options.lightnetAccountManager === 'string' ) { lightnetAccountManagerEndpoint = options.lightnetAccountManager; Fetch.setLightnetAccountManagerEndpoint(lightnetAccountManagerEndpoint); } if ( options.bypassTransactionLimits !== undefined && typeof options.bypassTransactionLimits === 'boolean' ) { enforceTransactionLimits = !options.bypassTransactionLimits; } } else { throw new Error( "Network: malformed input. Please provide a string or an object with 'mina' and 'archive' endpoints." ); } return { getNetworkId: () => minaNetworkId, getNetworkConstants() { if (currentTransaction()?.fetchMode === 'test') { Fetch.markNetworkToBeFetched(minaGraphqlEndpoint); const genesisConstants = Fetch.getCachedGenesisConstants(minaGraphqlEndpoint); return genesisConstants !== undefined ? genesisToNetworkConstants(genesisConstants) : defaultNetworkConstants; } if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') { const genesisConstants = Fetch.getCachedGenesisConstants(minaGraphqlEndpoint); if (genesisConstants !== undefined) return genesisToNetworkConstants(genesisConstants); } return defaultNetworkConstants; }, /** * Returns the current slot number. * * For LocalBlockchain, this always works. * For remote networks, requires cached network state populated by: * - `Mina.transaction()` - automatically fetches and caches network state * - `fetchLastBlock()` - but note this already returns `globalSlotSinceGenesis`, making `currentSlot()` redundant * * @throws {Error} If called on a remote network without cached data. Use `fetchCurrentSlot()` instead. */ currentSlot() { if (currentTransaction()?.fetchMode === 'test') { Fetch.markNetworkToBeFetched(minaGraphqlEndpoint); let network = Fetch.getCachedNetwork(minaGraphqlEndpoint); return network?.globalSlotSinceGenesis ?? UInt32.from(0); } if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') { let network = Fetch.getCachedNetwork(minaGraphqlEndpoint); if (network !== undefined) return network.globalSlotSinceGenesis; } throw Error( `currentSlot: Could not fetch current slot from graphql endpoint ${minaGraphqlEndpoint} outside of a transaction.\n` + 'To query the current slot outside of a transaction, import `fetchCurrentSlot` from o1js and call it with your GraphQL endpoint.\n' + "You can fetch the global slot since genesis (default) or the epoch-relative slot by passing 'epoch' as the second parameter." ); }, hasAccount(publicKey: PublicKey, tokenId: Field = TokenId.default) { if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') { return !!Fetch.getCachedAccount(publicKey, tokenId, minaGraphqlEndpoint); } return false; }, getAccount(publicKey: PublicKey, tokenId: Field = TokenId.default) { if (currentTransaction()?.fetchMode === 'test') { Fetch.markAccountToBeFetched(publicKey, tokenId, minaGraphqlEndpoint); let account = Fetch.getCachedAccount(publicKey, tokenId, minaGraphqlEndpoint); return account ?? dummyAccount(publicKey); } if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') { let account = Fetch.getCachedAccount(publicKey, tokenId, minaGraphqlEndpoint); if (account !== undefined) return account; } throw Error( `${reportGetAccountError( publicKey.toBase58(), TokenId.toBase58(tokenId) )}\nGraphql endpoint: ${minaGraphqlEndpoint}` ); }, /** * Returns the current network state. * * For LocalBlockchain, this always works. * For remote networks, requires cached network state populated by: * - `Mina.transaction()` - automatically fetches and caches network state * - `fetchLastBlock()` - explicitly fetches and caches network state * * @throws {Error} If called on a remote network without cached data. */ getNetworkState() { if (currentTransaction()?.fetchMode === 'test') { Fetch.markNetworkToBeFetched(minaGraphqlEndpoint); let network = Fetch.getCachedNetwork(minaGraphqlEndpoint); return network ?? defaultNetworkState(); } if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') { let network = Fetch.getCachedNetwork(minaGraphqlEndpoint); if (network !== undefined) return network; } throw Error( `getNetworkState: Could not fetch network state from graphql endpoint ${minaGraphqlEndpoint} outside of a transaction.` ); }, sendTransaction(txn) { return toPendingTransactionPromise(async () => { if (enforceTransactionLimits) verifyTransactionLimits(txn.transaction); let [response, error] = await Fetch.sendZkapp(txn.toJSON()); let errors: string[] = []; if (response === undefined && error !== undefined) { errors = [JSON.stringify(error)]; } else if (response && response.errors && response.errors.length > 0) { response?.errors.forEach((e: any) => errors.push(JSON.stringify(e))); } const updatedErrors = humanizeErrors(errors); const status: PendingTransactionStatus = errors.length === 0 ? 'pending' : 'rejected'; let mlTest = await Test(); const hash = mlTest.transactionHash.hashZkAppCommand(txn.toJSON()); const pendingTransaction: Omit = { status, data: response?.data, errors: updatedErrors, transaction: txn.transaction, setFee: txn.setFee, setFeePerSnarkCost: txn.setFeePerSnarkCost, 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 createIncludedTransaction(pendingTransaction, res.blockHeight); } else if (res.failureReason) { const error = invalidTransactionError(txn.transaction, res.failureReason, { accountCreationFee: defaultNetworkConstants.accountCreationFee.toString(), }); return createRejectedTransaction(pendingTransaction, [error]); } } catch (error) { return createRejectedTransaction(pendingTransaction, [(error as Error).message]); } if (maxAttempts && attempts >= maxAttempts) { return createRejectedTransaction(pendingTransaction, [ `Exceeded max attempts.\nTransactionId: ${transactionHash}\nAttempts: ${attempts}\nLast received status: ${res}`, ]); } await new Promise((resolve) => setTimeout(resolve, interval)); return pollTransactionStatus(transactionHash, maxAttempts, interval, attempts + 1); }; // 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 poll = async ( maxAttempts: number = 45, interval: number = 20000 ): Promise => { return pollTransactionStatus(hash, maxAttempts, interval); }; const wait = async (options?: { maxAttempts?: number; interval?: number; }): Promise => { const pendingTransaction = await safeWait(options); if (pendingTransaction.status === 'rejected') { throw Error(`Transaction failed with errors:\n${pendingTransaction.errors.join('\n')}`); } return pendingTransaction; }; const safeWait = async (options?: { maxAttempts?: number; interval?: number; }): Promise => { if (status === 'rejected') { return createRejectedTransaction(pendingTransaction, pendingTransaction.errors); } return await poll(options?.maxAttempts, options?.interval); }; return { ...pendingTransaction, wait, safeWait, }; }); }, transaction(sender: FeePayerSpec, f: () => Promise) { return toTransactionPromise(async () => { // TODO we run the transaction twice to be able to fetch data in between let tx = await createTransaction(sender, f, 0, { fetchMode: 'test', isFinalRunOutsideCircuit: false, }); await Fetch.fetchMissingData(minaGraphqlEndpoint, archiveEndpoint); let hasProofs = tx.transaction.accountUpdates.some(Authorization.hasLazyProof); return await createTransaction(sender, f, 1, { fetchMode: 'cached', isFinalRunOutsideCircuit: !hasProofs, }); }); }, async fetchEvents( publicKey: PublicKey, tokenId: Field = TokenId.default, filterOptions: EventActionFilterOptions = {}, headers?: HeadersInit ) { const pubKey = publicKey.toBase58(); const token = TokenId.toBase58(tokenId); const from = filterOptions.from ? Number(filterOptions.from.toString()) : undefined; const to = filterOptions.to ? Number(filterOptions.to.toString()) : undefined; return Fetch.fetchEvents( { publicKey: pubKey, tokenId: token, from, to }, archiveEndpoint, headers ); }, async fetchActions( publicKey: PublicKey, actionStates?: ActionStates, tokenId: Field = TokenId.default, from?: number, to?: number, headers?: HeadersInit ) { const pubKey = publicKey.toBase58(); const token = TokenId.toBase58(tokenId); const { fromActionState, endActionState } = actionStates ?? {}; const fromActionStateBase58 = fromActionState ? fromActionState.toString() : undefined; const endActionStateBase58 = endActionState ? endActionState.toString() : undefined; return Fetch.fetchActions( { publicKey: pubKey, actionStates: { fromActionState: fromActionStateBase58, endActionState: endActionStateBase58, }, from, to, tokenId: token, }, archiveEndpoint, headers ); }, getActions( publicKey: PublicKey, actionStates?: ActionStates, tokenId: Field = TokenId.default ) { if (currentTransaction()?.fetchMode === 'test') { Fetch.markActionsToBeFetched(publicKey, tokenId, archiveEndpoint, actionStates); let actions = Fetch.getCachedActions(publicKey, tokenId); return actions ?? []; } if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') { let actions = Fetch.getCachedActions(publicKey, tokenId); if (actions !== undefined) return actions; } throw Error(`getActions: Could not find actions for the public key ${publicKey.toBase58()}`); }, proofsEnabled: true, }; } /** * Returns the public key of the current transaction's sender account. * * Throws an error if not inside a transaction, or the sender wasn't passed in. */ function sender() { let tx = currentTransaction(); if (tx === undefined) throw Error( `The sender is not available outside a transaction. Make sure you only use it within \`Mina.transaction\` blocks or smart contract methods.` ); let sender = currentTransaction()?.sender; if (sender === undefined) throw Error( `The sender is not available, because the transaction block was created without the optional \`sender\` argument. Here's an example for how to pass in the sender and make it available: Mina.transaction(sender, // <-- pass in sender's public key here () => { // methods can use this.sender }); ` ); return sender; } function dummyAccount(pubkey?: PublicKey): Account { let dummy = Types.Account.empty(); if (pubkey) dummy.publicKey = pubkey; return dummy; } async function waitForFunding( address: string, network: string, headers?: HeadersInit ): Promise { let tag = `Mina Faucet: ${network.replace(/^\w/, (c) => c.toUpperCase())}:`; // Devnet: ~3 min slot time, can stretch to 15-20 min when unstable // Mesa: ~90s slot time, can stretch to 6-10 min when unstable let interval = network === 'mesa' ? 30000 : 60000; let maxAttempts = network === 'mesa' ? 20 : 25; let attempts = 0; let maxWaitMin = (maxAttempts * interval) / 60000; console.log( `${tag} Waiting for funding to complete (polling every ${interval / 1000}s, up to ${maxWaitMin} min)` ); const executePoll = async (resolve: () => void, reject: (err: Error) => void | Error) => { let { account } = await Fetch.fetchAccount({ publicKey: address }, undefined, { headers }); attempts++; if (account) { console.log(`${tag} Account funded successfully.`); return resolve(); } else if (maxAttempts && attempts === maxAttempts) { return reject( new Error( `${tag} Timed out after ${maxWaitMin} min waiting for account ${address} to be funded. ` + `The transaction may still be pending — the network might be slow or unstable.` ) ); } else { setTimeout(executePoll, interval, resolve, reject); } }; return new Promise(executePoll); } // Cached promise for the compiled faucet challenge circuit let faucetCircuitPromise: Promise | null = null; async function getCompiledFaucetCircuit() { if (!faucetCircuitPromise) { faucetCircuitPromise = (async () => { const { ZkFunction } = await import('../../proof-system/zkfunction.js'); const sumToOneHundred = ZkFunction({ name: 'sumToOneHundred', publicInputType: Field, privateInputTypes: [Field], main: (challenge: Field, userNumber: Field) => { challenge.assertGreaterThanOrEqual(Field(0)); challenge.assertLessThan(Field(100)); userNumber.assertGreaterThanOrEqual(Field(1)); userNumber.assertLessThanOrEqual(Field(100)); const sum = challenge.add(userNumber); sum.assertEquals(Field(100)); }, }); console.log('Compiling faucet challenge circuit...'); await sumToOneHundred.compile(); console.log('Faucet challenge circuit compiled.'); return sumToOneHundred; })(); } return faucetCircuitPromise; } /** * Requests the [testnet faucet](https://faucet.minaprotocol.com/api/v1/faucet) to fund a public key. * * Solves a ZK captcha challenge (sum-to-100 proof) before submitting the funding request. * The first call compiles the ZK circuit (~30-60s), subsequent calls reuse the cached circuit. * * @param pub - The public key to fund. * @param network - The network to fund on: `devnet` (default) or `mesa`. * @param headers - Optional headers passed to `fetchAccount` when polling for funding confirmation. * * @throws `rate-limit` — The address has already been funded on this network (one funding per address). * @throws `rate-limit-ip` — Too many faucet requests from this IP (max 5/hour, 10/day). * @throws `forbidden` — The faucet rejected the request origin. * @throws `challenge-required` — The ZK challenge proof was invalid or expired. * * @example * ```ts * // Fund on Devnet (default) * await Mina.faucet(myPublicKey); * * // Fund on Mesa * await Mina.faucet(myPublicKey, 'mesa'); * ``` */ async function faucet(pub: PublicKey, network: string = 'devnet', headers?: HeadersInit) { let address = pub.toBase58(); let tag = `Mina Faucet: ${network.replace(/^\w/, (c) => c.toUpperCase())}:`; let faucetHeaders = { 'Content-Type': 'application/json', Origin: 'https://faucet.minaprotocol.com', }; // Fetch a challenge from the faucet API let challengeResponse = await fetch('https://faucet.minaprotocol.com/api/v1/challenge', { headers: faucetHeaders, }); if (!challengeResponse.ok) { throw new Error( `${tag} Failed to fetch challenge: ${challengeResponse.status} ${challengeResponse.statusText}` ); } let { challenge, challengeId } = (await challengeResponse.json()) as { challenge: number; challengeId: string; expiresAt: string; }; let userAnswer = 100 - challenge; let sumToOneHundred = await getCompiledFaucetCircuit(); let proof = await sumToOneHundred.prove(Field(challenge), Field(userAnswer)); // Submit the faucet request with the challenge solution and proof let faucetResponse = await fetch('https://faucet.minaprotocol.com/api/v1/faucet', { method: 'POST', headers: faucetHeaders, body: JSON.stringify({ network, address, challengeId, userAnswer, proof: proof.toJSON(), }), }); let result = (await faucetResponse.json()) as { status: string; message?: { paymentID: string }; reason?: string; }; if (result.status !== 'success') { let details: Record = { 'rate-limit': 'The address has already been funded on this network (one funding per address).', 'rate-limit-ip': 'Too many faucet requests from this IP (max 5/hour, 10/day).', forbidden: 'The faucet rejected the request origin.', 'challenge-required': 'The ZK challenge proof was invalid or expired.', }; let message = details[result.status] ?? 'Unexpected error.'; throw new Error(`${tag} ${message} ${JSON.stringify(result)}`); } let txHash = result.message?.paymentID ?? 'unknown'; let explorerUrl = network === 'mesa' ? `https://mesa.minaexplorer.com/transaction/${txHash}` : `https://minascan.io/devnet/tx/${txHash}`; console.log(`${tag} Funded ${address}\n ${explorerUrl}`); await waitForFunding(address, network, headers); } function genesisToNetworkConstants(genesisConstants: Fetch.GenesisConstants): NetworkConstants { return { genesisTimestamp: UInt64.from(Date.parse(genesisConstants.genesisTimestamp)), slotTime: UInt64.from(genesisConstants.slotDuration), accountCreationFee: UInt64.from(genesisConstants.accountCreationFee), }; }