o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
448 lines (447 loc) • 23.1 kB
JavaScript
import { Test } from '../../../bindings.js';
import { Types } from '../../../bindings/mina-transaction/v1/types.js';
import { UInt32, UInt64 } from '../../provable/int.js';
import { Field } from '../../provable/wrapped.js';
import { Authorization, TokenId } from './account-update.js';
import { humanizeErrors, invalidTransactionError } from './errors.js';
import * as Fetch from './fetch.js';
import { LocalBlockchain, TestPublicKey } from './local-blockchain.js';
import { activeInstance, currentSlot, defaultNetworkConstants, fetchActions, fetchEvents, getAccount, getActions, getBalance, getNetworkConstants, getNetworkId, getNetworkState, getProofsEnabled, hasAccount, setActiveInstance, } 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, } from './transaction.js';
export { 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, };
// patch active instance so that we can still create basic transactions without giving Mina network details
setActiveInstance({
...activeInstance,
transaction(sender, f) {
return toTransactionPromise(() => createTransaction(sender, f, 0));
},
});
function Network(options) {
let minaNetworkId = 'devnet';
let minaGraphqlEndpoint;
let archiveEndpoint;
let lightnetAccountManagerEndpoint;
let enforceTransactionLimits = 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, tokenId = TokenId.default) {
if (!currentTransaction.has() || currentTransaction.get().fetchMode === 'cached') {
return !!Fetch.getCachedAccount(publicKey, tokenId, minaGraphqlEndpoint);
}
return false;
},
getAccount(publicKey, tokenId = 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 = [];
if (response === undefined && error !== undefined) {
errors = [JSON.stringify(error)];
}
else if (response && response.errors && response.errors.length > 0) {
response?.errors.forEach((e) => errors.push(JSON.stringify(e)));
}
const updatedErrors = humanizeErrors(errors);
const status = errors.length === 0 ? 'pending' : 'rejected';
let mlTest = await Test();
const hash = mlTest.transactionHash.hashZkAppCommand(txn.toJSON());
const pendingTransaction = {
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, maxAttempts, interval, attempts = 0) => {
let res;
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.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 = 45, interval = 20000) => {
return pollTransactionStatus(hash, maxAttempts, interval);
};
const wait = async (options) => {
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) => {
if (status === 'rejected') {
return createRejectedTransaction(pendingTransaction, pendingTransaction.errors);
}
return await poll(options?.maxAttempts, options?.interval);
};
return {
...pendingTransaction,
wait,
safeWait,
};
});
},
transaction(sender, f) {
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, tokenId = TokenId.default, filterOptions = {}, headers) {
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, actionStates, tokenId = TokenId.default, from, to, headers) {
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, actionStates, tokenId = 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) {
let dummy = Types.Account.empty();
if (pubkey)
dummy.publicKey = pubkey;
return dummy;
}
async function waitForFunding(address, network, headers) {
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, reject) => {
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 = 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, userNumber) => {
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, network = 'devnet', headers) {
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());
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());
if (result.status !== 'success') {
let details = {
'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) {
return {
genesisTimestamp: UInt64.from(Date.parse(genesisConstants.genesisTimestamp)),
slotTime: UInt64.from(genesisConstants.slotDuration),
accountCreationFee: UInt64.from(genesisConstants.accountCreationFee),
};
}
//# sourceMappingURL=mina.js.map