From a8fb53522f0c93fd1f283af44bcd4b6f3e94791a Mon Sep 17 00:00:00 2001 From: EvgenKor Date: Fri, 10 Nov 2023 09:14:31 +0300 Subject: [PATCH 1/2] Support for the different SNARK parameters within the single instance (#164) * Preparing for multiparameters support * Fetch parameters on pool switching * backward compatibility * Fix issue with params loading * Cosmetic * Update src/client-provider.ts Co-authored-by: Alexander Filippov * Minor refactoring --------- Co-authored-by: Alexander Filippov --- src/client.ts | 84 ++++++++++++++++++++++++++++++--------------------- src/config.ts | 23 +++++++++++--- src/index.ts | 2 +- src/params.ts | 34 ++++++++++----------- src/worker.ts | 57 ++++++++++++++++++++++------------ 5 files changed, 124 insertions(+), 76 deletions(-) diff --git a/src/client.ts b/src/client.ts index f9eff431..29d551f5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import { Proof, ITransferData, IWithdrawData, StateUpdate, TreeNode, IAddressComponents, IndexedTx } from 'libzkbob-rs-wasm-web'; -import { Chains, Pools, SnarkConfigParams, ClientConfig, +import { Chains, Pools, Parameters, ClientConfig, AccountConfig, accountId, ProverMode, DepositType } from './config'; import { truncateHexPrefix, toTwosComplementHex, bufToHex, bigintToArrayLe @@ -33,6 +33,8 @@ const PERMIT_DEADLINE_THRESHOLD = 300; // minimum time to deadline before tx p const CONTINUOUS_STATE_UPD_INTERVAL = 200; // updating client's state timer interval for continuous states (in ms) const CONTINUOUS_STATE_THRESHOLD = 1000; // the state considering continuous after that interval (in ms) +const GLOBAL_PARAMS_NAME = '__globalParams'; + const PROVIDER_SYNC_TIMEOUT = 60; // maximum time to sync network backend by blockNumber (in seconds) // Common database table's name @@ -98,7 +100,6 @@ export class ZkBobClient extends ZkBobProvider { // Direct deposit processors are used to create DD and fetch DD pending txs private ddProcessors: { [poolAlias: string]: DirectDepositProcessor } = {}; // The single worker for the all pools - // Currently we assume parameters are the same for the all pools private worker: any; // Performance estimation (msec per tx) private wasmSpeed: number | undefined; @@ -152,28 +153,34 @@ export class ZkBobClient extends ZkBobProvider { this.statDb = statDb; } - private async workerInit( - snarkParams: SnarkConfigParams, - forcedMultithreading: boolean | undefined = undefined, // specify this parameter to override multithreading autoselection - ): Promise { - // Get tx parameters hash from the relayer - // to check local params consistence - let txParamsHash: string | undefined = undefined; - try { - txParamsHash = await this.relayer().txParamsHash(); - } catch (err) { - console.warn(`Cannot fetch tx parameters hash from the relayer (${err.message})`); + private async workerInit(config: ClientConfig): Promise { + // collecting SNARK params config (only used) + const allParamsSet: Parameters = {}; + const usedParams: string[] = Object.keys(config.pools) + .map((aPool) => config.pools[aPool].parameters ?? GLOBAL_PARAMS_NAME) + .filter((val, idx, arr) => arr.indexOf(val) === idx); // only unique values + usedParams.forEach((val) => { + if (val != GLOBAL_PARAMS_NAME) { + if (config.snarkParamsSet && config.snarkParamsSet[val]) { + allParamsSet[val] = config.snarkParamsSet[val]; + } else { + throw new InternalError(`Cannot find SNARK parameters \'${val}\' in the client config (check snarkParamsSet)`); + } + } else { + if (config.snarkParams) { + allParamsSet[GLOBAL_PARAMS_NAME] = config.snarkParams; + } else { + throw new InternalError(`Not all pools have assigned SNARK parameters (check snarkParams in client config)`); + } + } + }); + if (usedParams.length > 1) { + console.log(`The following SNARK parameters are supported: ${usedParams.join(', ')}`); } - + let worker: any; - worker = wrap(new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })); - await worker.initWasm( - snarkParams.transferParamsUrl, - txParamsHash, - snarkParams.transferVkUrl, - forcedMultithreading - ); + await worker.initWasm(allParamsSet, config.forcedMultithreading); return worker; } @@ -198,7 +205,7 @@ export class ZkBobClient extends ZkBobProvider { const client = new ZkBobClient(config.pools, config.chains, activePoolAlias, config.supportId ?? "", callback, commonDb); - const worker = await client.workerInit(config.snarkParams); + const worker = await client.workerInit(config); client.zpStates = {}; client.worker = worker; @@ -232,11 +239,6 @@ export class ZkBobClient extends ZkBobProvider { public async login(account: AccountConfig) { this.account = account; await this.switchToPool(account.pool, account.birthindex); - try { - await this.setProverMode(this.account.proverMode); - } catch (err) { - console.error(err); - } } public async logout() { @@ -301,6 +303,12 @@ export class ZkBobClient extends ZkBobProvider { this.zpStates[newPoolAlias] = state; this.ddProcessors[newPoolAlias] = new DirectDepositProcessor(pool, network, state, this.subgraph()); + try { + await this.setProverMode(this.account.proverMode); + } catch (err) { + console.error(err); + } + console.log(`Pool and user account was switched to ${newPoolAlias} successfully`); } else { console.log(`Pool was switched to ${newPoolAlias} but account is not set yet`); @@ -323,6 +331,11 @@ export class ZkBobClient extends ZkBobProvider { return this.zpStates[requestedPool]; } + private snarkParamsAlias(): string { + const pool = this.pool(); + return pool.parameters ?? GLOBAL_PARAMS_NAME; + } + // ------------------=========< Balances and History >=========------------------- // | Quering shielded balance and history records | // ------------------------------------------------------------------------------- @@ -1396,10 +1409,13 @@ export class ZkBobClient extends ZkBobProvider { // | Local and delegated prover support | // ---------------------------------------------------------------------------- public async setProverMode(mode: ProverMode) { - if (mode != ProverMode.Delegated) { - this.worker.loadTxParams(); - } - await super.setProverMode(mode); + await super.setProverMode(mode).finally(async () => { + // The invoked setProverMode method doesn't ensure the requested mode will set + if (this.getProverMode() != ProverMode.Delegated) { + const relayerParamsHash = await this.relayer().txParamsHash().catch(() => undefined); + this.worker.loadTxParams(this.snarkParamsAlias(), relayerParamsHash); + } + }); } // Universal proving routine @@ -1411,7 +1427,7 @@ export class ZkBobClient extends ZkBobProvider { try { const proof = await prover.proveTx(pub, sec); const inputs = Object.values(pub); - const txValid = await this.worker.verifyTxProof(inputs, proof); + const txValid = await this.worker.verifyTxProof(this.snarkParamsAlias(), inputs, proof); if (!txValid) { throw new TxProofError(); } @@ -1426,8 +1442,8 @@ export class ZkBobClient extends ZkBobProvider { } } - const txProof = await this.worker.proveTx(pub, sec); - const txValid = await this.worker.verifyTxProof(txProof.inputs, txProof.proof); + const txProof = await this.worker.proveTx(this.snarkParamsAlias(), pub, sec); + const txValid = await this.worker.verifyTxProof(this.snarkParamsAlias(), txProof.inputs, txProof.proof); if (!txValid) { throw new TxProofError(); } diff --git a/src/config.ts b/src/config.ts index 2aa3b785..b7b4ca63 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,10 @@ export type Pools = { [name: string]: Pool; }; +export type Parameters = { + [name: string]: SnarkConfigParams; +} + export enum DepositType { Approve = 'approve', // deprecated but still supported deposit scheme SaltedPermit = 'permit', // based on EIP-2612 (salt was added to the signing message) @@ -27,7 +31,7 @@ export enum DepositType { } export interface Pool { - chainId: number, + chainId: number; poolAddress: string; tokenAddress: string, relayerUrls: string[]; @@ -38,6 +42,7 @@ export interface Pool { feeDecimals?: number; isNative?: boolean; ddSubgraph?: string; + parameters?: string; } export enum ProverMode { @@ -51,9 +56,17 @@ export interface ClientConfig { pools: Pools; // A map of supported chains (chain id => chain params) chains: Chains; - // Pathses for params and verification keys - // (currenly we assume the parameters are the same for the all pools) - snarkParams: SnarkConfigParams; + // URLs for params and verification keys: + // pools without 'parameters' field assumed to use that params + snarkParams?: SnarkConfigParams; + // Separated parameters for different pools are also supported: + // - the `Pool` object can contain the params name from that set + // in the 'parameters' optional fields + // - you can combine snarkParams (as global ones) + // with snarkParamsSet (as custom for the specified pools) + // - you MUST define at least snarkParams or snarkParamsSet in the config + // otherwise the error will thrown during the client initialization + snarkParamsSet?: Parameters; // Support ID - unique random string to track user's activity for support purposes supportId?: string; // By default MT mode selects automatically depended on browser @@ -64,7 +77,7 @@ export interface ClientConfig { export interface AccountConfig { // Spending key for the account sk: Uint8Array; - // Initial (current) pool alias (e.g. 'BOB-Polygon' or 'BOB-Optimism') + // Initial (current) pool alias (e.g. 'USDC-Polygon' or 'BOB-Sepolia') // The pool can be switched later without logout pool: string; // Account birthday for selected pool diff --git a/src/index.ts b/src/index.ts index d19863a3..c24c1e5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { ClientConfig, AccountConfig, accountId, ProverMode, Chain, Pool, Chains, Pools, - SnarkConfigParams, DepositType + SnarkConfigParams, Parameters, DepositType, } from './config'; export { ZkBobClient, TransferConfig, TransferRequest, FeeAmount, ClientState, ClientStateCallback diff --git a/src/params.ts b/src/params.ts index 1194c19b..523e6cee 100644 --- a/src/params.ts +++ b/src/params.ts @@ -1,5 +1,6 @@ import { InternalError } from "./errors"; import { FileCache } from "./file-cache"; +import { SnarkConfigParams } from "./config"; const MAX_VK_LOAD_ATTEMPTS = 3; @@ -15,7 +16,6 @@ export enum LoadingStatus { export class SnarkParams { private paramUrl: string; private vkUrl: string; - private expectedHash: string | undefined; // only params verified by a hash private cache: FileCache; @@ -27,16 +27,15 @@ export class SnarkParams { private loadingPromise: Promise | undefined; private loadingStatus: LoadingStatus; - public constructor(paramUrl: string, vkUrl: string, expectedParamHash: string | undefined) { + public constructor(params: SnarkConfigParams) { this.loadingStatus = LoadingStatus.NotStarted; - this.paramUrl = paramUrl; - this.vkUrl = vkUrl; - this.expectedHash = expectedParamHash; + this.paramUrl = params.transferParamsUrl; + this.vkUrl = params.transferVkUrl; } - public async getParams(wasm: any): Promise { + public async getParams(wasm: any, expectedHash?: string): Promise { if (!this.isParamsReady()) { - this.loadParams(wasm); + this.loadParams(wasm, expectedHash); return await this.loadingPromise; } @@ -47,8 +46,9 @@ export class SnarkParams { // VK doesn't stored at the local storage (no verification ability currently) public async getVk(): Promise { let attempts = 0; + const filename = this.vkUrl.substring(this.vkUrl.lastIndexOf('/') + 1); + const startTs = Date.now(); while (!this.isVkReady() && attempts++ < MAX_VK_LOAD_ATTEMPTS) { - console.time(`VK initializing`); try { const vk = await (await fetch(this.vkUrl, { headers: { 'Cache-Control': 'no-cache' } })).json(); // verify VK structure @@ -65,10 +65,10 @@ export class SnarkParams { } this.vk = vk; + + console.log(`VK ${filename} loaded in ${Date.now() - startTs} ms`); } catch(err) { console.warn(`VK loading attempt has failed: ${err.message}`); - } finally { - console.timeEnd(`VK initializing`); } } @@ -79,7 +79,7 @@ export class SnarkParams { return this.vk; } - private loadParams(wasm: any) { + private loadParams(wasm: any, expectedHash?: string) { if (this.isParamsReady() || this.loadingStatus == LoadingStatus.InProgress) { return; } @@ -94,15 +94,15 @@ export class SnarkParams { .finally(() => console.timeEnd(`Load parameters from DB`)); // check parameters hash if needed - if (txParamsData && this.expectedHash !== undefined) { - let computedHash = await cache.getHash(this.paramUrl); - if (!computedHash) { - computedHash = await cache.saveHash(this.paramUrl, txParamsData); + if (txParamsData && expectedHash !== undefined) { + let cachedHash = await cache.getHash(this.paramUrl); + if (!cachedHash) { + cachedHash = await cache.saveHash(this.paramUrl, txParamsData); } - if (computedHash.toLowerCase() != this.expectedHash.toLowerCase()) { + if (cachedHash.toLowerCase() != expectedHash.toLowerCase()) { // forget saved params in case of hash inconsistence - console.warn(`Hash of cached tx params (${computedHash}) doesn't associated with provided (${this.paramUrl}).`); + console.warn(`Hash of cached tx params (${cachedHash}) doesn't associated with provided (${this.paramUrl}).`); cache.remove(this.paramUrl); txParamsData = null; } diff --git a/src/worker.ts b/src/worker.ts index 4ec296ea..3af4cb1b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6,9 +6,10 @@ import { IDepositData, IDepositPermittableData, ITransferData, IWithdrawData, } from 'libzkbob-rs-wasm-web'; import { threads } from 'wasm-feature-detect'; import { SnarkParams } from './params'; +import { Parameters } from './config'; import { InternalError } from './errors'; -let txParams: SnarkParams; +let txParams: { [name: string]: SnarkParams } = {}; let txParser: any; let zpAccounts: { [accountId: string]: any } = {}; @@ -16,9 +17,7 @@ let wasm: any; const obj = { async initWasm( - txParamsUrl: string, - txParamsHash: string | undefined = undefined, // skip hash checking when undefined - txVkUrl: string, + params: Parameters, forcedMultithreading: boolean | undefined = undefined, ) { console.info('Initializing web worker...'); @@ -39,25 +38,50 @@ const obj = { await wasm.default() } - txParams = new SnarkParams(txParamsUrl, txVkUrl, txParamsHash); - // VK is always needed to transact, so initiate its loading right now - txParams.getVk().catch((err) => { - console.warn(`Unable to fetch tx verification key (don't worry, it will refetched when needed): ${err.message}`); - }); + + // Initialize parameters + for (const [name, par] of Object.entries(params)) { + const snarkParams = new SnarkParams(par); + // VK is always needed to transact, so initiate its loading right now + snarkParams.getVk().catch((err) => { + console.warn(`Unable to fetch tx verification key (don't worry, it will refetched when needed): ${err.message}`); + }); + txParams[name] = snarkParams; + } txParser = wasm.TxParser._new() console.info('Web worker init complete.'); }, - async loadTxParams() { - txParams.getParams(wasm); + async loadTxParams(paramsName: string, expectedHash?: string) { + const params = txParams[paramsName]; + if (params === undefined) { + throw new InternalError(`Cannot find snark parameters set \'${paramsName}\'`); + } + + params.getParams(wasm, expectedHash); }, - async proveTx(pub, sec) { + async proveTx(paramsName: string, pub, sec) { + const params = txParams[paramsName]; + if (params === undefined) { + throw new InternalError(`Cannot find snark parameters set \'${paramsName}\'`); + } + console.debug('Web worker: proveTx'); - let params = await txParams.getParams(wasm); - return wasm.Proof.tx(params, pub, sec); + let snarkParams = await params.getParams(wasm); + return wasm.Proof.tx(snarkParams, pub, sec); + }, + + async verifyTxProof(paramsName: string, inputs: string[], proof: SnarkProof): Promise { + const params = txParams[paramsName]; + if (params === undefined) { + throw new InternalError(`Cannot find snark parameters set \'${paramsName}\'`); + } + + const vk = await params.getVk(); // will throw error if VK fetch fail + return wasm.Proof.verify(vk, inputs, proof); }, async parseTxs(sk: Uint8Array, txs: IndexedTx[]): Promise { @@ -191,11 +215,6 @@ const obj = { return zpAccounts[accountId].updateStateColdStorage(bulks, indexFrom, indexTo); }, - async verifyTxProof(inputs: string[], proof: SnarkProof): Promise { - const vk = await txParams.getVk(); // will throw error if VK fetch fail - return wasm.Proof.verify(vk, inputs, proof); - }, - async generateAddress(accountId: string): Promise { return zpAccounts[accountId].generateAddress(); }, From 54617203c74f76ffc4cdc81bae427338a747a2da Mon Sep 17 00:00:00 2001 From: EvgenKor Date: Fri, 10 Nov 2023 10:48:54 +0300 Subject: [PATCH 2/2] Feature/forced exit (#166) * Forced exit stub * Updating ABI * Merge branch 'develop' into feature/forced-exit # Conflicts: # src/networks/evm/evm-abi.ts * Forced exit contract interaction * Export CommittedForcedExit type * Minor fixes * Committing forced exit * Retrieving committed forced exit * Getting forced exit state * Fix types, minor refactoring * isAccountDead, availableFundsToForcedExit routines; fixes and optimizing * Improvement: getting state index relayer falllback, correct FE events parsing * Getting executed forced exit object * Syncing local state with the subgraph as a fallback * Increasing version * Removing cancelled state * Merging the develop branch into the forced-exit branch # Conflicts: # package.json # src/.graphclient/index.ts # src/client.ts # src/index.ts # src/networks/evm/evm-abi.ts # src/networks/evm/index.ts # src/networks/index.ts # src/networks/tron/index.ts # src/services/relayer.ts # src/state.ts # src/subgraph/index.ts # src/subgraph/tx-query.graphql # src/tx.ts # yarn.lock * Tron fixes * Apply suggestions from code review Co-authored-by: Alexander Filippov * Code review fixes * Checking account FE status before the transaction creating * Preventing frequent state updating * Updating contracts ABI for getting limits (as a fallback) * beta2 * Fix issues from codereview * Tuning getLimitsFor request * Set production libraries --------- Co-authored-by: Alexander Filippov --- build_and_copy_to_console.sh | 7 + package.json | 6 +- src/.graphclient/index.ts | 33 ++++ src/client.ts | 141 ++++++++++++++--- src/emergency.ts | 274 ++++++++++++++++++++++++++++++++ src/errors.ts | 14 +- src/index.ts | 3 +- src/networks/evm/evm-abi.ts | 285 ++++++++++++++++++++++++++++++++-- src/networks/evm/index.ts | 278 ++++++++++++++++++++++++++++++--- src/networks/index.ts | 18 ++- src/networks/tron/index.ts | 250 ++++++++++++++++++++++++----- src/services/relayer.ts | 23 ++- src/state.ts | 189 +++++++++++----------- src/subgraph/index.ts | 40 ++++- src/subgraph/tx-query.graphql | 11 ++ src/tx.ts | 23 ++- src/worker.ts | 5 + yarn.lock | 16 +- 18 files changed, 1385 insertions(+), 231 deletions(-) create mode 100755 build_and_copy_to_console.sh create mode 100644 src/emergency.ts diff --git a/build_and_copy_to_console.sh b/build_and_copy_to_console.sh new file mode 100755 index 00000000..0a9a9e92 --- /dev/null +++ b/build_and_copy_to_console.sh @@ -0,0 +1,7 @@ +yarn build + +DST_DIR=../zkbob-console/node_modules/zkbob-client-js + +rm -rf $DST_DIR/lib $DST_DIR/src +cp -R ./src $DST_DIR +cp -R ./lib $DST_DIR diff --git a/package.json b/package.json index ab268e7b..aa9adac0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zkbob-client-js", - "version": "5.3.0", + "version": "5.4.0", "description": "zkBob integration library", "repository": "git@github.com:zkBob/libzkbob-client-js.git", "author": "Dmitry Vdovin ", @@ -30,8 +30,8 @@ "graphql": "16.7.1", "hdwallet-babyjub": "^0.0.2", "idb": "^7.0.0", - "libzkbob-rs-wasm-web": "1.4.2", - "libzkbob-rs-wasm-web-mt": "1.4.2", + "libzkbob-rs-wasm-web": "1.5.0", + "libzkbob-rs-wasm-web-mt": "1.5.0", "promise-throttle": "^1.1.2", "regenerator-runtime": "^0.13.9", "tronweb": "^5.3.0", diff --git a/src/.graphclient/index.ts b/src/.graphclient/index.ts index 48c676f9..1cb85bbb 100644 --- a/src/.graphclient/index.ts +++ b/src/.graphclient/index.ts @@ -2347,6 +2347,12 @@ const merger = new(BareMerger as any)({ return printWithCache(PoolTxesByIndexesDocument); }, location: 'PoolTxesByIndexesDocument.graphql' + },{ + document: PoolTxesFromIndexDocument, + get rawSDL() { + return printWithCache(PoolTxesFromIndexDocument); + }, + location: 'PoolTxesFromIndexDocument.graphql' } ]; }, @@ -2423,6 +2429,17 @@ export type PoolTxesByIndexesQuery = { poolTxes: Array<( ) | Pick | Pick | Pick } )> }; +export type PoolTxesFromIndexQueryVariables = Exact<{ + index_gte: Scalars['BigInt']; + first?: InputMaybe; +}>; + + +export type PoolTxesFromIndexQuery = { poolTxes: Array<( + Pick + & { zk: Pick } + )> }; + export const DirectDepositByIdDocument = gql` query DirectDepositById($id: ID!) { @@ -2535,6 +2552,19 @@ export const PoolTxesByIndexesDocument = gql` } } ` as unknown as DocumentNode; +export const PoolTxesFromIndexDocument = gql` + query PoolTxesFromIndex($index_gte: BigInt!, $first: Int = 1000) { + poolTxes(where: {index_gte: $index_gte}, first: $first, orderBy: index) { + index + zk { + out_commit + } + tx + message + } +} + ` as unknown as DocumentNode; + @@ -2550,6 +2580,9 @@ export function getSdk(requester: Requester) { }, PoolTxesByIndexes(variables?: PoolTxesByIndexesQueryVariables, options?: C): Promise { return requester(PoolTxesByIndexesDocument, variables, options) as Promise; + }, + PoolTxesFromIndex(variables: PoolTxesFromIndexQueryVariables, options?: C): Promise { + return requester(PoolTxesFromIndexDocument, variables, options) as Promise; } }; } diff --git a/src/client.ts b/src/client.ts index 29d551f5..dd2b1fc2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,16 +1,14 @@ -import { Proof, ITransferData, IWithdrawData, StateUpdate, TreeNode, IAddressComponents, IndexedTx } from 'libzkbob-rs-wasm-web'; +import { Proof, ITransferData, IWithdrawData, StateUpdate, TreeNode, IAddressComponents, IndexedTx, Account } from 'libzkbob-rs-wasm-web'; import { Chains, Pools, Parameters, ClientConfig, AccountConfig, accountId, ProverMode, DepositType } from './config'; -import { truncateHexPrefix, - toTwosComplementHex, bufToHex, bigintToArrayLe - } from './utils'; -import { SyncStat, ZkBobState } from './state'; +import { truncateHexPrefix, toTwosComplementHex, bigintToArrayLe } from './utils'; +import { SyncStat, ZkBobState, ZERO_OPTIMISTIC_STATE } from './state'; import { DirectDeposit, RegularTxType, txTypeToString } from './tx'; import { CONSTANTS } from './constants'; import { HistoryRecord, HistoryRecordState, HistoryTransactionType, ComplianceHistoryRecord } from './history' import { EphemeralAddress } from './ephemeral'; import { - InternalError, PoolJobError, RelayerJobError, SignatureError, TxDepositAllowanceTooLow, TxDepositDeadlineExpiredError, + InternalError, PoolJobError, RelayerJobError, SignatureError, TxAccountDeadError, TxAccountLocked, TxDepositAllowanceTooLow, TxDepositDeadlineExpiredError, TxInsufficientFundsError, TxInvalidArgumentError, TxLimitError, TxProofError, TxSmallAmount, TxSwapTooHighError } from './errors'; import { JobInfo, RelayerFee } from './services/relayer'; @@ -19,6 +17,7 @@ import { DepositData, SignatureRequest } from './signers/abstract-signer'; import { DepositSignerFactory } from './signers/signer-factory' import { PERMIT2_CONTRACT } from './signers/permit2-signer'; import { DirectDepositProcessor, DirectDepositType } from './dd'; +import { ForcedExitProcessor, ForcedExitState, CommittedForcedExit, FinalizedForcedExit } from './emergency'; import { wrap } from 'comlink'; import { PreparedTransaction } from './networks'; @@ -99,6 +98,9 @@ export class ZkBobClient extends ZkBobProvider { private auxZpStates: { [id: string]: ZkBobState } = {}; // Direct deposit processors are used to create DD and fetch DD pending txs private ddProcessors: { [poolAlias: string]: DirectDepositProcessor } = {}; + // Forced exit processors are used to withdraw funds emergency from the pool + // and deactivate the account + private feProcessors: { [poolAlias: string]: ForcedExitProcessor } = {}; // The single worker for the all pools private worker: any; // Performance estimation (msec per tx) @@ -302,6 +304,7 @@ export class ZkBobClient extends ZkBobProvider { ); this.zpStates[newPoolAlias] = state; this.ddProcessors[newPoolAlias] = new DirectDepositProcessor(pool, network, state, this.subgraph()); + this.feProcessors[newPoolAlias] = new ForcedExitProcessor(pool, network, state, this.subgraph()) try { await this.setProverMode(this.account.proverMode); @@ -433,7 +436,7 @@ export class ZkBobClient extends ZkBobProvider { const relayer = this.relayer(); const readyToTransact = await giftCardState.updateState( relayer, - async (index) => (await this.getPoolState(index)).root, + async (index) => this.getPoolState(index), await this.coldStorageConfig(), this.coldStorageBaseURL(), ); @@ -704,6 +707,7 @@ export class ZkBobClient extends ZkBobProvider { } await this.updateState(); + await this.assertAccountCanTransact(); // Fee estimating const usedFee = relayerFee ?? await this.getRelayerFee(); @@ -855,6 +859,8 @@ export class ZkBobClient extends ZkBobProvider { const ddQueueAddress = await processor.getQueueContract(); const zkAddress = await this.generateAddress(); + await this.assertAccountCanTransact(); + const limits = await this.getLimits(fromAddress); if (amount > limits.dd.total) { throw new TxLimitError(amount, limits.dd.total); @@ -917,6 +923,8 @@ export class ZkBobClient extends ZkBobProvider { } })); + await this.assertAccountCanTransact(); + const usedFee = relayerFee ?? await this.getRelayerFee(); const txParts = await this.getTransactionParts(RegularTxType.Transfer, transfers, usedFee); @@ -929,7 +937,7 @@ export class ZkBobClient extends ZkBobProvider { } let jobsIds: string[] = []; - let optimisticState = this.zeroOptimisticState(); + let optimisticState = ZERO_OPTIMISTIC_STATE; for (let index = 0; index < txParts.length; index++) { const onePart = txParts[index]; const outputs = onePart.outNotes.map((aNote) => { return {to: aNote.destination, amount: `${aNote.amountGwei}`} }); @@ -976,17 +984,6 @@ export class ZkBobClient extends ZkBobProvider { return jobsIds; } - private zeroOptimisticState(): StateUpdate { - const optimisticState: StateUpdate = { - newLeafs: [], - newCommitments: [], - newAccounts: [], - newNotes: [], - } - - return optimisticState; - } - // Withdraw shielded funds to the specified native chain address // This method can produce several transactions in case of insufficient input notes (constants::IN per tx) // relayerFee - fee from the relayer (request one if undefined) @@ -1001,6 +998,8 @@ export class ZkBobClient extends ZkBobProvider { } const addressBin = this.network().addressToBytes(address); + await this.assertAccountCanTransact(); + const supportedSwapAmount = await this.maxSupportedTokenSwap(); if (swapAmount > supportedSwapAmount) { throw new TxSwapTooHighError(swapAmount, supportedSwapAmount); @@ -1026,7 +1025,7 @@ export class ZkBobClient extends ZkBobProvider { } let jobsIds: string[] = []; - let optimisticState = this.zeroOptimisticState(); + let optimisticState = ZERO_OPTIMISTIC_STATE; for (let index = 0; index < txParts.length; index++) { const onePart = txParts[index]; @@ -1097,6 +1096,8 @@ export class ZkBobClient extends ZkBobProvider { if (giftCard.poolAlias != this.curPool) { throw new InternalError(`Cannot redeem gift card due to unsuitable pool (gift-card pool: ${giftCard.poolAlias}, current pool: ${this.curPool})`); } + + await this.assertAccountCanTransact(); const giftCardAcc: AccountConfig = { sk: giftCard.sk, @@ -1126,7 +1127,7 @@ export class ZkBobClient extends ZkBobProvider { fee: actualFee.toString(), }; const giftCardState = this.auxZpStates[accId]; - const txData = await giftCardState.createTransferOptimistic(oneTx, this.zeroOptimisticState()); + const txData = await giftCardState.createTransferOptimistic(oneTx, ZERO_OPTIMISTIC_STATE); const startProofDate = Date.now(); const txProof: Proof = await this.proveTx(txData.public, txData.secret, giftCardAcc.proverMode); @@ -1163,6 +1164,19 @@ export class ZkBobClient extends ZkBobProvider { } } + private async assertAccountCanTransact() { + if (await this.isForcedExitSupported()) { + if (await this.isAccountDead()) { + throw new TxAccountDeadError(); + } + + const committed = await this.activeForcedExit(); + if (committed && committed.exitEnd * 1000 > Date.now()) { + throw new TxAccountLocked(new Date(committed.exitEnd * 1000)); + } + } + } + // ------------------=========< Transaction configuration >=========------------------- // | These methods includes fee estimation, multitransfer estimation and other inform | // | functions. | @@ -1447,6 +1461,7 @@ export class ZkBobClient extends ZkBobProvider { if (!txValid) { throw new TxProofError(); } + return txProof; } @@ -1572,7 +1587,7 @@ export class ZkBobClient extends ZkBobProvider { noOwnTxsInOptimisticState = await this.zpState().updateState( this.relayer(), - async (index) => (await this.getPoolState(index)).root, + async (index) => this.getPoolState(index), await this.coldStorageConfig(), this.coldStorageBaseURL(), ); @@ -1733,7 +1748,7 @@ export class ZkBobClient extends ZkBobProvider { } // ------------------=========< Direct Deposits >=========------------------ - // | Calculating sync time | + // | Direct deposit srvice routines | // ------------------------------------------------------------------------- protected ddProcessor(): DirectDepositProcessor { @@ -1758,5 +1773,83 @@ export class ZkBobClient extends ZkBobProvider { return this.network().getDirectDepositFee(ddQueueAddr); } } - + + // --------------------=========< Forced Exit >=========-------------------- + // | Emergency withdrawing funds (direct contract interaction) | + // ------------------------------------------------------------------------- + + protected feProcessor(): ForcedExitProcessor { + const proccessor = this.feProcessors[this.curPool]; + if (!proccessor) { + throw new InternalError(`No forced exit processor initialized for the pool ${this.curPool}`); + } + + return proccessor; + } + + protected async updateStateWithFallback(): Promise { + return this.updateState().catch((err) => { // try to sync state (we must have the actual last nullifier) + console.warn(`Unable to sync state (${err.message}). The last nullifier may be invalid`); + // TODO: sync with the pool contract directly + return false; + }); + } + + // This routine available regardless forced exit support + public async isAccountDead(): Promise { + await this.updateStateWithFallback(); + return await this.feProcessor().isAccountDead(); + } + + // Checking is FE available for the current pool + public async isForcedExitSupported(): Promise { + return await this.feProcessor().isForcedExitSupported(); + } + // The following forced exit related routines should called only if forced exit supported + + public async forcedExitState(): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().forcedExitState(); + } + + public async activeForcedExit(): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().getActiveForcedExit(); + } + + // Returns only executed (not cancelled) exit + // (because cancelled events cannot be located efficiently due to nullifier updates) + public async executedForcedExit(): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().getExecutedForcedExit(); + } + + public async availableFundsToForcedExit(): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().availableFundsToForcedExit(); + } + + public async requestForcedExit( + executerAddress: string, // who will send emergency exit execute transaction + toAddress: string, // which address should receive funds + sendTxCallback: (tx: PreparedTransaction) => Promise // callback to send transaction + ): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().requestForcedExit( + executerAddress, + toAddress, + sendTxCallback, + (pub: any, sec: any) => this.proveTx(pub, sec) + ); + } + + public async executeForcedExit(sendTxCallback: (tx: PreparedTransaction) => Promise): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().executeForcedExit(sendTxCallback); + } + + public async cancelForcedExit(sendTxCallback: (tx: PreparedTransaction) => Promise): Promise { + await this.updateStateWithFallback(); + return this.feProcessor().cancelForcedExit(sendTxCallback); + } } \ No newline at end of file diff --git a/src/emergency.ts b/src/emergency.ts new file mode 100644 index 00000000..6f7f7edf --- /dev/null +++ b/src/emergency.ts @@ -0,0 +1,274 @@ +import { IWithdrawData, SnarkProof } from "libzkbob-rs-wasm-web"; +import { Pool } from "./config"; +import { L1TxState, NetworkBackend, PreparedTransaction } from "./networks"; +import { ZkBobState, ZERO_OPTIMISTIC_STATE } from "./state"; +import { ZkBobSubgraph } from "./subgraph"; +import { InternalError } from "./errors"; +import { keccak256 } from "web3-utils"; +import { addHexPrefix, bufToHex } from "./utils"; + +const WAIT_TX_TIMEOUT = 60; +const DEAD_SIGNATURE = BigInt('0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000'); +const DEAD_SIG_MASK = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000'); + +export enum ForcedExitState { + NotStarted = 0, + CommittedWaitingSlot, + CommittedReady, + Completed, + Outdated, +} + +interface ForcedExit { + nullifier: bigint; + to: string; + amount: bigint; +} + +export interface ForcedExitRequest extends ForcedExit { + operator: string; + index: number; + out_commit: bigint; + tx_proof: SnarkProof; +} + +export interface CommittedForcedExit extends ForcedExit { + operator: string; + exitStart: number; + exitEnd: number; + txHash: string; +} + +export interface FinalizedForcedExit extends ForcedExit { + cancelled: boolean; // false for successful forced exit, true for canceled one + txHash: string; +} + + +export class ForcedExitProcessor { + protected network: NetworkBackend; + protected subgraph?: ZkBobSubgraph; + protected state: ZkBobState; + + protected tokenAddress: string; + protected poolAddress: string; + + + constructor(pool: Pool, network: NetworkBackend, state: ZkBobState, subgraph?: ZkBobSubgraph) { + this.network = network; + this.subgraph = subgraph; + this.state = state; + this.tokenAddress = pool.tokenAddress; + this.poolAddress = pool.poolAddress; + } + + public async isForcedExitSupported(): Promise { + return this.network.isSupportForcedExit(this.poolAddress); + } + + // state MUST be synced before at the top level + public async isAccountDead(): Promise { + const nullifier = await this.getCurrentNullifier(); + const nullifierValue = await this.network.nullifierValue(this.poolAddress, BigInt(nullifier)); + + return (nullifierValue & DEAD_SIG_MASK) == DEAD_SIGNATURE + } + + // state MUST be synced before at the top level + public async forcedExitState(): Promise { + const nullifier = await this.getCurrentNullifier(); + const nullifierValue = await this.network.nullifierValue(this.poolAddress, BigInt(nullifier)); + if (nullifierValue == 0n) { + // the account is alive yet: check is forced exit procedure started + const commitedForcedExit = await this.network.committedForcedExitHash(this.poolAddress, BigInt(nullifier)); + if (commitedForcedExit != 0n) { + // the forced exit record exist on the pool for the current nullifier + // check is it just committed or already cancelled + const committed = await this.getActiveForcedExit(); + if (committed) { + const curTs = Date.now() / 1000; + if (curTs < committed.exitStart) { + return ForcedExitState.CommittedWaitingSlot; + } else if (curTs > committed.exitEnd) { + return ForcedExitState.Outdated; + } + return ForcedExitState.CommittedReady; + } + } + + return ForcedExitState.NotStarted; + } else { + // nullifier value doesn't equal zero: checking if account was already killed + if ((nullifierValue & DEAD_SIG_MASK) == DEAD_SIGNATURE) { + return ForcedExitState.Completed; + } + + throw new InternalError('The nullifier is not last for that account'); + } + } + + public async getActiveForcedExit(): Promise { + const nullifier = BigInt(await this.getCurrentNullifier()); + const [isDead, isCommitted] = await Promise.all([ + this.isAccountDead(), + this.network.committedForcedExitHash(this.poolAddress, nullifier).then((hash) => hash != 0n) + ]); + + if (!isDead && isCommitted) { + return this.network.committedForcedExit(this.poolAddress, nullifier) + } + + return undefined; + } + + public async getExecutedForcedExit(): Promise { + if (await this.isAccountDead()) { + return this.network.executedForcedExit(this.poolAddress, BigInt(await this.getCurrentNullifier())) + } + + return undefined; + } + + public async availableFundsToForcedExit(): Promise { + const accountBalance = await this.state.accountBalance(); + const notes = await this.state.usableNotes(); + const txNotesSum: bigint = notes.slice(0, 3).reduce((acc, cur) => acc + BigInt(cur[1].b), 0n); + + return accountBalance + txNotesSum; + } + + public async requestForcedExit( + executerAddress: string, // who will send emergency exit execute transaction + toAddress: string, // which address should receive funds + sendTxCallback: (tx: PreparedTransaction) => Promise, // callback to send transaction + proofTxCallback: (pub: any, sec: any) => Promise, + ): Promise { + // getting available amount to emergency withdraw + const requestedAmount = await this.availableFundsToForcedExit();; + + console.log(`Latest nullifier: ${await this.getCurrentNullifier()}`); + + // create regular withdraw tx + const oneTx: IWithdrawData = { + amount: requestedAmount.toString(), + fee: '0', + to: this.network.addressToBytes(toAddress), + native_amount: '0', + energy_amount: '0', + }; + const oneTxData = await this.state.createWithdrawalOptimistic(oneTx, ZERO_OPTIMISTIC_STATE); + + // customize memo field in the public part (pool contract know nothing about real memo) + const customMemo = addHexPrefix(bufToHex(oneTx.to)); + const customMemoHash = BigInt(keccak256(customMemo)); + const R = BigInt('21888242871839275222246405745257275088548364400416034343698204186575808495617'); + oneTxData.public.memo = (customMemoHash % R).toString(10); + + // calculate transaction proof + const txProof = await proofTxCallback(oneTxData.public, oneTxData.secret); + + // create an internal object to request + const request: ForcedExitRequest = { + nullifier: oneTxData.public.nullifier, + operator: executerAddress, + to: toAddress, + amount: requestedAmount, + index: oneTxData.parsed_delta.index, + out_commit: oneTxData.public.out_commit, + tx_proof: txProof.proof, + } + + // getting raw transaction + const commitTransaction = await this.network.createCommitForcedExitTx(this.poolAddress, request); + // ...and bringing it back to the application to send it + const txHash = await sendTxCallback(commitTransaction); + + // Assume tx was sent, try to figure out the result and retrieve a commited forced exit + const waitingTimeout = Date.now() + WAIT_TX_TIMEOUT * 1000; + do { + const status = await this.network.getTransactionState(txHash); + switch (status) { + case L1TxState.MinedSuccess: + const committed = await this.getActiveForcedExit(); + if (committed) { + return committed; + } + case L1TxState.MinedFailed: + const errReason = await this.network.getTxRevertReason(txHash); + throw new InternalError(`Forced exit transaction was reverted with message: ${errReason ?? ''}`); + + default: break; + } + } while (Date.now() < waitingTimeout); + + throw new InternalError('Unable to find forced exit commit transaction on the pool contract'); + } + + public async executeForcedExit( + sendTxCallback: (tx: PreparedTransaction) => Promise + ): Promise { + const state = await this.forcedExitState(); + if (state == ForcedExitState.CommittedReady) { + return this.executeActiveForcedExit(false, sendTxCallback); + } else { + throw new InternalError('Invallid forced exit state to execute forced exit'); + } + } + + public async cancelForcedExit( + sendTxCallback: (tx: PreparedTransaction) => Promise + ): Promise { + const state = await this.forcedExitState(); + if (state == ForcedExitState.Outdated) { + return this.executeActiveForcedExit(true, sendTxCallback); + } else { + throw new InternalError('Invallid forced exit state to cancel forced exit'); + } + } + + private async executeActiveForcedExit( + cancel: boolean, + sendTxCallback: (tx: PreparedTransaction) => Promise + ): Promise { + const committed = await this.getActiveForcedExit(); + if (committed) { + // getting raw transaction + const transaction = cancel ? + await this.network.createCancelForcedExitTx(this.poolAddress, committed) : + await this.network.createExecuteForcedExitTx(this.poolAddress, committed); + // ...and bring it back to the application to send it + const txHash = await sendTxCallback(transaction); + + // Assume tx was sent, try to figure out the result and retrieve a commited forced exit + const waitingTimeout = Date.now() + WAIT_TX_TIMEOUT * 1000; + do { + const status = await this.network.getTransactionState(txHash); + switch (status) { + case L1TxState.MinedSuccess: + return { + nullifier: committed.nullifier, + to: committed.to, + amount: committed.amount, + cancelled: cancel, + txHash: txHash, + }; + + case L1TxState.MinedFailed: + const errReason = await this.network.getTxRevertReason(txHash); + throw new InternalError(`Forced exit ${cancel ? 'cancel' : 'execute'} transaction was reverted with message: ${errReason ?? ''}`); + + default: break; + } + } while (Date.now() < waitingTimeout); + + throw new InternalError(`Unable to find forced exit ${cancel ? 'cancel' : 'execute'} transaction on the pool contract`); + } + + throw new InternalError(`Cannot find active forced exit to ${cancel ? 'cancel' : 'execute'} (need to commit first)`) + } + + private async getCurrentNullifier(): Promise { + return this.state.accountNullifier(); + } + +} \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts index cdcfac4d..c2c804d9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -72,7 +72,19 @@ export class TxInsufficientFundsError extends BobError { export class TxSwapTooHighError extends BobError { constructor(public requested: bigint, public supported: bigint) { - super(`The pool doesn't support requested swap amount (requested ${requested.toString()}, supported ${supported.toString()})`);52 + super(`The pool doesn't support requested swap amount (requested ${requested.toString()}, supported ${supported.toString()})`); + } +} + +export class TxAccountDeadError extends BobError { + constructor() { + super('The account cannot transact or receive funds anymore due to executed forced exit'); + } +} + +export class TxAccountLocked extends BobError { + constructor(public upto: Date) { + super(`The account was locked for emergency exit up to ${upto.toLocaleString()}`); } } diff --git a/src/index.ts b/src/index.ts index c24c1e5d..dc3c444d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export { ClientConfig, AccountConfig, accountId, SnarkConfigParams, Parameters, DepositType, } from './config'; export { ZkBobClient, TransferConfig, TransferRequest, FeeAmount, - ClientState, ClientStateCallback + ClientState, ClientStateCallback, } from './client'; export { ZkBobProvider as ZkBobAccountlessClient, GiftCardProperties } from './client-provider'; export { SyncStat } from './state'; @@ -19,5 +19,6 @@ export { deriveSpendingKeyZkBob } from './utils' export { IAddressComponents } from 'libzkbob-rs-wasm-web'; export { SignatureType } from './signers/abstract-signer' export { DirectDepositType } from './dd' +export { ForcedExitState, CommittedForcedExit, FinalizedForcedExit } from './emergency' export * from './errors' \ No newline at end of file diff --git a/src/networks/evm/evm-abi.ts b/src/networks/evm/evm-abi.ts index d2513491..874e4409 100644 --- a/src/networks/evm/evm-abi.ts +++ b/src/networks/evm/evm-abi.ts @@ -196,7 +196,7 @@ export const poolContractABI: AbiItem[] = [ name: '', type: 'uint256' }], - name: 'roots', + name: 'nullifiers', outputs: [{ internalType: 'uint256', name: '', @@ -207,52 +207,67 @@ export const poolContractABI: AbiItem[] = [ }, { inputs: [{ + internalType: 'uint256', + name: '', + type: 'uint256' + }], + name: 'roots', + outputs: [{ + internalType: 'uint256', + name: '', + type: 'uint256' + }], + stateMutability: 'view', + type: 'function' + }, + { + inputs:[{ internalType: 'address', name: '_user', - type: 'address', + type: 'address' }], name: 'getLimitsFor', outputs: [{ components: [{ internalType: 'uint256', name: 'tvlCap', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'tvl', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'dailyDepositCap', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'dailyDepositCapUsage', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'dailyWithdrawalCap', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'dailyWithdrawalCapUsage', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'dailyUserDepositCap', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'dailyUserDepositCapUsage', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint256', name: 'depositCap', - type: 'uint256', + type: 'uint256' }, { internalType: 'uint8', name: 'tier', - type: 'uint8', + type: 'uint8' }, { internalType: 'uint256', name: 'dailyUserDirectDepositCap', @@ -266,12 +281,23 @@ export const poolContractABI: AbiItem[] = [ name: 'directDepositCap', type: 'uint256' }], - internalType: 'struct ZkBobAccounting.Limits', + internalType: 'struct IZkBobAccounting.Limits', name: '', type: 'tuple' }], stateMutability: 'view', - type: 'function', + type: 'function' + }, + { + inputs: [], + name: 'accounting', + outputs: [{ + internalType: 'contract IZkBobAccounting', + name: '', + type: 'address' + }], + stateMutability: 'view', + type: 'function' }, { inputs: [], @@ -321,7 +347,165 @@ export const poolContractABI: AbiItem[] = [ outputs: [], stateMutability: 'nonpayable', type: 'function' - } + }, + { + anonymous: false, + inputs: [{ + indexed: true, + internalType: 'uint256', + name: 'nullifier', + type: 'uint256' + }, { + indexed: false, + internalType: 'address', + name: 'operator', + type: 'address' + }, { + indexed: false, + internalType: 'address', + name: 'to', + type: 'address' + }, { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256' + }, { + indexed: false, + internalType: 'uint256', + name: 'exitStart', + type: 'uint256' + }, { + indexed: false, + internalType: 'uint256', + name: 'exitEnd', + type: 'uint256' + }], + name: 'CommitForcedExit', + type: 'event' + }, + { + anonymous: false, + inputs: [{ + indexed: true, + internalType: 'uint256', + name: 'nullifier', + type: 'uint256' + }], + name: 'CancelForcedExit', + type: 'event' + }, + { + inputs: [{ + internalType: 'uint256', + name: '', + type: 'uint256' + }], + name: 'committedForcedExits', + outputs: [{ + internalType: 'bytes32', + name: '', + type: 'bytes32' + }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [{ + internalType: 'address', + name: '_operator', + type: 'address' + }, { + internalType: 'address', + name: '_to', + type: 'address' + }, { + internalType: 'uint256', + name: '_amount', + type: 'uint256' + }, { + internalType: 'uint256', + name: '_index', + type: 'uint256' + }, { + internalType: 'uint256', + name: '_nullifier', + type: 'uint256' + }, { + internalType: 'uint256', + name: '_out_commit', + type: 'uint256' + }, { + internalType: 'uint256[8]', + name: '_transfer_proof', + type: 'uint256[8]' + }], + name: 'commitForcedExit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + anonymous: false, + inputs: [{ + indexed: true, + internalType: 'uint256', + name: 'index', + type: 'uint256' + }, { + indexed: true, + internalType: 'uint256', + name: 'nullifier', + type: 'uint256' + }, { + indexed: false, + internalType: 'address', + name: 'to', + type: 'address' + }, { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256' + }], + name: 'ForcedExit', + type: 'event' + }, + { + inputs: [{ + internalType: 'uint256', + name: '_nullifier', + type: 'uint256' + }, { + internalType: 'address', + name: '_operator', + type: 'address' + }, { + internalType: 'address', + name: '_to', + type: 'address' + }, { + internalType: 'uint256', + name: '_amount', + type: 'uint256' + }, { + internalType: 'uint256', + name: '_exitStart', + type: 'uint256' + }, { + internalType: 'uint256', + name: '_exitEnd', + type: 'uint256' + }, { + internalType: 'bool', + name: '_cancel', + type: 'bool' + }], + name: 'executeForcedExit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, ]; export const ddContractABI: AbiItem[] = [ @@ -509,4 +693,75 @@ export const ddContractABI: AbiItem[] = [ name: 'RefundDirectDeposit', type: 'event' }, +]; + +export const accountingABI: AbiItem[] = [ + { + inputs:[{ + internalType: 'address', + name: '_user', + type: 'address' + }], + name: 'getLimitsFor', + outputs: [{ + components: [{ + internalType: 'uint256', + name: 'tvlCap', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'tvl', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyDepositCap', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyDepositCapUsage', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyWithdrawalCap', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyWithdrawalCapUsage', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyUserDepositCap', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyUserDepositCapUsage', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'depositCap', + type: 'uint256' + }, { + internalType: 'uint8', + name: 'tier', + type: 'uint8' + }, { + internalType: 'uint256', + name: 'dailyUserDirectDepositCap', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'dailyUserDirectDepositCapUsage', + type: 'uint256' + }, { + internalType: 'uint256', + name: 'directDepositCap', + type: 'uint256' + }], + internalType: 'struct IZkBobAccounting.Limits', + name: '', + type: 'tuple' + }], + stateMutability: 'view', + type: 'function' + }, ]; \ No newline at end of file diff --git a/src/networks/evm/index.ts b/src/networks/evm/index.ts index 1ff9fda2..309b3e1b 100644 --- a/src/networks/evm/index.ts +++ b/src/networks/evm/index.ts @@ -1,9 +1,9 @@ import Web3 from 'web3'; import { Contract } from 'web3-eth-contract' import { TransactionConfig } from 'web3-core' -import { NetworkBackend, PreparedTransaction} from '..'; +import { NetworkBackend, PreparedTransaction, L1TxState} from '..'; import { InternalError } from '../../errors'; -import { ddContractABI, poolContractABI, tokenABI } from './evm-abi'; +import { accountingABI, ddContractABI, poolContractABI, tokenABI } from './evm-abi'; import bs58 from 'bs58'; import { DDBatchTxDetails, RegularTxDetails, PoolTxDetails, RegularTxType, PoolTxType, DirectDeposit, DirectDepositState } from '../../tx'; import { addHexPrefix, bufToHex, hexToBuf, toTwosComplementHex, truncateHexPrefix } from '../../utils'; @@ -15,9 +15,11 @@ import { isAddress } from 'web3-utils'; import { Transaction, TransactionReceipt } from 'web3-core'; import { RpcManagerDelegate, MultiRpcManager } from '../rpcman'; import { ZkBobState } from '../../state'; +import { CommittedForcedExit, FinalizedForcedExit, ForcedExitRequest } from '../../emergency'; const RETRY_COUNT = 10; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const ZERO_ADDRESS1 = '0x0000000000000000000000000000000000000001'; export enum PoolSelector { Transact = "af989083", @@ -30,11 +32,13 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa private pool?: Contract; private dd?: Contract; private token?: Contract; + private accounting?: Contract; // Local cache private tokenSellerAddresses = new Map(); // poolContractAddress -> tokenSellerContractAddress private ddContractAddresses = new Map(); // poolContractAddress -> directDepositContractAddress - private supportsNonces = new Map(); // tokenAddress -> isSupportsNonceMethod + private accountingAddresses = new Map(); // poolContractAddress -> accountingContractAddress + private supportedMethods = new Map(); // (contractAddress + methodName) => isSupported // ------------------------=========< Lifecycle >=========------------------------ // | Init, enabling and disabling backend | @@ -53,7 +57,8 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa return this.web3 !== undefined && this.pool !== undefined && this.dd !== undefined && - this.token !== undefined; + this.token !== undefined && + this.accounting !== undefined; } public setEnabled(enabled: boolean) { @@ -63,12 +68,14 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa this.pool = new this.web3.eth.Contract(poolContractABI) as unknown as Contract; this.dd = new this.web3.eth.Contract(ddContractABI) as unknown as Contract; this.token = new this.web3.eth.Contract(tokenABI) as unknown as Contract; + this.accounting = new this.web3.eth.Contract(accountingABI) as unknown as Contract; } } else { this.web3 = undefined; this.pool = undefined; this.dd = undefined; this.token = undefined; + this.accounting = undefined; } } @@ -104,6 +111,14 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa return this.token; } + private accountingContract(): Contract { + if (!this.accounting) { + throw new InternalError(`EvmNetwork: accounting contract object is undefined`); + } + + return this.accounting; + } + private contractCallRetry(contract: Contract, address: string, method: string, args: any[] = []): Promise { return this.commonRpcRetry(async () => { contract.options.address = address; @@ -114,6 +129,30 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa ); } + private async isMethodSupportedByContract( + contract: Contract, + address: string, + methodName: string, + testParams: any[] = [], + ): Promise { + const mapKey = address + methodName; + let isSupport = this.supportedMethods.get(mapKey); + if (isSupport === undefined) { + try { + contract.options.address = address; + await contract.methods[methodName](...testParams).call() + isSupport = true; + } catch (err) { + console.warn(`The contract seems doesn't support \'${methodName}\' method`); + isSupport = false; + } + + this.supportedMethods.set(mapKey, isSupport); + }; + + return isSupport + } + // -----------------=========< Token-Related Routiness >=========----------------- // | Getting balance, allowance, nonce etc | // ------------------------------------------------------------------------------- @@ -191,22 +230,8 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa } public async isSupportNonce(tokenAddress: string): Promise { - let isSupport = this.supportsNonces.get(tokenAddress); - if (isSupport === undefined) { - try { - const tokenContract = this.tokenContract(); - tokenContract.options.address = tokenAddress; - await tokenContract.methods['nonces'](ZERO_ADDRESS).call() - isSupport = true; - } catch (err) { - console.warn(`The token seems doesn't support nonces method`); - isSupport = false; - } - - this.supportsNonces.set(tokenAddress, isSupport); - }; - - return isSupport + const tokenContract = this.tokenContract(); + return this.isMethodSupportedByContract(tokenContract, tokenAddress, 'nonces', [ZERO_ADDRESS]); } @@ -246,7 +271,193 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa } public async poolLimits(poolAddress: string, address: string | undefined): Promise { - return await this.contractCallRetry(this.poolContract(), poolAddress, 'getLimitsFor', [address ?? ZERO_ADDRESS]); + let contract: Contract; + let contractAddress: string; + if (await this.isMethodSupportedByContract(this.poolContract(), poolAddress, 'accounting')) { + // Current contract deployments (getLimitsFor implemented in the separated ZkBobAccounting contract) + let accountingAddress = this.accountingAddresses.get(poolAddress); + if (!accountingAddress) { + accountingAddress = await this.contractCallRetry(this.poolContract(), poolAddress, 'accounting'); + if (accountingAddress) { + this.accountingAddresses.set(poolAddress, accountingAddress) + } else { + throw new InternalError(`Cannot retrieve accounting contract address for the pool ${poolAddress}`); + } + } + contract = this.accountingContract(); + contractAddress = accountingAddress; + } else { + // Fallback for the old deployments (getLimitsFor implemented in pool contract) + contract = this.poolContract(); + contractAddress = poolAddress; + } + + return await this.contractCallRetry(contract, contractAddress, 'getLimitsFor', [address ?? ZERO_ADDRESS1]); + } + + public async isSupportForcedExit(poolAddress: string): Promise { + const poolContract = this.poolContract(); + return this.isMethodSupportedByContract(poolContract, poolAddress, 'committedForcedExits', ['0']); + } + + public async nullifierValue(poolAddress: string, nullifier: bigint): Promise { + const res = await this.contractCallRetry(this.poolContract(), poolAddress, 'nullifiers', [nullifier]); + + return BigInt(res); + } + + public async committedForcedExitHash(poolAddress: string, nullifier: bigint): Promise { + const res = await this.contractCallRetry(this.poolContract(), poolAddress, 'committedForcedExits', [nullifier.toString()]); + + return BigInt(res); + } + + public async createCommitForcedExitTx(poolAddress: string, forcedExit: ForcedExitRequest): Promise { + const method = 'commitForcedExit(address,address,uint256,uint256,uint256,uint256,uint256[8])'; + const encodedTx = await this.poolContract().methods[method]( + forcedExit.operator, + forcedExit.to, + forcedExit.amount.toString(), + forcedExit.index, + forcedExit.nullifier.toString(), + forcedExit.out_commit.toString(), + [forcedExit.tx_proof.a, + forcedExit.tx_proof.b, + forcedExit.tx_proof.c + ].flat(2), + ).encodeABI(); + + return { + to: poolAddress, + amount: 0n, + data: encodedTx, + }; + } + + public async committedForcedExit(poolAddress: string, nullifier: bigint): Promise { + const pool = this.poolContract(); + pool.options.address = poolAddress; + + const commitEventAbi = poolContractABI.find((val) => val.name == 'CommitForcedExit'); + const cancelEventAbi = poolContractABI.find((val) => val.name == 'CancelForcedExit'); + + if (!commitEventAbi || !cancelEventAbi) { + throw new InternalError('Could not find ABI items for forced exit events'); + } + + const commitSignature = this.activeWeb3().eth.abi.encodeEventSignature(commitEventAbi); + const cancelSignature = this.activeWeb3().eth.abi.encodeEventSignature(cancelEventAbi); + + const associatedEvents = await this.activeWeb3().eth.getPastLogs({ + address: poolAddress, + topics: [ + [commitSignature, cancelSignature], + addHexPrefix(nullifier.toString(16).padStart(64, '0')), + ], + fromBlock: 0, + toBlock: 'latest' + }); + + let result: CommittedForcedExit | undefined; + associatedEvents + .sort((e1, e2) => e1.blockNumber - e2.blockNumber) + .forEach((e) => { + switch (e.topics[0]) { + case commitSignature: + const decoded = this.activeWeb3().eth.abi.decodeLog(commitEventAbi.inputs ?? [], e.data, e.topics.slice(1)) + result = { + nullifier: BigInt(decoded.nullifier), + operator: decoded.operator, + to: decoded.to, + amount: BigInt(decoded.amount), + exitStart: Number(decoded.exitStart), + exitEnd: Number(decoded.exitEnd), + txHash: e.transactionHash, + }; + break; + + case cancelSignature: + result = undefined; + break; + } + }) + + return result; + } + + public async executedForcedExit(poolAddress: string, nullifier: bigint): Promise { + const pool = this.poolContract(); + pool.options.address = poolAddress; + + const executeEventAbi = poolContractABI.find((val) => val.name == 'ForcedExit'); + + if (!executeEventAbi) { + throw new InternalError('Could not find ABI items for forced exit event'); + } + + const executeSignature = this.activeWeb3().eth.abi.encodeEventSignature(executeEventAbi); + + const associatedEvents = await this.activeWeb3().eth.getPastLogs({ + address: poolAddress, + topics: [ + [executeSignature], + null, + addHexPrefix(nullifier.toString(16).padStart(64, '0')), + ], + fromBlock: 0, + toBlock: 'latest' + }); + + if (associatedEvents.length > 0) { + const decoded = this.activeWeb3().eth.abi.decodeLog(executeEventAbi.inputs ?? [], associatedEvents[0].data, associatedEvents[0].topics.slice(1)) + return { + nullifier: BigInt(decoded.nullifier), + to: decoded.to, + amount: BigInt(decoded.amount), + cancelled: false, + txHash: associatedEvents[0].transactionHash, + }; + } + + return undefined; + } + + public async createExecuteForcedExitTx(poolAddress: string, forcedExit: CommittedForcedExit): Promise { + const method = 'executeForcedExit(uint256,address,address,uint256,uint256,uint256,bool)'; + const encodedTx = await this.poolContract().methods[method]( + forcedExit.nullifier.toString(), + forcedExit.operator, + forcedExit.to, + forcedExit.amount.toString(), + forcedExit.exitStart, + forcedExit.exitEnd, + 0 + ).encodeABI(); + + return { + to: poolAddress, + amount: 0n, + data: encodedTx, + }; + } + + public async createCancelForcedExitTx(poolAddress: string, forcedExit: CommittedForcedExit): Promise { + const method = 'executeForcedExit(uint256,address,address,uint256,uint256,uint256,bool)'; + const encodedTx = await this.poolContract().methods[method]( + forcedExit.nullifier.toString(), + forcedExit.operator, + forcedExit.to, + forcedExit.amount.toString(), + forcedExit.exitStart, + forcedExit.exitEnd, + 1 + ).encodeABI(); + + return { + to: poolAddress, + amount: 0n, + data: encodedTx, + }; } public async getTokenSellerContract(poolAddress: string): Promise { @@ -649,6 +860,31 @@ export class EvmNetwork extends MultiRpcManager implements NetworkBackend, RpcMa return estimateEvmCalldataLength(txType, notesCnt, extraDataLen) } + public async getTransactionState(txHash: string): Promise { + try { + const [tx, receipt] = await Promise.all([ + this.activeWeb3().eth.getTransaction(txHash), + this.activeWeb3().eth.getTransactionReceipt(txHash), + ]); + + if (receipt) { + if (receipt.status == true) { + return L1TxState.MinedSuccess; + } else { + return L1TxState.MinedFailed; + } + } + + if (tx) { + return L1TxState.Pending; + } + } catch(err) { + console.warn(`[EvmNetwork] error on checking tx ${txHash}: ${err.message}`); + } + + return L1TxState.NotFound; + } + // ----------------------=========< Syncing >=========---------------------- // | Getting block number, waiting for a block... | // ------------------------------------------------------------------------- diff --git a/src/networks/index.ts b/src/networks/index.ts index a06c63c2..9025765b 100644 --- a/src/networks/index.ts +++ b/src/networks/index.ts @@ -2,6 +2,7 @@ import { ZkBobState } from "../state"; import { EvmNetwork, InternalError, TxType } from ".."; import { DirectDeposit, PoolTxDetails } from "../tx"; import { TronNetwork } from "./tron"; +import { CommittedForcedExit, FinalizedForcedExit, ForcedExitRequest } from "../emergency"; export interface PreparedTransaction { to: string; @@ -10,6 +11,12 @@ export interface PreparedTransaction { selector?: string; } +export enum L1TxState { + NotFound, + Pending, + MinedSuccess, + MinedFailed, +} export interface NetworkBackend { // Backend Maintenance @@ -33,6 +40,14 @@ export interface NetworkBackend { getDenominator(poolAddress: string): Promise; poolState(poolAddress: string, index?: bigint): Promise<{index: bigint, root: bigint}>; poolLimits(poolAddress: string, address: string | undefined): Promise; + isSupportForcedExit(poolAddress: string): Promise; + nullifierValue(poolAddress: string, nullifier: bigint): Promise; + committedForcedExitHash(poolAddress: string, nullifier: bigint): Promise; + createCommitForcedExitTx(poolAddress: string, forcedExit: ForcedExitRequest): Promise; + committedForcedExit(poolAddress: string, nullifier: bigint): Promise; + executedForcedExit(poolAddress: string, nullifier: bigint): Promise; + createExecuteForcedExitTx(poolAddress: string, forcedExit: CommittedForcedExit): Promise; + createCancelForcedExitTx(poolAddress: string, forcedExit: CommittedForcedExit): Promise; getTokenSellerContract(poolAddress: string): Promise; // Direct Deposits @@ -65,6 +80,7 @@ export interface NetworkBackend { getTxDetails(index: number, poolTxHash: string, state: ZkBobState): Promise; calldataBaseLength(): number; estimateCalldataLength(txType: TxType, notesCnt: number, extraDataLen: number): number; + getTransactionState(txHash: string): Promise; // syncing with external providers getBlockNumber(): Promise; @@ -78,7 +94,7 @@ enum SupportedNetwork { } function networkType(chainId: number): SupportedNetwork | undefined { - if ([0x2b6653dc, 0x94a9059e].includes(chainId)) { + if ([0x2b6653dc, 0x94a9059e, 0xcd8690dc].includes(chainId)) { return SupportedNetwork.TronNetwork; } else if ([1, 137, 10, 11155111, 5, 420, 1337, 31337].includes(chainId)) { return SupportedNetwork.EvmNetwork; diff --git a/src/networks/tron/index.ts b/src/networks/tron/index.ts index a8ce25cc..e0dd8380 100644 --- a/src/networks/tron/index.ts +++ b/src/networks/tron/index.ts @@ -1,20 +1,22 @@ -import { NetworkBackend, PreparedTransaction } from '..'; +import { L1TxState, NetworkBackend, PreparedTransaction } from '..'; import { InternalError, TxType } from '../../index'; import { DDBatchTxDetails, DirectDeposit, DirectDepositState, PoolTxDetails, PoolTxType, RegularTxDetails, RegularTxType } from '../../tx'; import tokenAbi from './abi/usdt-abi.json'; -import { ddContractABI as ddAbi, poolContractABI as poolAbi} from '../evm/evm-abi'; +import { ddContractABI as ddAbi, poolContractABI as poolAbi, accountingABI} from '../evm/evm-abi'; import { bufToHex, hexToBuf, toTwosComplementHex, truncateHexPrefix } from '../../utils'; import { CALLDATA_BASE_LENGTH, decodeEvmCalldata, estimateEvmCalldataLength, getCiphertext } from '../evm/calldata'; import { hexToBytes } from 'web3-utils'; import { PoolSelector } from '../evm'; import { MultiRpcManager, RpcManagerDelegate } from '../rpcman'; import { ZkBobState } from '../../state'; +import { CommittedForcedExit, FinalizedForcedExit, ForcedExitRequest } from '../../emergency'; const TronWeb = require('tronweb') const bs58 = require('bs58') const RETRY_COUNT = 5; const DEFAULT_ENERGY_FEE = 420; +const DEFAULT_FEE_LIMIT = 100_000_000; const ZERO_ADDRESS = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcManagerDelegate { @@ -24,6 +26,7 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM private tokenContracts = new Map(); // tokenAddress -> contact object private poolContracts = new Map(); // tokenAddress -> contact object private ddContracts = new Map(); // tokenAddress -> contact object + private accountingContracts = new Map(); // tokenAddress -> contact object // blockchain long-lived cached parameters private chainId: number | undefined = undefined; @@ -32,8 +35,8 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM private tokenDecimals = new Map(); // tokenAddress -> decimals private tokenSellerAddresses = new Map(); // poolContractAddress -> tokenSellerContractAddress private ddContractAddresses = new Map(); // poolAddress -> ddQueueAddress - private supportsNonces = new Map(); // tokenAddress -> isSupportsNonceMethod - + private accountingAddresses = new Map(); // poolAddress -> accountingAddress + private supportedMethods = new Map(); // contractAddress+method => isSupport // ------------------------=========< Lifecycle >=========------------------------ // | Init, enabling and disabling backend | @@ -72,6 +75,7 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM this.tokenContracts.clear(); this.poolContracts.clear(); this.ddContracts.clear(); + this.accountingContracts.clear(); } } @@ -117,6 +121,20 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM return contract; } + protected async getAccountingContract(accountingAddress: string): Promise { + let contract = this.accountingContracts.get(accountingAddress); + if (!contract) { + contract = await this.activeTronweb().contract(accountingABI, accountingAddress); + if (contract) { + this.ddContracts.set(accountingAddress, contract); + } else { + throw new Error(`Cannot initialize a contact object for the accounting ${accountingAddress}`); + } + } + + return contract; + } + private contractCallRetry(contract: any, method: string, args: any[] = []): Promise { return this.commonRpcRetry(async () => { return await contract[method](...args).call() @@ -125,6 +143,25 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM RETRY_COUNT, ); } + + private async isMethodSupportedByContract(contractAddress: string, methodName: string): Promise { + const mapKey = contractAddress + methodName; + let isSupport = this.supportedMethods.get(mapKey); + if (isSupport === undefined) { + const contract = await this.commonRpcRetry(() => { + return this.tronWeb.trx.getContract(contractAddress); + }, 'Unable to retrieve smart contract object', RETRY_COUNT); + const methods = contract.abi.entrys; + if (Array.isArray(methods)) { + isSupport = methods.find((val) => val.name == methodName) !== undefined; + this.supportedMethods.set(mapKey, isSupport); + } else { + isSupport = false; + } + } + + return isSupport; + } // -----------------=========< Token-Related Routiness >=========----------------- // | Getting balance, allowance, nonce etc | @@ -205,25 +242,9 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM } public async isSupportNonce(tokenAddress: string): Promise { - let isSupport = this.supportsNonces.get(tokenAddress); - if (isSupport === undefined) { - const contract = await this.commonRpcRetry(() => { - return this.activeTronweb().trx.getContract(tokenAddress); - }, 'Unable to retrieve smart contract object', RETRY_COUNT); - const methods = contract.abi.entrys; - if (Array.isArray(methods)) { - isSupport = methods.find((val) => val.name == 'nonces') !== undefined; - this.supportsNonces.set(tokenAddress, isSupport); - } else { - isSupport = false; - } - } - - return isSupport; + return this.isMethodSupportedByContract(tokenAddress, 'nonces'); } - - // ---------------------=========< Pool Interaction >=========-------------------- // | Getting common info: pool ID, denominator, limits etc | // ------------------------------------------------------------------------------- @@ -267,8 +288,128 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM } public async poolLimits(poolAddress: string, address: string | undefined): Promise { + let contract: any; + if (await this.isMethodSupportedByContract(poolAddress, 'accounting')) { + // Current contract deployments (getLimitsFor implemented in the separated ZkBobAccounting contract) + let accountingAddr = this.accountingAddresses.get(poolAddress); + if (!accountingAddr) { + const pool = await this.getPoolContract(poolAddress); + const rawAddr = await this.contractCallRetry(pool, 'accounting'); + accountingAddr = TronWeb.address.fromHex(rawAddr); + if (accountingAddr) { + this.accountingAddresses.set(poolAddress, accountingAddr); + } else { + throw new InternalError(`Cannot fetch accounting contract address`); + } + } + + contract = await this.getAccountingContract(accountingAddr); + } else { + // Fallback for the old deployments (getLimitsFor implemented in pool contract) + contract = await this.getPoolContract(poolAddress); + } + + return await this.contractCallRetry(contract, 'getLimitsFor', [address ?? ZERO_ADDRESS]); + } + + public async isSupportForcedExit(poolAddress: string): Promise { + return this.isMethodSupportedByContract(poolAddress, 'committedForcedExits'); + } + + public async nullifierValue(poolAddress: string, nullifier: bigint): Promise { + const pool = await this.getPoolContract(poolAddress); + const res = await this.contractCallRetry(pool, 'nullifiers', [nullifier.toString()]); + + return BigInt(res); + } + + public async committedForcedExitHash(poolAddress: string, nullifier: bigint): Promise { const pool = await this.getPoolContract(poolAddress); - return await this.contractCallRetry(pool, 'getLimitsFor', [address ?? ZERO_ADDRESS]); + const res = await this.contractCallRetry(pool, 'committedForcedExits', [nullifier.toString()]); + + return BigInt(res); + } + + private async prepareTransaction( + contractAddress: string, + selector: string, // name(arg1_type,arg2_type,...) + parameters: {type: string, value: any}[], + nativeAmount: bigint = 0n, // sun + feeLimit: number = DEFAULT_FEE_LIMIT, // how many user's trx can be converted to energy + ): Promise { + const tx = await this.activeTronweb().transactionBuilder.triggerSmartContract(contractAddress, selector, { feeLimit }, parameters); + const contract = tx?.transaction?.raw_data?.contract; + let txData: any | undefined; + if (Array.isArray(contract) && contract.length > 0) { + txData = truncateHexPrefix(contract[0].parameter?.value?.data); + } + + if (typeof txData !== 'string' || txData.length < 8) { + throw new InternalError(`Unable to extract tx (${selector.split('(')[0]}) calldata`); + } + + return { + to: contractAddress, + amount: nativeAmount, + data: txData.slice(8), // skip selector from the calldata + selector, + }; + } + + public async createCommitForcedExitTx(poolAddress: string, forcedExit: ForcedExitRequest): Promise { + const selector = 'commitForcedExit(address,address,uint256,uint256,uint256,uint256,uint256[8])'; + const parameters = [ + {type: 'address', value: forcedExit.operator}, + {type: 'address', value: forcedExit.to}, + {type: 'uint256', value: forcedExit.amount.toString()}, + {type: 'uint256', value: forcedExit.index}, + {type: 'uint256', value: forcedExit.nullifier.toString()}, + {type: 'uint256', value: forcedExit.out_commit.toString()}, + {type: 'uint256[8]', value: [forcedExit.tx_proof.a, + forcedExit.tx_proof.b, + forcedExit.tx_proof.c, + ].flat(2)}, + ]; + + return this.prepareTransaction(poolAddress, selector, parameters); + } + + public async committedForcedExit(poolAddress: string, nullifier: bigint): Promise { + throw new InternalError('unimplemented'); + } + + public async executedForcedExit(poolAddress: string, nullifier: bigint): Promise { + throw new InternalError('unimplemented'); + } + + public async createExecuteForcedExitTx(poolAddress: string, forcedExit: CommittedForcedExit): Promise { + const selector = 'executeForcedExit(uint256,address,address,uint256,uint256,uint256,bool)'; + const parameters = [ + {type: 'uint256', value: forcedExit.nullifier.toString()}, + {type: 'address', value: forcedExit.operator}, + {type: 'address', value: forcedExit.to}, + {type: 'uint256', value: forcedExit.amount.toString()}, + {type: 'uint256', value: forcedExit.exitStart}, + {type: 'uint256', value: forcedExit.exitEnd}, + {type: 'bool', value: 0}, + ]; + + return this.prepareTransaction(poolAddress, selector, parameters); + } + + public async createCancelForcedExitTx(poolAddress: string, forcedExit: CommittedForcedExit): Promise { + const selector = 'executeForcedExit(uint256,address,address,uint256,uint256,uint256,bool)'; + const parameters = [ + {type: 'uint256', value: forcedExit.nullifier.toString()}, + {type: 'address', value: forcedExit.operator}, + {type: 'address', value: forcedExit.to}, + {type: 'uint256', value: forcedExit.amount.toString()}, + {type: 'uint256', value: forcedExit.exitStart}, + {type: 'uint256', value: forcedExit.exitEnd}, + {type: 'bool', value: 1}, + ]; + + return this.prepareTransaction(poolAddress, selector, parameters); } public async getTokenSellerContract(poolAddress: string): Promise { @@ -326,23 +467,8 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM {type: 'uint256', value: amount}, {type: 'bytes', value: zkAddrBytes} ]; - const tx = await this.activeTronweb().transactionBuilder.triggerSmartContract(ddQueueAddress, selector, { feeLimit: 100_000_000 }, parameters); - const contract = tx?.transaction?.raw_data?.contract; - let txData: any | undefined; - if (Array.isArray(contract) && contract.length > 0) { - txData = truncateHexPrefix(contract[0].parameter?.value?.data); - } - - if (typeof txData !== 'string' && txData.length < 8) { - throw new InternalError('Unable to extract DD calldata'); - } - - return { - to: ddQueueAddress, - amount: 0n, - data: txData.slice(8), // skip selector from the calldata - selector, - }; + + return this.prepareTransaction(ddQueueAddress, selector, parameters); } public async createNativeDirectDepositTx( @@ -501,7 +627,25 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM } public async getTxRevertReason(txHash: string): Promise { - return 'UNKNOWN_REASON' + try { + const txInfo = await this.commonRpcRetry(async () => { + return this.activeTronweb().trx.getTransactionInfo(txHash); + }, '[TronNetwork] Cannot get transaction', RETRY_COUNT); + + if (txInfo && txInfo.receipt) { + if (txInfo.result && txInfo.result == 'FAILED') { + if (txInfo.resMessage) { + return this.tronWeb.toAscii(txInfo.resMessage); + } + + return 'UNKNOWN_REASON'; + } + } + } catch(err) { + console.warn(`[TronNetwork] error on checking tx ${txHash}: ${err.message}`); + } + + return null; } public async getChainId(): Promise { @@ -541,7 +685,7 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM const tronTransaction = await this.commonRpcRetry(async () => { return this.activeTronweb().trx.getTransaction(poolTxHash); }, '[TronNetwork] Cannot get transaction', RETRY_COUNT); - //const tronTransactionInfo = await this.activeTronweb().trx.getTransaction(poolTxHash); + const txState = await this.getTransactionState(poolTxHash); const timestamp = tronTransaction?.raw_data?.timestamp const contract = tronTransaction?.raw_data?.contract; let txData: any | undefined; @@ -550,8 +694,7 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM } if (txData && timestamp) { - // TODO: get is tx mined!!! - let isMined = true; + let isMined = txState == L1TxState.MinedSuccess; const txSelector = txData.slice(0, 8).toLowerCase(); if (txSelector == PoolSelector.Transact) { @@ -624,6 +767,27 @@ export class TronNetwork extends MultiRpcManager implements NetworkBackend, RpcM return estimateEvmCalldataLength(txType, notesCnt, extraDataLen) } + public async getTransactionState(txHash: string): Promise { + try { + const txInfo = await this.commonRpcRetry(async () => { + return this.activeTronweb().trx.getTransactionInfo(txHash); + }, '[TronNetwork] Cannot get transaction', RETRY_COUNT); + + if (txInfo && txInfo.receipt) { + // tx is on the blockchain (assume mined) + if (txInfo.result && txInfo.result == 'FAILED') { + return L1TxState.MinedFailed; + } + + return L1TxState.MinedSuccess; + } + } catch(err) { + console.warn(`[TronNetwork] error on checking tx ${txHash}: ${err.message}`); + } + + return L1TxState.NotFound; + } + // xxxxxxxxxxxxxxxxxxxxXXXXXXXXX< Private routines >XXXXXXXXXxxxxxxxxxxxxxxxxxxxxx // x Sending tx, working with energy and others x diff --git a/src/services/relayer.ts b/src/services/relayer.ts index e22f734b..e9b9c596 100644 --- a/src/services/relayer.ts +++ b/src/services/relayer.ts @@ -1,11 +1,13 @@ -import { RegularTxType } from "../tx"; -import { hexToNode } from "../utils"; +import { PoolTxMinimal, RegularTxType } from "../tx"; +import { addHexPrefix, hexToNode } from "../utils"; import { InternalError, ServiceError } from "../errors"; import { IZkBobService, ServiceType, ServiceVersion, isServiceVersion, ServiceVersionFetch, defaultHeaders, fetchJson, } from "./common"; import { Proof, TreeNode } from 'libzkbob-rs-wasm-web'; +import { CONSTANTS } from "../constants"; +import { NetworkBackend } from "../networks"; const RELAYER_VERSION_REQUEST_THRESHOLD = 3600; // relayer's version expiration (in seconds) @@ -182,7 +184,7 @@ export class ZkBobRelayer implements IZkBobService { // | | // ------------------------------------------------------------------------------- - public async fetchTransactionsOptimistic(offset: BigInt, limit: number = 100): Promise { + public async fetchTransactionsOptimistic(network: NetworkBackend, offset: number, limit: number = 100): Promise { const url = new URL(`/transactions/v2`, this.url()); url.searchParams.set('limit', limit.toString()); url.searchParams.set('offset', offset.toString()); @@ -193,7 +195,18 @@ export class ZkBobRelayer implements IZkBobService { throw new ServiceError(this.type(), 200, `Response should be an array`); } - return txs; + const OUTPLUSONE = CONSTANTS.OUT + 1; // number of leaves (account + notes) in a transaction + + return txs.map((tx, txIdx) => { + // tx structure from relayer: mined flag + txHash(32 bytes, 64 chars) + commitment(32 bytes, 64 chars) + memo + return { + index: offset + txIdx * OUTPLUSONE, + commitment: tx.slice(65, 129), + txHash: network.txHashFromHexString(tx.slice(1, 65)), + memo: tx.slice(129), + isMined: tx.slice(0, 1) === '1', + } + }); } // returns transaction job ID @@ -283,7 +296,7 @@ export class ZkBobRelayer implements IZkBobService { public async limits(address: string | undefined): Promise { const url = new URL('/limits', this.url()); - if (address !== undefined) { + if (address) { url.searchParams.set('address', address); } const headers = defaultHeaders(this.supportId); diff --git a/src/state.ts b/src/state.ts index 9ffd8a7b..e5496099 100644 --- a/src/state.ts +++ b/src/state.ts @@ -15,8 +15,7 @@ import { ZkBobRelayer } from './services/relayer'; import { CONSTANTS } from './constants'; import { NetworkBackend } from './networks'; import { ZkBobSubgraph } from './subgraph'; - -const LOG_STATE_HOTSYNC = false; +import { TreeState } from './client-provider'; const OUTPLUSONE = CONSTANTS.OUT + 1; // number of leaves (account + notes) in a transaction const BATCH_SIZE = 1000; // number of transactions per request during a sync state @@ -25,6 +24,14 @@ const CORRUPT_STATE_ROLLBACK_ATTEMPTS = 2; // number of state restore attempts ( const CORRUPT_STATE_WIPE_ATTEMPTS = 5; // number of state restore attempts (via wipe) const COLD_STORAGE_USAGE_THRESHOLD = 1000; // minimum number of txs to cold storage using const MIN_TX_COUNT_FOR_STAT = 100; +const MIN_RESYNC_INTERVAL = 1000; // minimum interval between state resyncs (ms) + +export const ZERO_OPTIMISTIC_STATE: StateUpdate = { + newLeafs: [], + newCommitments: [], + newAccounts: [], + newNotes: [], +} export interface BatchResult { @@ -84,6 +91,7 @@ export class ZkBobState { public stateId: string; // should depends on pool and sk private sk: Uint8Array; private network: NetworkBackend; + private subgraph?: ZkBobSubgraph; private birthIndex?: number; public history?: HistoryStorage; // should work synchronically with the state private ephemeralAddrPool?: EphemeralPool; // depends on sk so we placed it here @@ -91,6 +99,8 @@ export class ZkBobState { private updateStatePromise: Promise | undefined; private syncStats: SyncStat[] = []; private skipColdStorage: boolean = false; + private lastSyncTimestamp: number = 0; // it's need for frequent resyncs preventing + private lastSyncResult: boolean = true; // true: no own transactions in optimistic state during the last state sync // State self-healing private rollbackAttempts = 0; @@ -120,6 +130,7 @@ export class ZkBobState { const zpState = new ZkBobState(); zpState.sk = new Uint8Array(sk); zpState.network = network; + zpState.subgraph = subgraph; zpState.birthIndex = birthIndex; const userId = bufToHex(hash(zpState.sk)).slice(0, 32); @@ -314,12 +325,12 @@ export class ZkBobState { public async updateState( relayer: ZkBobRelayer, - getPoolRoot: (index: bigint) => Promise, + getPoolState: (index?: bigint) => Promise, coldConfig?: ColdStorageConfig, coldBaseAddr?: string, ): Promise { if (this.updateStatePromise == undefined) { - this.updateStatePromise = this.updateStateOptimisticWorker(relayer, getPoolRoot, coldConfig, coldBaseAddr).finally(() => { + this.updateStatePromise = this.updateStateOptimisticWorker(relayer, getPoolState, coldConfig, coldBaseAddr).finally(() => { this.updateStatePromise = undefined; }); } else { @@ -332,14 +343,27 @@ export class ZkBobState { // returns is ready to transact private async updateStateOptimisticWorker( relayer: ZkBobRelayer, - getPoolRoot: (index: bigint) => Promise, + getPoolState: (index?: bigint) => Promise, coldConfig?: ColdStorageConfig, coldBaseAddr?: string, ): Promise { + // Skip sync if the last one was just finished + if (Date.now() < this.lastSyncTimestamp + MIN_RESYNC_INTERVAL) { + return this.lastSyncResult; + } this.lastSyncInfo = {txCount: 0, hotSyncCount: 0, processedTxCount: 0, startTimestamp: Date.now()} let startIndex = Number(await this.getNextIndex()); - const stateInfo = await relayer.info(); + const stateInfo = await relayer.info().catch(async (err) => { + console.warn(`Cannot get relayer state. Getting current index from the pool contract...`); + const poolState = await getPoolState(); + return { + root: poolState.root.toString(), + optimisticRoot: poolState.root.toString, + deltaIndex: poolState.index, + optimisticDeltaIndex: poolState.index + } + }); const nextIndex = Number(stateInfo.deltaIndex); const optimisticIndex = Number(stateInfo.optimisticDeltaIndex); @@ -370,7 +394,7 @@ export class ZkBobState { this.lastSyncInfo.txCount = (optimisticIndex - startIndex) / OUTPLUSONE; // Try to using the cold storage if possible - const coldResultPromise = this.startColdSync(getPoolRoot, coldConfig, coldBaseAddr, startIndex); + const coldResultPromise = this.startColdSync(getPoolState, coldConfig, coldBaseAddr, startIndex); // Start the hot sync simultaneously with the cold sync const assumedNextIndex = this.nextIndexAfterColdSync(coldConfig, startIndex); @@ -450,7 +474,6 @@ export class ZkBobState { this.history?.setLastMinedTxIndex(totalRes.maxMinedIndex); this.history?.setLastPendingTxIndex(totalRes.maxPendingIndex); - const fullSyncTime = Date.now() - startSyncTime; const fullSyncTimePerTx = fullSyncTime / (coldResult.txCount + totalRes.txCount); @@ -480,7 +503,7 @@ export class ZkBobState { const checkIndex = await this.getNextIndex(); const stableIndex = await this.lastVerifiedIndex(); if (checkIndex != stableIndex) { - const isStateCorrect = await this.verifyState(getPoolRoot); + const isStateCorrect = await this.verifyState(getPoolState); if (!isStateCorrect) { console.log(`🚑[StateVerify] Merkle tree root at index ${checkIndex} mistmatch!`); if (stableIndex > 0 && stableIndex < checkIndex && @@ -505,7 +528,7 @@ export class ZkBobState { } // resync the state - return await this.updateStateOptimisticWorker(relayer, getPoolRoot, coldConfig, coldBaseAddr); + return await this.updateStateOptimisticWorker(relayer, getPoolState, coldConfig, coldBaseAddr); } else { this.rollbackAttempts = 0; this.wipeAttempts = 0; @@ -515,6 +538,9 @@ export class ZkBobState { // set finish sync timestamp this.lastSyncInfo.endTimestamp = Date.now(); + this.lastSyncTimestamp = Date.now(); + this.lastSyncResult = isReadyToTransact; + return isReadyToTransact; } @@ -537,58 +563,54 @@ export class ZkBobState { // Get the transactions from the relayer starting from the specified index private async fetchBatch(relayer: ZkBobRelayer, fromIndex: number, count: number): Promise { - return relayer.fetchTransactionsOptimistic(BigInt(fromIndex), count).then( async txs => { - const txHashes: Record = {}; - const indexedTxs: IndexedTx[] = []; - const indexedTxsPending: IndexedTx[] = []; - - let maxMinedIndex = -1; - let maxPendingIndex = -1; - - for (let txIdx = 0; txIdx < txs.length; ++txIdx) { - const tx = txs[txIdx]; - const memo_idx = fromIndex + txIdx * OUTPLUSONE; // Get the first leaf index in the tree - - // tx structure from relayer: mined flag + txHash(32 bytes, 64 chars) + commitment(32 bytes, 64 chars) + memo - const memo = tx.slice(129); // Skip mined flag, txHash and commitment - const commitment = tx.slice(65, 129) - - const indexedTx: IndexedTx = { - index: memo_idx, - memo: memo, - commitment: commitment, + return relayer.fetchTransactionsOptimistic(this.network, fromIndex, count) + .catch((err) => { + if (this.subgraph) { + console.warn(`Unable to load transactions from the relayer (${err.message}), fallbacking to subgraph`); + return this.subgraph.getTxesMinimal(fromIndex, count); + } else { + console.log(`🔥[HotSync] cannot get txs batch from index ${fromIndex}: ${err.message}`) + throw new InternalError(`Cannot fetch txs from the relayer: ${err.message}`); } + }) + .then( async txs => { + const txHashes: Record = {}; + const indexedTxs: IndexedTx[] = []; + const indexedTxsPending: IndexedTx[] = []; - // 3. Get txHash - const txHash = tx.slice(1, 65); - txHashes[memo_idx] = this.network.txHashFromHexString(txHash); + let maxMinedIndex = -1; + let maxPendingIndex = -1; - // 4. Get mined flag - if (tx.slice(0, 1) === '1') { - indexedTxs.push(indexedTx); - maxMinedIndex = Math.max(maxMinedIndex, memo_idx); - } else { - indexedTxsPending.push(indexedTx); - maxPendingIndex = Math.max(maxPendingIndex, memo_idx); + for (let txIdx = 0; txIdx < txs.length; ++txIdx) { + const tx = txs[txIdx]; + txHashes[tx.index] = tx.txHash; + const indexedTx: IndexedTx = { + index: tx.index, + memo: tx.memo, + commitment: tx.commitment, + } + + if (tx.isMined) { + indexedTxs.push(indexedTx); + maxMinedIndex = Math.max(maxMinedIndex, tx.index); + } else { + indexedTxsPending.push(indexedTx); + maxPendingIndex = Math.max(maxPendingIndex, tx.index); + } } - } - console.log(`🔥[HotSync] got batch of ${txs.length} transactions from index ${fromIndex}`); + console.log(`🔥[HotSync] got batch of ${txs.length} transactions from index ${fromIndex}`); - return { - fromIndex: fromIndex, - count: txs.length, - minedTxs: indexedTxs, - pendingTxs: indexedTxsPending, - maxMinedIndex, - maxPendingIndex, - txHashes, - } + return { + fromIndex: fromIndex, + count: txs.length, + minedTxs: indexedTxs, + pendingTxs: indexedTxsPending, + maxMinedIndex, + maxPendingIndex, + txHashes, + } }) - .catch((err) => { - console.log(`🔥[HotSync] cannot get txs batch from index ${fromIndex}: ${err.message}`) - throw new InternalError(`Cannot fetch txs from the relayer: ${err.message}`); - }); } // The heaviest work: parse txs batch @@ -603,9 +625,6 @@ export class ZkBobState { const parseResult: ParseTxsResult = await this.worker.parseTxs(this.sk, batch.minedTxs); const decryptedMemos = parseResult.decryptedMemos; batchState.set(batch.fromIndex, parseResult.stateUpdate); - //if (LOG_STATE_HOTSYNC) { - // this.logStateSync(i, i + txs.length * OUTPLUSONE, decryptedMemos); - //} for (let decryptedMemoIndex = 0; decryptedMemoIndex < decryptedMemos.length; ++decryptedMemoIndex) { // save memos corresponding to the our account to restore history const myMemo = decryptedMemos[decryptedMemoIndex]; @@ -645,43 +664,26 @@ export class ZkBobState { // Return StateUpdate object // This method used for multi-tx public async getNewState(relayer: ZkBobRelayer, accBirthIndex: number): Promise { - const startIndex = await this.getNextIndex(); + const startIndex = Number(await this.getNextIndex()); const stateInfo = await relayer.info(); - const optimisticIndex = BigInt(stateInfo.optimisticDeltaIndex); + const optimisticIndex = Number(stateInfo.optimisticDeltaIndex); if (optimisticIndex > startIndex) { const startTime = Date.now(); console.log(`⬇ Fetching transactions between ${startIndex} and ${optimisticIndex}...`); - const numOfTx = Number((optimisticIndex - startIndex) / BigInt(OUTPLUSONE)); - const stateUpdate = relayer.fetchTransactionsOptimistic(startIndex, numOfTx).then( async txs => { - console.log(`Getting ${txs.length} transactions from index ${startIndex}`); - - const indexedTxs: IndexedTx[] = []; - - for (let txIdx = 0; txIdx < txs.length; ++txIdx) { - const tx = txs[txIdx]; - // Get the first leaf index in the tree - const memo_idx = Number(startIndex) + txIdx * OUTPLUSONE; - - // tx structure from relayer: mined flag + txHash(32 bytes, 64 chars) + commitment(32 bytes, 64 chars) + memo - // 1. Extract memo block - const memo = tx.slice(129); // Skip mined flag, txHash and commitment - - // 2. Get transaction commitment - const commitment = tx.slice(65, 129) - - const indexedTx: IndexedTx = { - index: memo_idx, - memo: memo, - commitment: commitment, + const numOfTx = (optimisticIndex - startIndex) / OUTPLUSONE; + const stateUpdate = relayer.fetchTransactionsOptimistic(this.network, startIndex, numOfTx).then( async txs => { + console.log(`Got ${txs.length} transactions from index ${startIndex}`); + const indexedTxs: IndexedTx[] = txs.map((tx) => { + return { + index: tx.index, + memo: tx.memo, + commitment: tx.commitment, } - - // 3. add indexed tx - indexedTxs.push(indexedTx); - } + }); const parseResult: ParseTxsResult = await this.worker.parseTxs(this.sk, indexedTxs); @@ -690,7 +692,6 @@ export class ZkBobState { const msElapsed = Date.now() - startTime; const avgSpeed = msElapsed / numOfTx; - console.log(`Fetch finished in ${msElapsed / 1000} sec | ${numOfTx} tx, avg speed ${avgSpeed.toFixed(1)} ms/tx`); return stateUpdate; @@ -701,7 +702,7 @@ export class ZkBobState { } } - public async logStateSync(startIndex: number, endIndex: number, decryptedMemos: DecryptedMemo[]) { + private async logStateSync(startIndex: number, endIndex: number, decryptedMemos: DecryptedMemo[]) { for (const decryptedMemo of decryptedMemos) { if (decryptedMemo.index > startIndex) { console.info(`📝 Adding hashes to state (from index ${startIndex} to index ${decryptedMemo.index - OUTPLUSONE})`); @@ -721,10 +722,10 @@ export class ZkBobState { } // returns false when the local state is inconsistent - private async verifyState(getPoolRoot: (index: bigint) => Promise): Promise { + private async verifyState(getPoolState: (index?: bigint) => Promise): Promise { const checkIndex = await this.getNextIndex(); const localRoot = await this.getRoot(); - const poolRoot = await getPoolRoot(checkIndex); + const poolRoot = (await getPoolState(checkIndex)).root; if (localRoot == poolRoot) { await this.setLastVerifiedIndex(checkIndex); @@ -764,7 +765,7 @@ export class ZkBobState { } private async startColdSync( - getPoolRoot: (index: bigint) => Promise, + getPoolState: (index?: bigint) => Promise, coldConfig?: ColdStorageConfig, coldStorageBaseAddr?: string, fromIndex?: number, @@ -827,7 +828,7 @@ export class ZkBobState { syncResult.nextIndex = actualRangeEnd; syncResult.totalTime = Date.now() - startTime; - const isStateCorrect = await this.verifyState(getPoolRoot); + const isStateCorrect = await this.verifyState(getPoolState); if (!isStateCorrect) { console.warn(`🧊[ColdSync] Merkle tree root at index ${await this.getNextIndex()} mistmatch! Wiping the state...`); await this.clean(); // rollback to 0 @@ -889,4 +890,8 @@ export class ZkBobState { public async decryptNote(symkey: Uint8Array, encrypted: Uint8Array): Promise { return this.worker.decryptNote(symkey, encrypted); } + + public async accountNullifier(): Promise { + return this.worker.accountNullifier(this.stateId); + } } \ No newline at end of file diff --git a/src/subgraph/index.ts b/src/subgraph/index.ts index 32706597..ca28abc6 100644 --- a/src/subgraph/index.ts +++ b/src/subgraph/index.ts @@ -4,14 +4,15 @@ import { hostedServiceDefaultURL } from "./resolvers"; import { ZkBobState } from "../state"; import { InternalError } from "../errors"; import { DDBatchTxDetails, DirectDeposit, DirectDepositState, - PoolTxDetails, PoolTxType, RegularTxDetails, RegularTxType + PoolTxDetails, PoolTxMinimal, PoolTxType, RegularTxDetails, RegularTxType } from "../tx"; import { decodeEvmCalldata } from '../networks/evm/calldata'; import { DepositSignerFactory } from '../signers/signer-factory'; import { NetworkBackend } from '../networks'; import { DepositType } from '../config'; import { DepositData } from '../signers/abstract-signer'; -import { toTwosComplementHex, truncateHexPrefix } from '../utils'; +import { addHexPrefix, toTwosComplementHex, truncateHexPrefix } from '../utils'; +import { CONSTANTS } from '../constants'; const SUBGRAPH_REQUESTS_PER_SECOND = 10; const SUBGRAPH_MAX_ITEMS_IN_RESPONSE = 100; @@ -126,7 +127,7 @@ export class ZkBobSubgraph { } } - // NetworkBackendd needed only for approve-deposit sender address recovering + // NetworkBackend needed only for approve-deposit sender address recovering public async getTxesDetails(indexes: number[], state: ZkBobState, network: NetworkBackend): Promise { const chunksPromises: Promise[] = []; for (let i = 0; i < indexes.length; i += SUBGRAPH_MAX_ITEMS_IN_RESPONSE) { @@ -227,4 +228,37 @@ export class ZkBobSubgraph { } })); } + + public async getTxesMinimal(fromIndex: number, count: number): Promise { + const chunksPromises: Promise[] = []; + const OUTPLUSONE = CONSTANTS.OUT + 1; // number of leaves (account + notes) in a transaction + for (let i = fromIndex; i < fromIndex + count * OUTPLUSONE; i += (SUBGRAPH_MAX_ITEMS_IN_RESPONSE * OUTPLUSONE)) { + chunksPromises.push(this.throttle.add(() => { + return this.sdk.PoolTxesFromIndex({ 'index_gte': i, 'first': SUBGRAPH_MAX_ITEMS_IN_RESPONSE }, { + subgraphEndpoint: this.subgraphEndpoint(), + }) + .then((data) => data.poolTxes) + .catch((err) => { + console.warn(`[Subgraph]: Cannot fetch txes from index ${i} (${err.message})`); + throw new InternalError(`Subgraph tx fetching error: ${err.message}`); + }); + })); + } + + const txs: any[] = []; + const chunksReady = await Promise.all(chunksPromises); + chunksReady.forEach((aChunk) => { + txs.push(...aChunk); + }) + + return Promise.all(txs.map(async (tx) => { + return { + index: Number(tx.index), + commitment: BigInt(tx.zk.out_commit).toString(16).padStart(64, '0'), // should be without hex prefix + txHash: tx.tx, // blockchain transaction hash + memo: truncateHexPrefix(tx.message), // starting from items_num, without hex prefix + isMined: true, // subgraph index only mined transactions + } + })); + } } \ No newline at end of file diff --git a/src/subgraph/tx-query.graphql b/src/subgraph/tx-query.graphql index 6a83bea9..f9e20b96 100644 --- a/src/subgraph/tx-query.graphql +++ b/src/subgraph/tx-query.graphql @@ -60,4 +60,15 @@ query PoolTxesByIndexes($index_in: [BigInt!], $first: Int = 100) { } } } +} + +query PoolTxesFromIndex($index_gte: BigInt!, $first: Int = 1000) { + poolTxes(where: {index_gte: $index_gte}, first: $first, orderBy: index) { + index + zk { + out_commit + } + tx + message + } } \ No newline at end of file diff --git a/src/tx.ts b/src/tx.ts index 000ffb83..e2a6cc07 100644 --- a/src/tx.ts +++ b/src/tx.ts @@ -29,20 +29,15 @@ export class ShieldedTx { extra: string; } -// The raw low-level append direct deposit calldata -/*export class AppendDDTx { - nullifier: bigint; - outCommit: bigint; - transferIndex: bigint; - energyAmount: bigint; - tokenAmount: bigint; - transactProof: bigint[]; - rootAfter: bigint; - treeProof: bigint[]; - txType: RegularTxType; - memo: string; - extra: string; -}*/ +// Minimal required pool transaction info +// needed to restore local state +export class PoolTxMinimal { + index: number; + commitment: string; // hex (without 0x prefix) + txHash: string; // needed to retrieve PoolTxDetails + memo: string; // hex (without 0x prefix) + isMined: boolean; +} // The top-level transaction details needed in the client library (HistoryStorage for example) export enum PoolTxType { diff --git a/src/worker.ts b/src/worker.ts index 3af4cb1b..a2917fd8 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -245,7 +245,12 @@ const obj = { async parseAddress(accountId: string, shieldedAddress: string): Promise { return zpAccounts[accountId].parseAddress(shieldedAddress); + }, + + async accountNullifier(accountId: string): Promise { + return zpAccounts[accountId].accountNullifier(); } + }; expose(obj); diff --git a/yarn.lock b/yarn.lock index 25ed184d..79e68d0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4986,15 +4986,15 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -libzkbob-rs-wasm-web-mt@1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/libzkbob-rs-wasm-web-mt/-/libzkbob-rs-wasm-web-mt-1.4.2.tgz#134140557a23f251a40995f64102d9b1091b94a2" - integrity sha512-2jvdNy20Va2e9IAlPbmewZ/7aeZMH1NBEQFhfH9BVObhVqNpNWWhna82zRXT0ebfu+HJwaZGJ1w4n6UzzYO4cw== +libzkbob-rs-wasm-web-mt@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/libzkbob-rs-wasm-web-mt/-/libzkbob-rs-wasm-web-mt-1.5.0.tgz#6a88fe3fdfcd0fe856fd791fa0976bdde2fea34b" + integrity sha512-2uwwE5mm32ITMvYgW3uPsPXLrPVutnpYB003wzDzMbPfF2EBjP1kh+sQwPUDmEl+ic4OSTXU/q3sdkWWmhUhRQ== -libzkbob-rs-wasm-web@1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/libzkbob-rs-wasm-web/-/libzkbob-rs-wasm-web-1.4.2.tgz#c69453f387817eb5d0e335d9b6a2bead28ce5b49" - integrity sha512-CJDe7lpxsS70Z/eSvVmufSq4nQZ25M59a49ZIXEyTG86hJ9xCNkCjIiSvJSZh0+UwVQI87PSkmMeQY+AefqDSQ== +libzkbob-rs-wasm-web@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/libzkbob-rs-wasm-web/-/libzkbob-rs-wasm-web-1.5.0.tgz#41c2b0a51a9283a96457bd5439cfeed27f866913" + integrity sha512-QlnFMNzqjFakkIDrST4kmOdr+OikMZVZoOi2B73F/kb/3elcPSA5vAjYM/AdmB3+Ojty4ZrQW1GnsFWJrebF5w== lie@3.1.1: version "3.1.1"