@nori-zk/mina-token-bridge
Version:
Nori ethereum state settelment and nETH token bridge zkApp
959 lines (958 loc) • 71.8 kB
JavaScript
var _TokenBridgeWorker_minaPrivateKey, _TokenBridgeWorker_mintProofCache;
import { __classPrivateFieldGet, __classPrivateFieldSet } from "tslib";
// Must be imported BEFORE o1js so that when o1js creates its
// FinalizationRegistry (kimchi_bindings/js/bindings/util.js), it gets this
// no-op class. With IDB-cached compile, o1js's decodeProverKey creates WASM
// wrappers that share underlying pointers; the original finalizer frees a
// pointer the prover still holds after the first prove(), causing dangling
// WASM memory on subsequent proves. Disabling auto-free is safe — WASM heap
// is reclaimed when the worker is torn down on page refresh.
// https://github.com/o1-labs/o1js/issues/2870
globalThis.FinalizationRegistry = class {
register() { }
unregister() {
return false;
}
};
import { Logger, LogPrinter } from 'esm-iso-logger';
import { CacheType, compileAndOptionallyVerifyContracts, decodeConsensusMptProof, EthInput, NodeProofLeft, } from '@nori-zk/o1js-zk-utils';
import { AccountUpdate, CircuitString, fetchAccount, Field, Mina, PrivateKey, PublicKey, Signature, Transaction, Cache } from 'o1js';
import { NoriStorageInterface } from '../../NoriStorageInterface.js';
import { FungibleToken } from '../../TokenBase.js';
import { NoriTokenBridge } from '../../NoriTokenBridge.js';
import { buildMerkleTreeContractDepositAttestorInput, computeDepositAttestationWitness, } from '../../depositAttestation.js';
import { codeChallengeFieldToBEHex, createCodeChallenge, SCRAMWitness, } from '../../scram.js';
import { noriStorageInterfaceVkHash } from '../../integrity/NoriStorageInterface.VkHash.js';
import { fungibleTokenVkHash } from '../../integrity/FungibleToken.VkHash.js';
import { noriTokenBridgeVkHash } from '../../integrity/NoriTokenBridge.VkHash.js';
import { NoriStorageInterfaceCacheLayout, FungibleTokenCacheLayout, NoriTokenBridgeCacheLayout, } from '../../cache-layouts/index.js';
import { cacheFactory } from '@nori-zk/o1js-zk-utils';
// `NoriTokenBridgeCacheLayout` is currently only referenced by the
// cached branch of `compileMinterDeps`, which is disabled until the
// o1js browser compilation cache is reliable. The `void` expression
// is here purely to silence the unused-import warning in the
// meantime; once the cached path is re-enabled the import is
// referenced by real call-site code and this line can be removed.
void NoriTokenBridgeCacheLayout;
// Initialise the side-effect log printer for this worker's logger
// namespace. Constructing the printer is what wires it into the
// shared logger infrastructure; the instance itself is not retained.
new LogPrinter('TokenBridgeWorker');
// Module-scoped logger shared by the top-level bootstrap trace below
// and by every method on `TokenBridgeWorker`.
const logger = new Logger('TokenBridgeWorker');
/**
* Detects whether this module is currently executing inside a browser
* runtime, covering both the main thread and a Web Worker context.
* Returns `false` in Node (including Node worker threads).
*
* Used by `compileMinterDeps` to gate browser-only network cache
* fetches (that cached path is currently disabled -- see the
* `TokenBridgeWorker` class docstring), and by the bootstrap trace
* below to record which runtime the worker was instantiated in.
*
* @returns `true` when running in a browser main thread or Web
* Worker, `false` otherwise (e.g. Node, or any environment that
* lacks both a DOM and Web Worker-style `importScripts`).
*/
export function isBrowser() {
return (typeof self !== 'undefined' &&
((typeof window !== 'undefined' &&
typeof window.document !== 'undefined') || // main thread
(typeof self !== 'undefined' &&
'importScripts' in self &&
typeof self.importScripts === 'function')) // worker
);
}
// Bootstrap trace emitted once on worker-bundle load so logs make it
// clear which runtime the worker was instantiated in.
logger.log('Constructing TokenBridgeWorker. isBrowser:', isBrowser());
console.log('FR is patched?', new FinalizationRegistry(() => { }).register.toString());
/**
* # TokenBridgeWorker
*
* Off-main-thread o1js bridge worker. Runs inside a browser Web Worker
* (or a Node worker thread) behind the RPC bridge in `workers/defs.ts`,
* wired through `workers/tokenBridgeWorker/{parent,child}.ts`.
*
* ## RPC surface and serialisation
*
* Every public (non-`#private`) method on this class is reachable by
* the client over the RPC bridge. Because every call crosses a worker
* boundary, every parameter and every return value on every public
* method must be serialisation-safe: base58 / hex / decimal strings,
* plain numbers and booleans, and plain JSON objects composed of the
* same. No class instances cross the boundary -- not `Field`, not
* `PublicKey`, not `Signature`, not `Mina.Transaction`, not
* `VerificationKey`. Methods reconstruct those types from their
* serialised form at call entry and re-serialise on return. If you add
* a new public method, its signature and return type must honour this
* invariant.
*
* There is no framework-level gate on which methods a client can reach.
* The method-name prefixes below are a developer-facing convention that
* says when each method is appropriate to call. Each method's docstring
* states explicitly when it must not be called. Renaming any public
* method is a client-visible API change.
*
* ## Production flow vs test flow
*
* Each stateful tx-producing operation (storage setup, mint) exists
* under a production flow and one or more test flows. The test flows
* stand in for the parts of the production flow that live outside
* the worker (most importantly, the user's real wallet).
*
* ### Production flow (target public API)
*
* A real frontend calls a target-API method (`setupStorage`, `mint`)
* and receives the proved transaction as a JSON string. The frontend
* then hands that JSON to the user's real wallet (e.g. Auro), which
* signs and submits it. The wallet lives entirely outside this file;
* the worker has no counterpart method for the sign-and-send half of
* this flow. The target-API method is the last worker call in the
* production flow for that operation.
*
* ### Test flow: single-call mock
*
* Integration specs inject a throwaway private key into the worker via
* `WALLET_setMinaPrivateKey`, then call `MOCK_setupStorage` /
* `MOCK_mint`. Those collapse build, prove, sign and send into one
* in-worker call using the throwaway key. Used when the spec does not
* need to mirror the production call shape.
*
* ### Test flow: split-stage mock
*
* Integration specs that do want to mirror the production call shape
* at the spec level use the pair
* `MOCK_computeMintProofAndCache` (build-and-prove half, caches the
* in-memory `Mina.Transaction` on the instance) followed by
* `WALLET_MOCK_signAndSendMintProofCache` (sign-and-send half, using
* the throwaway key). The handoff between the two is via an
* in-instance field rather than JSON, which sidesteps the o1js blocker
* described below. When that blocker is fixed, those specs migrate to
* `mint` + `WALLET_signAndSend` as a structurally identical pair.
*
* ### Wallet emulation (`WALLET_signAndSend`)
*
* `WALLET_signAndSend` is the aspirational wallet-emulation companion
* to the target-API methods: it takes the returned JSON tx, signs it
* with the worker-held throwaway key, and submits it. Test-only -- in
* tests it replaces the real wallet that a production frontend would
* use. A production frontend never calls `WALLET_signAndSend`, because
* in production the real wallet performs that step. Currently blocked
* by the `Transaction.fromJSON` issue described below.
*
* ## Upstream o1js blockers
*
* Two o1js limitations shape the test-flow scaffolding:
*
* - `Transaction.fromJSON` does not round-trip a proved transaction
* with its `lazyAuthorization` intact, so `WALLET_signAndSend`
* cannot reconstruct a tx that was proved in-worker and then
* serialised. The in-progress attempt is preserved in the commented
* block near `deserializeTransaction` as a reference for the next
* attempt. Until this is fixed, split-stage test flows pass the
* proved tx between worker calls via an in-instance field
* (`#mintProofCache`) rather than JSON.
*
* - The o1js browser compilation cache does not function reliably, so
* `compileMinterDeps` force-delegates to `compileMinterDepsNoCache`.
* The cache-enabled body is retained in full so it can be
* reactivated with a single edit once the upstream issue is resolved.
*
* ## Method-name prefix key
*
* Safe in production code:
*
* - no prefix: target public API. Builds and proves a transaction and
* returns it as a JSON string; the user's real wallet signs and
* submits it.
* - `SCRAM_`: pure SCRAM helpers (code-challenge creation,
* field-to-hex conversion). No wallet state required.
*
* Test-only -- must not be called from production frontend code:
*
* - `WALLET_setMinaPrivateKey`: injects a throwaway private key into
* the worker so subsequent `WALLET_`-prefixed methods can stand in
* for a real wallet.
* - `WALLET_signAndSend`: wallet-emulation sign-and-send for a JSON tx
* returned by a target-API method. Replaces the real wallet in
* tests; never called from production. Currently blocked by the
* o1js `Transaction.fromJSON` issue above.
* - `MOCK_`: single-call test variant of a target-API method
* (`MOCK_setupStorage`, `MOCK_mint`) that collapses the full build,
* prove, sign-and-send pipeline into one in-worker call.
* - `WALLET_MOCK_`: wallet-emulation half of a split-stage mock,
* paired with a `MOCK_` proof-compute step that caches the proved
* tx in-instance.
* - `MOCK_SCRAM_`: SCRAM helper that signs using the worker-held key.
* Production code must use the real wallet for SCRAM signing.
*/
export class TokenBridgeWorker {
constructor() {
// ================================================================
// Wallet interop (test-only)
// ================================================================
//
// This section stands in for an external wallet during tests. In
// production the user's real wallet (e.g. Auro) consumes the JSON
// transaction returned by a target-API method (`setupStorage`,
// `mint`) and performs the sign-and-send step outside the worker.
//
// The target behaviour we want to mirror in tests is: accept a
// proved transaction as a JSON string, deserialise it with its
// `lazyAuthorization` intact, sign it with the user's key, and
// submit it. `Transaction.fromJSON` in o1js does not currently
// round-trip a proved transaction's `lazyAuthorization`, so
// `WALLET_signAndSend` cannot yet be driven end-to-end from JSON.
// The commented `deserializeTransaction` block below is retained
// as reference material for the next attempt once the upstream
// issue is resolved.
//
// None of the methods in this section are appropriate to call
// from production frontend code.
// Initialise method used for MOCK methods
_TokenBridgeWorker_minaPrivateKey.set(this, void 0);
// ================================================================
// Split-stage mint (test-only)
// ================================================================
//
// Two-call test scaffolding that mirrors the production call
// shape at the spec level: a prove half and a sign-and-send half,
// with the proved transaction passed between them.
//
// `MOCK_computeMintProofAndCache` builds and proves the mint
// transaction and stores it on the instance in `#mintProofCache`;
// `WALLET_MOCK_signAndSendMintProofCache` then signs that cached
// transaction with the throwaway key and submits it. The handoff
// is via an in-instance field rather than JSON, which sidesteps
// the o1js `Transaction.fromJSON` blocker. When that blocker is
// fixed, specs migrate to `mint` + `WALLET_signAndSend`, which is
// the same two-call shape but with JSON on the wire.
//
// Both methods are test-only.
_TokenBridgeWorker_mintProofCache.set(this, void 0);
}
/**
* Inject a throwaway Mina private key into the worker so that
* subsequent `WALLET_`-prefixed and `MOCK_`-prefixed methods can
* stand in for a real wallet during tests. The key is held in a
* private class field and never leaves the worker.
*
* Must be called exactly once per worker instance. A second call
* throws rather than silently replacing the key, to avoid
* test-harness bugs where one spec's key bleeds into another.
*
* Not appropriate to call from production frontend code: a real
* frontend never hands a private key to the worker, because the
* user's real wallet performs all signing outside this file.
*
* @param minaPrivateKeyBase58 Base58-encoded Mina `PrivateKey` of
* the account that will pay fees for and authorise transactions
* produced by the `MOCK_` and `WALLET_MOCK_` methods.
* @returns Resolves once the key has been installed.
* @throws `Error` if a private key has already been set on this
* worker instance.
*/
async WALLET_setMinaPrivateKey(minaPrivateKeyBase58) {
if (__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f"))
throw new Error('Mina private key has already been set.');
__classPrivateFieldSet(this, _TokenBridgeWorker_minaPrivateKey, PrivateKey.fromBase58(minaPrivateKeyBase58), "f");
}
/**
* Work-in-progress reconstruction of a proved transaction from its
* serialised JSON form, retained as reference material for the
* next attempt once o1js supports round-tripping
* `lazyAuthorization`.
*
* The approach: deserialise the JSON with `Mina.Transaction.fromJSON`
* to recover the structural transaction, then reattach the
* `lazyAuthorization` (and, where present, its `blindingValue`)
* from a freshly-built sibling transaction `txNew` that still
* carries that state in memory. This is needed because
* `Transaction.fromJSON` drops `lazyAuthorization`, which the
* signer needs to complete account-update authorisation.
*
* Disabled until the upstream o1js blocker is resolved.
*
* @param serializedTransaction JSON string carrying `{ tx,
* blindingValues, length }`: `tx` is the stringified proved
* transaction, `blindingValues` is the per-account-update
* blinding-value array (empty strings where absent), and
* `length` is the expected account-update count used as a
* sanity check against both the fresh sibling and the
* deserialised transaction.
* @returns The reconstructed `Mina.Transaction` with
* `lazyAuthorization` restored on every account update.
* @throws `Error` if the serialised transaction's account-update
* count disagrees with either the freshly-built sibling or
* itself.
*/
/*private deserializeTransaction(serializedTransaction: string) {
const { tx, blindingValues, length } = JSON.parse(
serializedTransaction
);
const parsedTx = JSON.parse(tx);
const transaction = Mina.Transaction.fromJSON(
parsedTx
) as Mina.Transaction<false, false>;
if (length !== txNew.transaction.accountUpdates.length) {
throw new Error('New Transaction length mismatch');
}
if (length !== transaction.transaction.accountUpdates.length) {
throw new Error('Serialized Transaction length mismatch');
}
for (let i = 0; i < length; i++) {
transaction.transaction.accountUpdates[i].lazyAuthorization =
txNew.transaction.accountUpdates[i].lazyAuthorization;
if (blindingValues[i] !== '')
(
transaction.transaction.accountUpdates[i]
.lazyAuthorization as any
).blindingValue = Field.fromJSON(blindingValues[i]);
}
return transaction;
}*/
/**
* Minimal reconstruction of a transaction from its serialised JSON
* form, using the stock `Transaction.fromJSON` with no
* `lazyAuthorization` reattachment. Called by
* `WALLET_signAndSend` as the deserialisation step prior to
* signing.
*
* This form does not round-trip a proved transaction's
* `lazyAuthorization` and is the direct cause of
* `WALLET_signAndSend` being non-functional today; see the
* class-level docstring for the o1js blocker. The commented-out
* `payload` block below sketches an alternative shape (a signer
* payload with `onlySign: true`, `feePayer.fee` and
* `feePayer.memo`) kept for reference.
*
* Private helper: not reachable over the RPC surface, used only
* by `WALLET_signAndSend`.
*
* @param serializedTransaction JSON string produced by
* `provedTx.toJSON()` on a target-API method's return value.
* @returns The `Transaction` reconstructed by
* `Transaction.fromJSON`, suitable (in principle) for signing
* and submission.
*/
deserializeTransaction(serializedTransaction) {
return Transaction.fromJSON(serializedTransaction);
}
/**
* Wallet-emulation sign-and-send for a proved transaction returned
* by a target-API method (`setupStorage`, `mint`). Reconstructs
* the transaction from JSON, signs it with the throwaway key
* installed by `WALLET_setMinaPrivateKey`, submits it to the
* network, and waits for inclusion.
*
* Exists so integration tests can mirror the exact shape of the
* production call site, where the frontend hands the JSON to the
* user's real wallet and the wallet performs sign-and-send out of
* process. In production the real wallet replaces this call
* entirely; a production frontend never invokes
* `WALLET_signAndSend`.
*
* Currently non-functional because `Transaction.fromJSON` does
* not round-trip a proved transaction's `lazyAuthorization` (see
* the class-level docstring). While this is blocked, split-stage
* tests use `MOCK_computeMintProofAndCache` +
* `WALLET_MOCK_signAndSendMintProofCache` instead, which pass the
* proved transaction via an in-instance field rather than JSON.
*
* Not appropriate to call from production frontend code.
*
* @param provedTxJsonStr JSON string produced by
* `provedTx.toJSON()` inside a target-API method, transported
* back into the worker as a serialisation-safe string.
* @returns An object carrying the submitted transaction's hash as
* `{ txHash }`.
* @throws `Error` if `WALLET_setMinaPrivateKey` has not yet been
* called on this worker instance.
*/
async WALLET_signAndSend(provedTxJsonStr) {
if (!__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f"))
throw new Error('#minaPrivateKey is undefined please call setMinaPrivateKey first');
const tx = Transaction.fromJSON(provedTxJsonStr);
const result = await tx.sign([__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f")]).send().wait();
return { txHash: result.hash };
}
// ================================================================
// Mina setup
// ================================================================
//
// Installs a `Mina.Network` instance as the process-wide active
// instance so every subsequent call on this worker (fetches,
// transactions, waits) targets the configured network endpoints.
// Must be called before any method that talks to the chain.
/**
* Configure and activate the Mina network that this worker will
* target for every subsequent account fetch, transaction build,
* and transaction submission. The new network becomes the
* process-wide active instance via `Mina.setActiveInstance`.
*
* Must be called before any method that reads from or writes to
* the chain (`needsToSetupStorage`, `setupStorage`, `mint`,
* `getBalanceOf`, etc.). Safe to re-invoke to switch networks;
* the latest call wins.
*
* @param options Plain-object configuration passed straight to
* `Mina.Network`:
* - `networkId`: optional Mina network id; defaults to the o1js
* default when omitted.
* - `mina`: single node URL or array of node URLs for the JSON
* RPC endpoint.
* - `archive`: single archive URL or array of archive URLs for
* historical action and event queries.
* - `lightnetAccountManager`: optional lightnet account-manager
* URL for local development networks.
* - `bypassTransactionLimits`: optional flag that relaxes o1js
* transaction-size limits, intended for local testing.
* - `minaDefaultHeaders`, `archiveDefaultHeaders`: optional
* default headers applied to every node / archive request
* (e.g. auth tokens).
* @returns Resolves once the active instance has been installed.
*/
async minaSetup(options) {
const Network = Mina.Network(options);
Mina.setActiveInstance(Network);
}
// ================================================================
// Utilities
// ================================================================
//
// Shared helpers used across tx-producing methods. Private to the
// class and not reachable over the RPC surface.
/**
* Fetch the current on-chain state for a batch of accounts in
* parallel, hydrating the o1js account cache so subsequent
* `Mina.transaction` builds can read their state synchronously.
* Tx-producing methods call this at entry against the accounts
* they will read from or modify (typically the sender and the
* `NoriTokenBridge` address, optionally under a specific tokenId
* via direct `fetchAccount` calls elsewhere).
*
* Private helper: not reachable over the RPC surface.
*
* @param accounts Array of `PublicKey` instances to fetch. Each
* is fetched under the default tokenId; callers needing a
* non-default tokenId invoke `fetchAccount` directly rather
* than going through this helper.
* @returns Resolves once every fetch has settled. Individual
* fetch failures propagate because `Promise.all` short-circuits
* on the first rejection.
*/
async fetchAccounts(accounts) {
await Promise.all(accounts.map((addr) => fetchAccount({ publicKey: addr })));
}
// ================================================================
// Deposit attestation
// ================================================================
//
// Thin worker-side wrapper around the deposit-attestation witness
// builder. The witness proves, to the `NoriTokenBridge` circuit,
// that a particular Ethereum deposit occurred at a particular
// block and was bound to a specific SCRAM code challenge. The
// heavy lifting lives in `../../depositAttestation.js`; this
// method exists so the call can be made from the client over the
// RPC surface with serialisation-safe inputs.
/**
* Compute the deposit-attestation witness for a given SCRAM code
* challenge and deposit block, ready to be fed into the mint
* circuit. Converts the serialisable code-challenge form into the
* big-endian hex form that the attestor expects, then delegates
* to `computeDepositAttestationWitness` from the depositAttestation
* module.
*
* Safe to call from production frontend code.
*
* @param codeChallengeSCRAM Decimal string form of the SCRAM code
* challenge `Field`, as produced by `SCRAM_createCodeChallenge`.
* @param depositBlockNumber Ethereum block number at which the
* deposit event was emitted. Used by the attestor to locate the
* deposit in the chain's action history.
* @param domain Attestation-service domain used to look up the
* deposit proof. Defaults to the Nori production PCS endpoint;
* override for staging, local development, or test harnesses.
* @returns The `MerkleTreeContractDepositAttestorInputJson` that
* serialises the witness for transport back over the RPC
* surface. Re-hydrated by `mint` / `MOCK_mint` via
* `buildMerkleTreeContractDepositAttestorInput`.
*/
async computeDepositAttestationWitness(codeChallengeSCRAM, depositBlockNumber, domain = 'https://pcs.nori.it.com') {
const codeChallengeBigInt = BigInt(codeChallengeSCRAM);
const codeChallengeField = new Field(codeChallengeBigInt);
const codeChallengeFieldBEHex = codeChallengeFieldToBEHex(codeChallengeField);
return computeDepositAttestationWitness(depositBlockNumber, codeChallengeFieldBEHex, domain);
}
// ================================================================
// Storage setup
// ================================================================
//
// A user account's `NoriStorageInterface` subtree must be funded
// and initialised once before it can participate in mints. This
// section provides:
//
// - a read-only probe (`needsToSetupStorage`) that decides whether
// setup has already happened, used by clients to skip the step
// when it is not needed;
// - the target public API (`setupStorage`) that builds and proves
// the setup transaction and returns it as JSON for an external
// wallet to sign and submit;
// - a single-call test mock (`MOCK_setupStorage`) that signs and
// submits in-worker using the throwaway key from
// `WALLET_setMinaPrivateKey`. The mock goes away once
// `WALLET_signAndSend` is unblocked.
/**
* Probe whether the given Mina account needs its
* `NoriStorageInterface` subtree set up for the given
* `NoriTokenBridge`. Storage setup only needs to be done once per
* account, so clients call this before `setupStorage` to decide
* whether the setup step can be skipped. Fetches the storage
* account under the
* bridge's derived tokenId and checks for the presence of
* `userKeyHash`; if fetch or read fails for any reason the probe
* errs on the side of "setup is needed" so the caller will not
* silently skip a required step. Also logs the current
* `mintedSoFar` value when setup is already in place.
*
* Safe to call from production frontend code.
* A `true` return means a subsequent `setupStorage` call is
* required before the account can mint.
*
* @param noriTokenBridgeAddressBase58 Base58 address of the
* `NoriTokenBridge` zkApp whose storage subtree is being
* probed.
* @param minaSenderPublicKeyBase58 Base58 public key of the
* Mina account being probed.
* @returns `false` when the storage interface is already set up
* (userKeyHash present and readable); `true` when setup is
* needed or could not be confirmed.
*/
async needsToSetupStorage(noriTokenBridgeAddressBase58, minaSenderPublicKeyBase58) {
try {
const minaSenderPublicKey = PublicKey.fromBase58(minaSenderPublicKeyBase58);
const noriTokenBridgeAddress = PublicKey.fromBase58(noriTokenBridgeAddressBase58);
const noriTokenBridge = new NoriTokenBridge(noriTokenBridgeAddress);
const storage = new NoriStorageInterface(minaSenderPublicKey, noriTokenBridge.deriveTokenId());
await fetchAccount({
publicKey: minaSenderPublicKey,
tokenId: noriTokenBridge.deriveTokenId(),
});
const userKeyHash = await storage.userKeyHash.fetch();
if (!userKeyHash)
throw new Error('userKeyHash was falsey');
const mintedSoFar = await storage.mintedSoFar.fetch();
logger.log('mintedSoFar', mintedSoFar.toBigInt());
return false;
}
catch (e) {
const error = e;
logger.log(`Error determining if we needed to setup storage. Going to assume that we do need to.`, error);
// But perhaps this could error for other reasons?!
return true;
}
}
/**
* Target public API for storage setup. Builds and proves the
* setup transaction that funds and initialises the user's
* `NoriStorageInterface` subtree under the given
* `NoriTokenBridge`, then returns the proved transaction as a
* JSON string for the user's real wallet (e.g. Auro) to sign and
* submit. Sign-and-send happens outside this worker; this method
* is the last worker call in the production storage-setup flow.
*
* Hydrates the user and bridge accounts via `fetchAccounts`
* before building the transaction, funds a new on-chain account
* for the storage subtree (`AccountUpdate.fundNewAccount`), and
* invokes `NoriTokenBridge.setUpStorage` with the supplied
* verification key.
*
* Safe to call from production frontend code.
*
* @param userPublicKeyBase58 Base58 public key of the user whose
* storage subtree is being set up. This account pays the fee
* and owns the new storage account update.
* @param noriTokenBridgeAddressBase58 Base58 address of the
* `NoriTokenBridge` zkApp whose storage subtree is being
* initialised.
* @param txFee Fee in nanomina for the setup transaction.
* @param storageInterfaceVerificationKeySafe Serialisation-safe
* `NoriStorageInterface` verification key as returned by
* `compileMinterDeps` / `compileMinterDepsNoCache`
* (`{ data, hashStr }`). The `hashStr` decimal string is
* rehydrated back into a `Field` internally.
* @returns JSON string produced by `provedTx.toJSON()` on the
* proved setup transaction. Fed to the user's real wallet (or,
* in tests, to `WALLET_signAndSend` once that is unblocked).
*/
async setupStorage(userPublicKeyBase58, noriTokenBridgeAddressBase58, txFee, storageInterfaceVerificationKeySafe) {
logger.log('userPublicKeyBase58', userPublicKeyBase58);
const userPublicKey = PublicKey.fromBase58(userPublicKeyBase58);
const noriTokenBridgeAddress = PublicKey.fromBase58(noriTokenBridgeAddressBase58);
const { hashStr: storageInterfaceVerificationKeyHashStr, data } = storageInterfaceVerificationKeySafe;
const storageInterfaceVerificationKeyHashBigInt = BigInt(storageInterfaceVerificationKeyHashStr);
const hash = new Field(storageInterfaceVerificationKeyHashBigInt);
const storageInterfaceVerificationKey = { data, hash };
logger.log(`Setting up storage for user: ${userPublicKey.toBase58()}`);
// DO we need to do this is we are not proving here???
await this.fetchAccounts([userPublicKey, noriTokenBridgeAddress]);
// Note we could have another method to not have to do this multiple times, but keeping it stateless for now.
const noriTokenBridgeInst = new NoriTokenBridge(noriTokenBridgeAddress);
const setupTx = await Mina.transaction({ sender: userPublicKey, fee: txFee }, async () => {
AccountUpdate.fundNewAccount(userPublicKey, 1);
await noriTokenBridgeInst.setUpStorage(userPublicKey, storageInterfaceVerificationKey);
});
const provedTx = await setupTx.prove();
return provedTx.toJSON();
}
/**
* End-to-end test substitute for `setupStorage`. Builds, proves,
* signs and submits the setup transaction in one worker call
* using the throwaway key installed by `WALLET_setMinaPrivateKey`,
* then waits for inclusion and returns the tx hash.
*
* Exists only because `WALLET_signAndSend` is currently blocked by
* the o1js `Transaction.fromJSON` issue; without it, specs have
* no way to drive `setupStorage` + wallet-side sign-and-send as
* two worker calls. Goes away once `WALLET_signAndSend` is
* unblocked and tests migrate to `setupStorage` +
* `WALLET_signAndSend`.
*
* Not appropriate to call from production frontend code.
*
* @param userPublicKeyBase58 Base58 public key of the user whose
* storage subtree is being set up. The throwaway key installed
* via `WALLET_setMinaPrivateKey` must correspond to this public
* key, since the mock signs with it.
* @param noriTokenBridgeAddressBase58 Base58 address of the
* `NoriTokenBridge` zkApp whose storage subtree is being
* initialised.
* @param txFee Fee in nanomina for the setup transaction.
* @param storageInterfaceVerificationKeySafe
* `NoriStorageInterface` verification key in the
* `{ data, hashStr }` form returned by `compileMinterDeps` /
* `compileMinterDepsNoCache`.
* @returns Object carrying the submitted transaction's hash as
* `{ txHash }` once the network has accepted and included it.
*/
async MOCK_setupStorage(userPublicKeyBase58, noriTokenBridgeAddressBase58, txFee, storageInterfaceVerificationKeySafe) {
logger.log('MOCK_setupStorage called with', {
userPublicKeyBase58,
noriTokenBridgeAddressBase58,
txFee,
storageInterfaceVerificationKeySafe,
});
const userPublicKey = PublicKey.fromBase58(userPublicKeyBase58);
const noriTokenBridgeAddress = PublicKey.fromBase58(noriTokenBridgeAddressBase58);
const { hashStr: storageInterfaceVerificationKeyHashStr, data } = storageInterfaceVerificationKeySafe;
const storageInterfaceVerificationKeyHashBigInt = BigInt(storageInterfaceVerificationKeyHashStr);
const hash = new Field(storageInterfaceVerificationKeyHashBigInt);
const storageInterfaceVerificationKey = { data, hash };
logger.log(`Setting up storage for user: ${userPublicKey.toBase58()}`);
// DO we need to do this is we are not proving here???
await this.fetchAccounts([userPublicKey, noriTokenBridgeAddress]);
logger.log('fetched accounts');
// Note we could have another method to not have to do this multiple times, but keeping it stateless for now.
const noriTokenBridgeInst = new NoriTokenBridge(noriTokenBridgeAddress);
logger.log('got token bridge inst');
const setupTx = await Mina.transaction({ sender: userPublicKey, fee: txFee }, async () => {
AccountUpdate.fundNewAccount(userPublicKey, 1);
await noriTokenBridgeInst.setUpStorage(userPublicKey, storageInterfaceVerificationKey);
});
logger.log('setup tx');
const provedTx = await setupTx.prove();
logger.log('provedTx', provedTx);
const tx = await provedTx.sign([__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f")]).send();
logger.log('sent');
const result = await tx.wait();
logger.log('result', result);
logger.log('Storage setup completed successfully');
return { txHash: result.hash };
}
// ================================================================
// Balance queries
// ================================================================
//
// Read-only lookups against the deployed contracts. No wallet
// state is required and no transaction is built or submitted;
// these exist so the client can render current on-chain state
// (wrapped token balance, cumulative amount minted) over the RPC
// surface.
/**
* Fetch the user's current balance of the wrapped fungible token
* issued by the given `FungibleToken` zkApp. Hydrates the
* balance-holding account under the token's derived tokenId, then
* calls `FungibleToken.getBalanceOf` and returns the balance as a
* decimal string of the raw on-chain value (nanounits of the
* fungible token, equivalently the minted amount in the base
* unit).
*
* Safe to call from production frontend code.
*
* @param noriTokenBaseBase58 Base58 address of the `FungibleToken`
* zkApp that issues the wrapped token being queried.
* @param minaSenderPublicKeyBase58 Base58 public key of the
* account whose balance is being queried.
* @returns Decimal string form of the balance in the token's raw
* on-chain units. Caller formats this for display.
*/
async getBalanceOf(
//noriTokenBridgeAddressBase58: string,
noriTokenBaseBase58, minaSenderPublicKeyBase58) {
const minaSenderPublicKey = PublicKey.fromBase58(minaSenderPublicKeyBase58);
const noriTokenBaseAddress = PublicKey.fromBase58(noriTokenBaseBase58);
const noriTokenBase = new FungibleToken(noriTokenBaseAddress);
/*const storage = new NoriStorageInterface(
minaSenderPublicKey,
noriTokenBridge.deriveTokenId()
);*/
await fetchAccount({
publicKey: minaSenderPublicKey,
tokenId: noriTokenBase.deriveTokenId(),
});
const balanceOf = await noriTokenBase.getBalanceOf(minaSenderPublicKey);
logger.log('balanceOf raw', balanceOf);
logger.log('balanceOf string', balanceOf.toString());
return balanceOf.toBigInt().toString();
}
/**
* Fetch the cumulative amount a given user has already minted
* against the given `NoriTokenBridge`, as tracked by the user's
* `NoriStorageInterface` subtree. Requires the storage subtree
* to have been set up for the user (see `needsToSetupStorage` /
* `setupStorage`); errors if it has not been.
*
* Safe to call from production frontend code.
*
* @param noriTokenBridgeAddressBase58 Base58 address of the
* `NoriTokenBridge` zkApp whose storage subtree is being read.
* @param minaSenderPublicKeyBase58 Base58 public key of the user
* whose minted-so-far value is being queried.
* @returns Decimal string form of the `mintedSoFar` field on the
* user's storage subtree.
* @throws `Error` if the storage account's `userKeyHash` cannot
* be read, which typically indicates the account has not yet
* been set up.
*/
async mintedSoFar(noriTokenBridgeAddressBase58, minaSenderPublicKeyBase58) {
const minaSenderPublicKey = PublicKey.fromBase58(minaSenderPublicKeyBase58);
const noriTokenBridgeAddress = PublicKey.fromBase58(noriTokenBridgeAddressBase58);
const noriTokenBridge = new NoriTokenBridge(noriTokenBridgeAddress);
const storage = new NoriStorageInterface(minaSenderPublicKey, noriTokenBridge.deriveTokenId());
await fetchAccount({
publicKey: minaSenderPublicKey,
tokenId: noriTokenBridge.deriveTokenId(),
});
const userKeyHash = await storage.userKeyHash.fetch();
if (!userKeyHash)
throw new Error('userKeyHash was falsey. Perhaps this account is not set up?');
const mintedSoFar = await storage.mintedSoFar.fetch();
return mintedSoFar.toBigInt().toString();
}
// Update ******************************************************************************
/**
* @deprecated Deprecated in favour of tokenBridgeTester.update
*/
async update(noriTokenBridgeAddressBase58, sp1PlonkProof, proofData, txFee) {
if (!__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f"))
throw new Error('#minaPrivateKey is undefined please call setMinaPrivateKey first');
const senderPublicKey = __classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f").toPublicKey();
const noriTokenBridgeAddress = PublicKey.fromBase58(noriTokenBridgeAddressBase58);
const decoded = decodeConsensusMptProof(sp1PlonkProof);
const ethInput = new EthInput(decoded);
const rawProof = await NodeProofLeft.fromJSON(proofData);
logger.log(`Submitting update from sender: ${senderPublicKey.toBase58()}`);
await this.fetchAccounts([senderPublicKey, noriTokenBridgeAddress]);
const noriTokenBridgeInst = new NoriTokenBridge(noriTokenBridgeAddress);
const updateTx = await Mina.transaction({ sender: senderPublicKey, fee: txFee }, async () => {
await noriTokenBridgeInst.update(ethInput, rawProof);
});
const provedTx = await updateTx.prove();
return provedTx.toJSON();
}
// This will be removed when we have a working version of WALLET_signAndSend
/**
* @deprecated Deprecated in favour of tokenBridgeTester.update
*/
async MOCK_update(noriTokenBridgeAddressBase58, sp1PlonkProof, proofData, txFee) {
if (!__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f"))
throw new Error('#minaPrivateKey is undefined please call setMinaPrivateKey first');
const senderPublicKey = __classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f").toPublicKey();
const noriTokenBridgeAddress = PublicKey.fromBase58(noriTokenBridgeAddressBase58);
const decoded = decodeConsensusMptProof(sp1PlonkProof);
const ethInput = new EthInput(decoded);
const rawProof = await NodeProofLeft.fromJSON(proofData);
logger.log(`Submitting update from sender: ${senderPublicKey.toBase58()}`);
const noriTokenBridgeInst = new NoriTokenBridge(noriTokenBridgeAddress);
const updateTx = await Mina.transaction({ sender: senderPublicKey, fee: txFee }, async () => {
await noriTokenBridgeInst.update(ethInput, rawProof);
});
const provedTx = await updateTx.prove();
const tx = await provedTx.sign([__classPrivateFieldGet(this, _TokenBridgeWorker_minaPrivateKey, "f")]).send();
const result = await tx.wait();
logger.log('Update completed successfully');
return { txHash: result.hash };
}
// ================================================================
// Mint precheck
// ================================================================
/**
* Decide whether the mint transaction should fund a new token
* account for the sender via `AccountUpdate.fundNewAccount`. The
* caller passes the resulting boolean straight into `mint` /
* `MOCK_mint` as the `fundNewAccount` flag.
*
* Probes the sender's account under the given `FungibleToken`'s
* derived tokenId. A missing account, or any error while
* fetching, is taken as "needs funding" so the mint transaction
* will create the account rather than fail on a missing
* destination.
*
* Safe to call from production frontend code.
*
* @param noriTokenBaseBase58 Base58 address of the `FungibleToken`
* zkApp whose tokenId the sender will receive wrapped tokens
* under.
* @param minaSenderPublicKeyBase58 Base58 public key of the
* account that will receive the minted tokens.
* @returns `true` when the sender does not yet have an account
* under the token's tokenId (or when the probe failed); `false`
* when an existing account was found.
*/
async needsToFundAccount(noriTokenBaseBase58, minaSenderPublicKeyBase58) {
const minaSenderPublicKey = PublicKey.fromBase58(minaSenderPublicKeyBase58);
const noriTokenBaseAddress = PublicKey.fromBase58(noriTokenBaseBase58);
const noriTokenBase = new FungibleToken(noriTokenBaseAddress);
try {
const fetchAccountResult = await fetchAccount({
publicKey: minaSenderPublicKey,
tokenId: noriTokenBase.deriveTokenId(),
});
logger.log(fetchAccountResult);
if (fetchAccountResult.account === undefined)
return true;
return false;
}
catch (e) {
logger.log('We had an error fetching the account. We assume we need to fund it.', e instanceof Error ? e.stack : String(e));
return true;
}
}
// ================================================================
// Minter compilation
// ================================================================
//
// Worker-side compilation of the three zkApp circuits the minter
// depends on (`NoriStorageInterface`, `FungibleToken`,
// `NoriTokenBridge`). Compilation is a prerequisite for every
// tx-producing method in this worker and must happen once per
// worker lifetime. Two implementations are retained:
//
// - `compileMinterDepsNoCache` compiles every circuit from source
// on every call. Currently the only working path.
// - `compileMinterDeps` is the cache-aware entry point, intended
// to fetch prebuilt artefacts from a network cache server when
// running in the browser. Force-delegates to the no-cache path
// today because the o1js browser cache does not function
// reliably. The cache body below the early return is retained
// in full so it can be reactivated with a single edit once the
// upstream blocker is resolved.
/**
* Convert an o1js `VerificationKey` (carrying a `Field` hash) into
* the `{ data, hashStr }` form that crosses the worker boundary.
* `hashStr` is the hash's decimal-string representation, which
* consumers rehydrate back into a `Field` via
* `new Field(BigInt(hashStr))`.
*
* Private helper used by `compileMinterDeps` and
* `compileMinterDepsNoCache` when packaging return values.
*
* @param vk o1js `VerificationKey` produced by contract
* compilation.
* @returns `{ hashStr, data }` with `hashStr` as the decimal
* string form of `vk.hash` and `data` taken verbatim from the
* compiled key.
*/
vkToVkSafe(vk) {
const { data, hash } = vk;
return {
hashStr: hash.toBigInt().toString(),
data,
};
}
/**
* Cache-aware entry point for compiling the three minter
* dependencies. When the browser cache is functional this fetches
* prebuilt artefacts for each circuit from the given
* `cacheServer` and compiles against them; when not, it
* delegates to `compileMinterDepsNoCache`.
*
* Currently force-delegates to `compileMinterDepsNoCache` on
* every call regardless of `cacheServer`, because the o1js
* browser compilation cache does not function reliably. The
* cache-enabled body below the early return is retained in full
* so it can be reactivated once the upstream issue is resolved;
* do not remove it.
*
* Safe to call from production frontend code.
*
* @param cacheServer Optional base URL of a network cache server
* that exposes the prebuilt circuit artefacts laid out per
* `NoriStorageInterfaceCacheLayout`, `FungibleTokenCacheLayout`,
* and `NoriTokenBridgeCacheLayout`. Ignored while the cache
* path is disabled.
* @returns `{ noriStorageInterfaceVerificationKeySafe,
* fungibleTokenVerificationKeySafe,
* noriTokenBridgeVerificationKeySafe }`, each in the
* `{ data, hashStr }` form produced by `vkToVkSafe`. Fed into
* `setupStorage` / `mint` at their verification-key parameter.
*/
async compileMinterDeps(cacheServer) {
return this.compileMinterDepsNoCache(); // FORCE COMPILE WITHOUT CACHE AS O1JS browser cache does NOT work
//if (!cacheServer || !isBrowser()) return this.compileMinterDepsNoCache();
logger.log('Compiling all minter dependencies [Browser]...');
// Now fetch caches in parallel
const noriStorageInterfaceCache = cacheFactory({
type: CacheType.Network,
baseUrl: cacheServer,
path: NoriStorageInterfaceCacheLayout.name,
files: NoriStorageInterfaceCacheLayout.files,
});
const fungibleTokenCache = cacheFactory({
type: CacheType.Network,
baseUrl: cacheServer,
path: FungibleTokenCacheLayout.name,
files: FungibleTokenCacheLayout.files,
});
const noriTokenBridgeCache = cacheFactory({
type: CacheType.Network,
baseUrl: cacheServer,
path: NoriTokenBridgeCacheLayout.name,
files: NoriTokenBridgeCacheLayout.files,
});
// Compile contracts sequentially in dependency order
const noriStorageInterfaceVks = await compileAndOptionallyVerifyContracts(logger, [
{
name: 'NoriStorageInterface',
program: NoriStorageInterface,
integrityHash: noriStorageInterfaceVkHash,
},
], await noriStorageInterfaceCache);
const fungibleTokenVks = await compileAndOptionallyVerifyContracts(logger, [
{
name: 'FungibleToken',
program: FungibleToken,
integrityHash: fungibleTokenVkHash,
},
], await fungibleTokenCache);
const noriTokenBridgeVks = await compileAndOptionallyVerifyContracts(logger, [
{
name: 'NoriTokenBridge',
program: NoriTokenBridge,
integrityHash: noriTokenBridgeVkHash,
},
], await noriTokenBridgeCache);
const compiledVks = {
NoriStorageInterfaceVerificationKey: noriStora