o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
642 lines (606 loc) • 23.9 kB
text/typescript
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<void>) {
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<PendingTransaction, 'wait' | 'safeWait'> = {
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<IncludedTransaction | RejectedTransaction> => {
let res: Awaited<ReturnType<typeof Fetch.checkZkappTransaction>>;
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<IncludedTransaction | RejectedTransaction> => {
return pollTransactionStatus(hash, maxAttempts, interval);
};
const wait = async (options?: {
maxAttempts?: number;
interval?: number;
}): Promise<IncludedTransaction> => {
const pendingTransaction = await safeWait(options);
if (pendingTransaction.status === 'rejected') {
throw Error(`Transaction failed with errors:\n${pendingTransaction.errors.join('\n')}`);
}
return pendingTransaction;
};
const safeWait = async (options?: {
maxAttempts?: number;
interval?: number;
}): Promise<IncludedTransaction | RejectedTransaction> => {
if (status === 'rejected') {
return createRejectedTransaction(pendingTransaction, pendingTransaction.errors);
}
return await poll(options?.maxAttempts, options?.interval);
};
return {
...pendingTransaction,
wait,
safeWait,
};
});
},
transaction(sender: FeePayerSpec, f: () => Promise<void>) {
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<void> {
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<any> | 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<string, string> = {
'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),
};
}