UNPKG

@nori-zk/mina-token-bridge

Version:

A Mina zk-program contract allowing users to mint tokens on Nori Bridge.

460 lines 25.8 kB
import { combineLatest, concat, distinctUntilChanged, filter, firstValueFrom, interval, map, of, shareReplay, switchMap, take, takeWhile, tap, } from 'rxjs'; import { KeyTransitionStageMessageTypes, TransitionNoticeMessageType, } from '@nori-zk/pts-types'; /** * Represents the various processing states a bridge deposit can pass through before minting is possible or missed. * * - `WaitingForEthFinality`: The deposit is awaiting Ethereum chain finality before processing can begin. * - `WaitingForCurrentJobCompletion`: The deposit is included in the current bridge job but must wait for it to finalize. * - `WaitingForPreviousJobCompletion`: The deposit is not part of the current job and must wait for its job window to open. * - `ReadyToMint`: The deposit has passed all required stages and is now eligible for minting. * - `MissedMintingOpportunity`: The deposit missed its minting window. */ export var BridgeDepositProcessingStatus; (function (BridgeDepositProcessingStatus) { BridgeDepositProcessingStatus["WaitingForEthFinality"] = "WaitingForEthFinality"; BridgeDepositProcessingStatus["WaitingForCurrentJobCompletion"] = "WaitingForCurrentJobCompletion"; BridgeDepositProcessingStatus["WaitingForPreviousJobCompletion"] = "WaitingForPreviousJobCompletion"; BridgeDepositProcessingStatus["ReadyToMint"] = "ReadyToMint"; BridgeDepositProcessingStatus["MissedMintingOpportunity"] = "MissedMintingOpportunity"; // Indeterminate })(BridgeDepositProcessingStatus || (BridgeDepositProcessingStatus = {})); function stageIndex(stage) { return KeyTransitionStageMessageTypes.indexOf(stage); // Could be absent } // Index of key stages // At this stage we can create our eth proof if we are at WaitingForCurrentJobCompletion const stageIndexProofConversionJobSucceeded = stageIndex(TransitionNoticeMessageType.ProofConversionJobSucceeded); // At this stage if our deposit was in the last window it is unsafe to mint as Eth processor's storage root would be inconsistent. const stageIndexEthProcessorTransactionSubmitSucceeded = stageIndex(TransitionNoticeMessageType.EthProcessorTransactionSubmitSucceeded); /** * Monitors the status of a bridge deposit and emits a stream of updates regarding its processing state. * * The stream emits objects containing the current bridge state, estimated time remaining, elapsed time, * deposit processing status, and the original deposit block number. It transitions through various statuses such as * WaitingForEthFinality, WaitingForCurrentJobCompletion, ReadyToMint, or MissedMintingOpportunity. * * The observable completes once the deposit is considered a missed minting opportunity. * * @param depositBlockNumber The block number in which the deposit occurred. * @param ethStateTopic$ Observable stream of Ethereum finality data. * @param bridgeStateTopic$ Observable stream of the bridge state machine. * @param bridgeTimingsTopic$ Observable stream of bridge timing configuration. * @returns An observable emitting periodic updates about the deposit's processing status. */ export const getDepositProcessingStatus$ = (depositBlockNumber, ethStateTopic$, bridgeStateTopic$, bridgeTimingsTopic$) => { // Only react when bridgeState actually changes: const combinedBridge$ = combineLatest([ bridgeStateTopic$, bridgeTimingsTopic$, ]).pipe(distinctUntilChanged((prev, curr) => JSON.stringify(prev[0]) === JSON.stringify(curr[0]))); // Combine ethState with that, but once we're past finality waiting, // ignore ethState only updates: const trigger$ = combineLatest([ethStateTopic$, combinedBridge$]).pipe(distinctUntilChanged((prev, curr) => { const [prevEth, [prevBridge, prevTimings]] = prev; const [currEth, [currBridge, currTimings]] = curr; const wasWaiting = prevEth.latest_finality_block_number < depositBlockNumber; const isWaiting = currEth.latest_finality_block_number < depositBlockNumber; // if we’ve left “waiting” mode, ignore eth-only changes: if (!isWaiting && !wasWaiting) { return (JSON.stringify(prevBridge) === JSON.stringify(currBridge) && JSON.stringify(prevTimings) === JSON.stringify(currTimings)); } // otherwise (during waiting or on transition) always fire return false; })); // On each trigger, do one time / status computation and then switch to a single interval: const status$ = trigger$.pipe(map(([ethState, [bridgeState, bridgeTimings]]) => { // Determine status let status; // Extract bridgeState properties const { stage_name, elapsed_sec, input_block_number, output_block_number, } = bridgeState; if (ethState.latest_finality_block_number < depositBlockNumber) { status = BridgeDepositProcessingStatus.WaitingForEthFinality; } else { if (input_block_number <= depositBlockNumber && depositBlockNumber <= output_block_number) { if (stage_name === TransitionNoticeMessageType.EthProcessorTransactionFinalizationSucceeded) status = BridgeDepositProcessingStatus.ReadyToMint; else status = BridgeDepositProcessingStatus.WaitingForCurrentJobCompletion; } else if (output_block_number < depositBlockNumber) { status = BridgeDepositProcessingStatus.WaitingForPreviousJobCompletion; } else { // if (despositBlockNumber < input_block_number) // Here we might still be ready to mint if our last finalized job includes our deposit in its window // AND the current job has not reached TransitionNoticeMessageType.EthProcessorTransactionSubmitSucceeded if (bridgeState.last_finalized_job === 'unknown') { // Due to a server restart we don't know what our last finalized job was we can only assume that // we missed our minting opportunity. status = BridgeDepositProcessingStatus.MissedMintingOpportunity; } else { const { input_block_number: last_input_block_number, output_block_number: last_output_block_number, } = bridgeState.last_finalized_job; const stageIdx = stageIndex(stage_name); // If the deposit was in the last finalized job window and the current job has not been submitted to mina then we can still mint if (last_input_block_number <= depositBlockNumber && depositBlockNumber <= last_output_block_number && stageIdx < stageIndexEthProcessorTransactionSubmitSucceeded) { status = BridgeDepositProcessingStatus.ReadyToMint; } else { status = BridgeDepositProcessingStatus.MissedMintingOpportunity; } } } } // Do time estimate computation let timeToWait; if (status === BridgeDepositProcessingStatus.WaitingForEthFinality) { const delta = ethState.latest_finality_slot - ethState.latest_finality_block_number; const depositSlot = depositBlockNumber + delta; const rounded = Math.ceil(depositSlot / 32) * 32; const blocksRemaining = rounded - delta - ethState.latest_finality_block_number; timeToWait = Math.max(0, blocksRemaining * 12); } else { const expected = bridgeTimings.extension[stage_name] ?? 15; timeToWait = expected - elapsed_sec; } return { status, bridgeState, timeToWait }; }), // Complete if we have MissedMintingOpportunity takeWhile(({ status }) => status !== BridgeDepositProcessingStatus.MissedMintingOpportunity, true)); return status$.pipe(switchMap(({ status, bridgeState, timeToWait }) => { return concat(of(0), // emit immediately interval(1000) // then every 1s ).pipe( // Complete if we have MissedMintingOpportunity takeWhile(() => status !== BridgeDepositProcessingStatus.MissedMintingOpportunity, true), // Calculate timeRemaining map((tick) => { let timeRemaining = timeToWait - tick + 1; if (bridgeState.stage_name === TransitionNoticeMessageType.EthProcessorTransactionFinalizationSucceeded && status !== BridgeDepositProcessingStatus.WaitingForEthFinality) { timeRemaining = ((timeRemaining % 384) + 384) % 384; } return { ...bridgeState, time_remaining_sec: timeRemaining, elapsed_sec: tick, deposit_processing_status: status, deposit_block_number: depositBlockNumber, }; })); }), shareReplay(1)); }; /** * Waits until a deposit reaches the `ReadyToMint` status, or throws if the opportunity is missed. * * Resolves as soon as the deposit processing status becomes `ReadyToMint`. Throws an error * if the deposit transitions to `MissedMintingOpportunity` instead. * * @param depositProcessingStatus$ Observable emitting deposit processing updates. * @returns A promise resolving to true once the deposit is ready to mint. * @throws Error if the minting opportunity is missed. */ export async function canMint(depositProcessingStatus$) { return firstValueFrom(depositProcessingStatus$.pipe( // Error if we have missed our minting opportunity tap(({ deposit_processing_status }) => { if (deposit_processing_status === BridgeDepositProcessingStatus.MissedMintingOpportunity) { throw new Error('Minting opportunity missed.'); } }), // Wait for ReadyToMint before resolving filter(({ deposit_processing_status }) => deposit_processing_status === BridgeDepositProcessingStatus.ReadyToMint), // Only take one event take(1), // Map to a boolean map(() => true))); } // Implement a trinary version just works out our stage between ready, missed and a generic 'waiting' export const canMintStatus = [ BridgeDepositProcessingStatus.MissedMintingOpportunity, BridgeDepositProcessingStatus.ReadyToMint, 'Waiting', ]; export const canMintWaitingStatuses = [ BridgeDepositProcessingStatus.WaitingForCurrentJobCompletion, BridgeDepositProcessingStatus.WaitingForEthFinality, BridgeDepositProcessingStatus.WaitingForPreviousJobCompletion, ]; /** * Emits the current minting status as one of three possible values: * `ReadyToMint`, `MissedMintingOpportunity`, or `'Waiting'`. * * - Emits `'Waiting'` when the deposit is in any of the waiting states defined in `canMintWaitingStatuses`. * - Emits `BridgeDepositProcessingStatus.ReadyToMint` when the deposit is ready to mint. * - Emits `BridgeDepositProcessingStatus.MissedMintingOpportunity` and completes when the minting opportunity has been missed. * * Any other deposit processing statuses are ignored (no emission). * Duplicate consecutive statuses are suppressed. * * @param depositProcessingStatus$ Observable emitting deposit processing updates. * @returns Observable emitting the trinary mint status (`ReadyToMint`, `MissedMintingOpportunity`, or `'Waiting'`). */ export function getCanMint$(depositProcessingStatus$) { return depositProcessingStatus$.pipe(distinctUntilChanged((prev, curr) => prev.deposit_processing_status === curr.deposit_processing_status), map(({ deposit_processing_status }) => { if (deposit_processing_status === BridgeDepositProcessingStatus.MissedMintingOpportunity) { return BridgeDepositProcessingStatus.MissedMintingOpportunity; } else if (deposit_processing_status === BridgeDepositProcessingStatus.ReadyToMint) { return BridgeDepositProcessingStatus.ReadyToMint; } else if (canMintWaitingStatuses.includes(deposit_processing_status)) { return 'Waiting'; } else { // Suppress statuses not in any category by returning undefined return undefined; } }), // Filter out undefined values filter((status) => status !== undefined), // Suppress duplicates distinctUntilChanged(), // Complete when the minting opportunity is missed takeWhile((status) => status !== BridgeDepositProcessingStatus.MissedMintingOpportunity, true)); } /** * Waits until the deposit is eligible for mint proof generation, based on bridge state. * * Specifically, resolves when: * - Deposit status is `ReadyToMint`, or * - Deposit status is `WaitingForCurrentJobCompletion` and the stage index is at or beyond `ProofConversionJobSucceeded`, or * - Deposit is in the last deposit window (between the last finalized job’s input and output blocks), * the current job’s stage index is before `EthProcessorTransactionSubmitSucceeded`, * and the current job’s block range is not identical to the last finalized job’s block range * (edge case occurring when last finalized job updates at the `EthProcessorTransactionFinalizationSucceeded` event). * * Emits a warning if the deposit is in the last window and the stage index is greater than * `ProofConversionJobSucceeded` (excluding the above edge case). * * Throws an error if the deposit processing status becomes `MissedMintingOpportunity`. * * @param depositProcessingStatus$ Observable emitting deposit processing updates. * @returns A promise resolving to true when mint proof generation is ready. * @throws Error if the minting opportunity is missed. */ export function readyToComputeMintProof(depositProcessingStatus$) { // FIXME we could probably simplify this! // If our status is ReadyToMint then we are good (but possibly warn) // If our status is WaitingForCurrentJobCompletion and > stageIndex(TransitionNoticeMessageType.ProofConversionJobReceived) return firstValueFrom(depositProcessingStatus$.pipe( // Extend emitted values with computed properties for clarity and reuse map((data) => { const { last_finalized_job, deposit_block_number, input_block_number, output_block_number, } = data; // Determine if last finalized job is known (not 'unknown') const last_finalized_known = last_finalized_job !== 'unknown'; // Check if deposit is in the last deposit window const deposit_is_in_last_window = last_finalized_known && last_finalized_job.input_block_number <= deposit_block_number && deposit_block_number <= last_finalized_job.output_block_number; // Edge case: current job’s block range equals the last finalized job’s range. // This happens specifically at the `EthProcessorTransactionFinalizationSucceeded` event, // when the last finalized job info is updated with the current job's values, // causing both last finalized and current job block ranges to be identical. const current_job_equals_last_finalized = last_finalized_known && last_finalized_job.input_block_number === input_block_number && last_finalized_job.output_block_number === output_block_number; return { ...data, last_finalized_known, deposit_is_in_last_window, current_job_equals_last_finalized, }; }), tap(({ deposit_processing_status, stage_name, deposit_is_in_last_window, current_job_equals_last_finalized, }) => { // Throw if minting opportunity is missed if (deposit_processing_status === BridgeDepositProcessingStatus.MissedMintingOpportunity) { throw new Error('Minting opportunity missed.'); } // Warn if deposit is in the last window and the stage is advanced past ProofConversionJobReceived, // excluding the edge case where the current job’s block range equals the last finalized job’s block range. // This needs checking. if (deposit_processing_status != BridgeDepositProcessingStatus.ReadyToMint && deposit_is_in_last_window && stageIndex(stage_name) > stageIndexProofConversionJobSucceeded && !current_job_equals_last_finalized) { console.warn('Warning: Deposit is in the last window and stage is advanced beyond ProofConversionJobReceived. Your cutting it close to snipe the window.'); } }), filter(({ deposit_processing_status, stage_name, deposit_is_in_last_window, current_job_equals_last_finalized, }) => { // Accept if ReadyToMint if (deposit_processing_status === BridgeDepositProcessingStatus.ReadyToMint) return true; const stageIdx = stageIndex(stage_name); // Accept if deposit status is WaitingForCurrentJobCompletion // and stage is at or beyond ProofConversionJobSucceeded const waitingForCurrentJobAndStageOk = deposit_processing_status === BridgeDepositProcessingStatus.WaitingForCurrentJobCompletion && stageIdx >= stageIndexProofConversionJobSucceeded; // // Accept if deposit is in the last deposit window AND // the current job stage is not finalized, // excluding the edge case where current job equals last finalized job const inLastWindowAndNotAtMinaFinalizedStage = deposit_is_in_last_window && stageIdx < stageIndexEthProcessorTransactionSubmitSucceeded && //stage_name !== // TransitionNoticeMessageType.EthProcessorTransactionFinalizationSucceeded && // This may be too late! !current_job_equals_last_finalized; // Redundant be helped me rationalise it return (waitingForCurrentJobAndStageOk || inLastWindowAndNotAtMinaFinalizedStage); }), take(1), map(() => true))); } // Create a trinary version of this which just has 'waiting', 'canCompute', 'missed' export const canComputeEthProof = [ 'Waiting', 'CanCompute', BridgeDepositProcessingStatus.MissedMintingOpportunity, ]; /** * Emits the current proof computation status as one of three possible values: * `'CanCompute'`, `'Waiting'`, or `BridgeDepositProcessingStatus.MissedMintingOpportunity`. * * - Emits `'Waiting'` if none of the above conditions are met and the opportunity has not been missed. * - Emits `'CanCompute'` if: * - Deposit status is `ReadyToMint`, or * - Deposit status is `WaitingForCurrentJobCompletion` and the stage is at or beyond `ProofConversionJobSucceeded`, or * - Deposit is in the last deposit window, the current job stage index is before `EthProcessorTransactionSubmitSucceeded`, * and the current job’s block range is not identical to the last finalized job’s block range (edge-case avoidance). * - Emits `BridgeDepositProcessingStatus.MissedMintingOpportunity` and completes if the deposit transitions to a missed opportunity state. * * Consecutive duplicate values are suppressed. * Null intermediate values (indeterminate state) are not emitted. * * @param depositProcessingStatus$ Observable emitting deposit processing updates. * @returns Observable emitting `'canCompute'`, `'waiting'`, or `MissedMintingOpportunity`. */ export function getCanComputeEthProof$(depositProcessingStatus$) { return depositProcessingStatus$.pipe(map(({ deposit_processing_status, stage_name, last_finalized_job, deposit_block_number, input_block_number, output_block_number, }) => { if (deposit_processing_status === BridgeDepositProcessingStatus.MissedMintingOpportunity) return deposit_processing_status; // Otherwise deal with determining if we are waiting or if we can compute. // If we can mint we can compute the eth proof. if (deposit_processing_status === BridgeDepositProcessingStatus.ReadyToMint) return 'CanCompute'; // Can compute if deposit status is WaitingForCurrentJobCompletion // and stage is at or beyond ProofConversionJobSucceeded const stageIdx = stageIndex(stage_name); if (deposit_processing_status === BridgeDepositProcessingStatus.WaitingForCurrentJobCompletion && stageIdx >= stageIndexProofConversionJobSucceeded) return 'CanCompute'; // Otherwise if we are in the last batch but the current job has not gotten to the stage of the mina // tx being submitted we can compute. // Determine if last finalized job is known (if it isnt known we cannot decide this yet). if (last_finalized_job === 'unknown') return null; // Check if deposit is in the last deposit window const deposit_is_in_last_window = last_finalized_job.input_block_number <= deposit_block_number && deposit_block_number <= last_finalized_job.output_block_number; const current_job_equals_last_finalized = last_finalized_job.input_block_number === input_block_number && last_finalized_job.output_block_number === output_block_number; // If the deposit is in the last window and the current job has not been sent to mina we can still mint. if (deposit_is_in_last_window && stageIdx < stageIndexEthProcessorTransactionSubmitSucceeded && !current_job_equals_last_finalized) { return 'CanCompute'; } // Otherwise we are waiting return 'Waiting'; }), // Filter nulls filter((value) => value !== null), // Suppress duplicates distinctUntilChanged(), // Complete when the minting opportunity is missed takeWhile((status) => status !== BridgeDepositProcessingStatus.MissedMintingOpportunity, true)); } /** * Waits for the next combined emission of Ethereum finalization state, bridge state, and bridge timing data. * * **Unsafe Method Warning:** Awaiting this function before calling getDepositProcessingStatus may incorrectly * identify a deposit as having missed its minting opportunity if the bridge has finalized the deposit’s proof * window, started processing a new job that has not yet emitted an `EthProcessorTransactionFinalizationSucceeded` * transition notice, and a websocket server restart caused loss of the last finalized job state. However, using * the safe method may require the user to wait for the current bridge job to complete, which could take many minutes, * before locking is allowed. * * Using this “unsafe” method trades accuracy for speed: it returns as soon as all three streams emit once, * potentially allowing a user to lock sooner, at the risk of misclassification of the deposit status. * * @param ethStateTopic$ Observable stream of Ethereum finalization state. * @param bridgeStateTopic$ Observable stream of the bridge’s processing state. * @param bridgeTimingsTopic$ Observable stream of the bridge’s timing parameters. * @returns A promise resolving to a tuple [ethState, bridgeState, bridgeTimings] containing the latest * values from each stream. */ export function bridgeStatusesKnownEnoughToLockUnsafe(ethStateTopic$, bridgeStateTopic$, bridgeTimingsTopic$) { return firstValueFrom(combineLatest([ethStateTopic$, bridgeStateTopic$, bridgeTimingsTopic$])); } /** * Waits for the next combined emission of Ethereum finalization state, bridge state, and bridge timing data, * ensuring that the last finalized bridge job is known before resolving. * * **Safe Method Guarantee:** Awaiting this function guarantees accurate classification of the deposit status * when using the getDepositProcessingStatus function by waiting until the bridge reports a known `last_finalized_job`. * This ensures the minting opportunity window for the deposit can be definitively determined. * * This prevents unsafe assumptions that could occur if the bridge has finalized the deposit’s proof window * and started processing a new job, **and** a websocket server restart caused loss of the last finalized job state— * leading to incorrect classification of the current deposit as having missed its minting opportunity. * * However, this safety comes at the cost of responsiveness: users may be forced to wait until the current job * has been finalized (which may take up to 30 minutes) before locking is permitted. * * This method prioritizes correctness over responsiveness, and should be used in user-facing flows where * reliable deposit status is required. * * @param ethStateTopic$ Observable stream of Ethereum finalization state. * @param bridgeStateTopic$ Observable stream of the bridge’s processing state. * @param bridgeTimingsTopic$ Observable stream of the bridge’s timing parameters. * @returns A promise resolving to a tuple [ethState, bridgeState, bridgeTimings] only when the last * finalized job is known. */ export function bridgeStatusesKnownEnoughToLockSafe(ethStateTopic$, bridgeStateTopic$, bridgeTimingsTopic$) { return firstValueFrom(combineLatest([ ethStateTopic$, bridgeStateTopic$, bridgeTimingsTopic$, ]).pipe(filter(([_, bridgeState, __]) => { return bridgeState.last_finalized_job !== 'unknown'; }))); } //# sourceMappingURL=deposit.js.map