UNPKG

@nori-zk/mina-token-bridge

Version:

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

620 lines (613 loc) 31.4 kB
import { Field, Bytes, UInt64, } from 'o1js'; import { TransitionNoticeMessageType, } from '@nori-zk/pts-types'; import { InvertedPromise, NodeProofLeft, wordToBytes } from '@nori-zk/proof-conversion/min'; import { clearInterval } from 'node:timers'; import { EthVerifier, ContractDepositAttestor, Bytes20, Bytes32, ContractDeposit, buildContractDepositLeaves, getContractDepositWitness, computeMerkleTreeDepthAndSize, foldMerkleLeft, getMerkleZeros, ContractDepositAttestorInput, EthInput, decodeConsensusMptProof, fieldToBigIntLE, } from '@nori-zk/o1js-zk-utils'; import { EthDepositProgramInput, EthDepositProgram, } from './e2ePrerequisites.js'; import { fieldToHexBE, uint8ArrayToBigIntBE, } from '@nori-zk/o1js-zk-utils/build/utils.js'; import { hexStringToUint8Array } from './testUtils.js'; describe('should perform an end to end pipeline', () => { async function connectWebsocket(onData, onClose) { return new Promise((resolve, reject) => { let timeout; const webSocket = new WebSocket('wss://wss.nori.it.com'); webSocket.addEventListener('open', (event) => { console.log('WebSocket is opened', event); timeout = setInterval(() => { webSocket.send(JSON.stringify({ method: 'ping' })); // Keep the connection alive }, 3000); resolve(webSocket); }); webSocket.addEventListener('error', (event) => { console.error('Websocket Error', event); if (timeout) clearInterval(timeout); reject(webSocket); }); webSocket.addEventListener('message', (event) => { if (event.data === '{"data":"pong"}') return; // Ignore pong results onData(event); }); webSocket.addEventListener('close', (event) => onClose(event)); }); } async function proofConversionServiceRequest(depositBlockNumber) { const fetchResponse = await fetch(`https://pcs.nori.it.com/converted-consensus-mpt-proofs/${depositBlockNumber}`); console.log('fetchResponse GET', fetchResponse); const json = await fetchResponse.json(); console.log('parsedjson', json, typeof json); if ('error' in json) throw new Error(json.error); return json; } async function lockTokens(attestationHash, amount) { // Lock guard expect(amount).toBeLessThan(0.001); // Ensure we can do the field -> hex -> field round trip const beBytes = Bytes.from(wordToBytes(attestationHash, 32).reverse()); const attestationHex = beBytes.toHex(); console.log('attestationHex', attestationHex); const bytesFromHex = Bytes.fromHex(attestationHex); // this is be let fieldFromHex = new Field(0); for (let i = 0; i < 32; i++) { fieldFromHex = fieldFromHex .mul(256) .add(bytesFromHex.bytes[i].value); } expect(fieldFromHex.toBigInt()).toEqual(attestationHash.toBigInt()); console.log(fieldFromHex.toBigInt(), attestationHash.toBigInt()); // Use the ethereum package to lock our tokens const { spawn } = await import('node:child_process'); const { fileURLToPath } = await import('url'); const { resolve, dirname } = await import('node:path'); const __filename = fileURLToPath(import.meta.url); const rootDir = dirname(__filename); const commandDetails = [ 'npm', ['run', 'test:lock', `0x${attestationHex}`, amount.toString()], { cwd: resolve(rootDir, '..', '..', '..', 'ethereum') }, ]; console.log('commandDetails', commandDetails); const [command, args, options] = commandDetails; const child = spawn(command, args, options); let data = ''; let error = ''; for await (let chunk of child.stdout) { data += chunk; } for await (let chunk of child.stderr) { error += chunk; } await new Promise((resolve, reject) => child.on('close', (code) => { if (code) return reject(new Error(`Process exited non zero code ${code}\n${error}`)); resolve(code); })); console.log(`Lock output:\n${data}`); console.log('----------------------'); const match = data.match(/Transaction included in block number: (\d+)/); if (!match) return null; return parseInt(match[1]); } async function getEthereumEnvPrivateKey() { const { fileURLToPath } = await import('url'); const { resolve, dirname } = await import('node:path'); const __filename = fileURLToPath(import.meta.url); const rootDir = dirname(__filename); const fs = await import('fs'); const dotenv = await import('dotenv'); const envBuffer = fs.readFileSync(resolve(rootDir, '..', '..', '..', 'ethereum', '.env')); const parsed = dotenv.parse(envBuffer); //console.log(parsed); return parsed.ETH_PRIVATE_KEY; } async function getEthWallet() { const privateKey = await getEthereumEnvPrivateKey(); const { ethers } = await import('ethers'); return new ethers.Wallet(privateKey); } beforeAll(() => { }); test('get_eth_address', async () => { console.log((await getEthWallet()).address); }); /*test('websockets_test', async () => { const webSocket = await connectWebsocket((event) => { console.log(JSON.stringify(JSON.parse(event.data),null,2)); }, console.error); // Subscribe to relevant topics needed to facilitate bridging. webSocket.send( JSON.stringify({ method: 'subscribe', topic: 'state.eth', }) ); webSocket.send( JSON.stringify({ method: 'subscribe', topic: 'state.bridge', }) ); webSocket.send( JSON.stringify({ method: 'subscribe', topic: 'timings.notices.transition', }) ); const invertedPromise = new InvertedPromise(); await invertedPromise.promise; });*/ /*test('should_get_credential', async () => { const ethWallet = await getEthWallet(); let { publicKey: minaPubKey } = PrivateKey.randomKeypair(); const credential = await getCredential(ethWallet, minaPubKey); console.log( '✅ created credential', Credential.toJSON(credential).slice(0, 1000) + '...' ); console.log('--------------------'); console.log(JSON.stringify(credential.witness.proof, null, 2)); console.log('--------------------'); //console.log(JSON.stringify(credential.witness.proof.proof, null, 2)); // this is a big int credential.witness.type; credential.witness.vk; await Credential.validate(credential); // Todo credential presentation });*/ test('connect_to_wss_and_await_message', async () => { const invertedPromise = new InvertedPromise(); function onData(event) { console.log('Got first message', event.data); invertedPromise.resolve(event); } function onClose(event) { console.error('Connection closed', event); invertedPromise.reject(event); } const webSocket = await connectWebsocket(onData, onClose); webSocket.send(JSON.stringify({ method: 'subscribe', topic: 'timings.notices.transition', })); await invertedPromise.promise; webSocket.close(); }, 1000000000); test('fetch_proof_from_block_number', async () => { await proofConversionServiceRequest(4162671); }); test('fetch_proof_from_block_number_handle_error', async () => { const responseJson = proofConversionServiceRequest('hello'); expect(responseJson).rejects.toThrow("Invalid block number 'hello'"); }); test('lock_token', async () => { const blockNumber = await lockTokens(new Field(10111011), 0.000001); console.log('block_number', blockNumber); }); /*test('timing_service', async () => { const exitPromise = new InvertedPromise(); function onClose(event: CloseEvent) { console.error('Connection closed', event); exitPromise.reject(event); } let lastElapsedTime: { [key in TransitionNoticeMessageType]?: number; } = {}; function onData(event: MessageEvent<string>) { let data = JSON.parse( event.data ) as WebSocketServiceTopicSubscriptionMessage; if (data.message_type === 'TransitionTiming') { lastElapsedTime = data.extension; } } setInterval(() => { console.log(lastElapsedTime); }, 1000); const webSocket = await connectWebsocket(onData, onClose); webSocket.send( JSON.stringify({ method: 'subscribe', topic: 'timings.notices.transition', }) ); await exitPromise.promise; webSocket.close(); }, 1000000000);*/ test('e2e_pipeline_with_services', async () => { // Before we start we need access to a wallet and an attested credential.... // Get eth wallet ----------------------------------------------------------- const ethWallet = await getEthWallet(); const ethAddressLowerHex = ethWallet.address.toLowerCase(); console.log('ethAddressLowerHex', ethAddressLowerHex); // Get attestation properly TODO // const bytes = Bytes.from(<bigInt credintial.witness.proof.proof>) // bytes.toFields() // then poseidon hash these fields together (might this be to expensive as proofs are like 100k?) const credentialAttestationHash = Field.random(); // For now random mock const beAttestationHashBytes = Bytes.from(wordToBytes(credentialAttestationHash, 32).reverse()); const attestationBEHex = `0x${beAttestationHashBytes.toHex()}`; // this does not have the 0x.... console.log('attestationBEHex', attestationBEHex); // Now we can start thinking about the bridge's status and the locking. // Setup first the bridge head state machine... const initPromise = new InvertedPromise(); const exitPromise = new InvertedPromise(); let bridgeStageTimings; let bridgeState; let ethState; let highLevelState = 'initalising'; let subStage = null; let depositBlockNumber; let stageWaitTime = null; let timeTrackInterval; let ethFinalityTickerHandler; let stageElapsedSecIncrementorTimeout; // Utility to calculate the slot -> block delta function getBlockToSlotDelta() { if (ethState == null && Number.isFinite(ethState.latest_finality_block_number) && Number.isFinite(ethState.latest_finality_slot)) return undefined; return (ethState.latest_finality_slot - ethState.latest_finality_block_number); } // Estimator for eth finality transitions // TODO how to improve this estimate? // Perhaps inspect how far we are from finality but if we exceed it by some margin assume we wait for the next finality before // the bridge head accepts this new state.... due to low vote counts being rejected. function setEthFinalityEstimatorTicker(depositBlockNumber) { const delta = getBlockToSlotDelta(); if (delta === undefined) return; const depositSlot = depositBlockNumber + delta; const roundedSlot = Math.ceil(depositSlot / 32) * 32; const targetBlock = roundedSlot - delta; const blocksRemaining = targetBlock - ethState.latest_finality_block_number; let timeToWait = blocksRemaining * 12; stageWaitTime = Math.max(0, timeToWait); clearInterval(ethFinalityTickerHandler); ethFinalityTickerHandler = setInterval(() => { timeToWait -= 1; stageWaitTime = Math.max(0, timeToWait); if (stageWaitTime === 0) { // if we hit zero guess that it will occur in next finality time... timeToWait = 384; } }, 1000); } // Utility to track compared to our deposit do we need to wait for finality. let previousFinalityBlockNumber = null; function determineDepositMintReadyness() { if (ethState.latest_finality_block_number === 'unknown') return; const hasChanged = ethState.latest_finality_block_number !== previousFinalityBlockNumber; if (!hasChanged) return; if (!['locking_tokens', 'waiting_for_eth_finality'].includes(highLevelState)) return; previousFinalityBlockNumber = ethState.latest_finality_block_number; const diff = depositBlockNumber - ethState.latest_finality_block_number; console.log('diff', diff); if (diff > 0) { highLevelState = 'waiting_for_eth_finality'; setEthFinalityEstimatorTicker(depositBlockNumber); } else { const inputBlock = bridgeState.input_block_number; const outputBlock = bridgeState.output_block_number; if (inputBlock <= depositBlockNumber && depositBlockNumber <= outputBlock) { // Deposit is in the current job window highLevelState = 'waiting_for_current_job_completion'; } else if (outputBlock < depositBlockNumber) { // Job has not yet reached the deposit highLevelState = 'waiting_for_previous_job_completion'; } else { // Deposit missed the job window highLevelState = 'missed_minting_oppertunity'; } stageWaitTime = 0; clearInterval(ethFinalityTickerHandler); trackJobCompletion(); } } let ethVerifierProof; let depositAttestationProof; let despositSlotRaw; let proofsBuilt = false; let invokedCompute = false; // When subStage === TransitionNoticeMessageType.ProofConversionJobSucceeded && highLevelState === 'waiting_for_current_job_completion' // Occurs the proof conversion will have finished and a proof bundle + the window's contract deposits will be store and ready to be served by pcs.nori.it.com // Fetch the bundle... Compute the deposit attestation and verify the proof. Set proofsBuilt=true when all done.... async function fetchContractWindowProofsSlotsAndCompute() { if (invokedCompute === true) return; invokedCompute = true; console.log(`Fetching proof bundle for deposit with block number: ${depositBlockNumber}`); console.time('proofConversionServiceRequest'); const { consensusMPTProof: { proof: consensusMPTProofProof, contract_storage_slots: consensusMPTProofContractStorageSlots, }, consensusMPTProofVerification: consensusMPTProofVerification, } = await proofConversionServiceRequest(depositBlockNumber); console.timeEnd('proofConversionServiceRequest'); console.log('consensusMPTProofVerification, consensusMPTProofProof, consensusMPTProofContractStorageSlots', consensusMPTProofVerification, consensusMPTProofProof, consensusMPTProofContractStorageSlots); // Find deposit console.log(`Finding deposit within bundle.consensusMPTProof.contract_storage_slots`); const depositIndex = consensusMPTProofContractStorageSlots.findIndex((slot) => slot.slot_key_address === ethAddressLowerHex && slot.slot_nested_key_attestation_hash === attestationBEHex); if (depositIndex === -1) throw new Error(`Could not find deposit index with attestationBEHex: ${attestationBEHex}, ethAddressLowerHex:${ethAddressLowerHex} in slots ${JSON.stringify(consensusMPTProofContractStorageSlots, null, 4)}`); console.log(`Found deposit within bundle.consensusMPTProof.contract_storage_slots`); despositSlotRaw = consensusMPTProofContractStorageSlots[depositIndex]; const totalDespositedValue = despositSlotRaw.value; // this is a hex // would be nice here to print a bigint console.log(`Total deposited to date (hex): ${totalDespositedValue}`); // Build contract storage slots (to be hashed) const contractStorageSlots = consensusMPTProofContractStorageSlots.map((slot) => { console.log({ add: slot.slot_key_address.slice(2).padStart(40, '0'), attr: slot.slot_nested_key_attestation_hash .slice(2) .padStart(64, '0'), value: slot.value.slice(2).padStart(64, '0'), }); const addr = Bytes20.fromHex(slot.slot_key_address.slice(2).padStart(40, '0')); const attestation = Bytes32.fromHex(slot.slot_nested_key_attestation_hash .slice(2) .padStart(64, '0')); const value = Bytes32.fromHex(slot.value.slice(2).padStart(64, '0')); return new ContractDeposit({ address: addr, attestationHash: attestation, value, }); }); // Select our deposit const depositSlot = contractStorageSlots[depositIndex]; // Build deposit witness // Build leaves console.time('buildContractDepositLeaves'); const leaves = buildContractDepositLeaves(contractStorageSlots); console.timeEnd('buildContractDepositLeaves'); // Compute path console.time('getContractDepositWitness'); const path = getContractDepositWitness([...leaves], depositIndex); console.timeEnd('getContractDepositWitness'); // Compute root const { depth, paddedSize } = computeMerkleTreeDepthAndSize(leaves.length); console.time('foldMerkleLeft'); const rootHash = foldMerkleLeft(leaves, paddedSize, depth, getMerkleZeros(depth)); console.timeEnd('foldMerkleLeft'); console.log(`Computed Merkle root: ${rootHash.toString()}`); // Build ZK input const depositProofInput = new ContractDepositAttestorInput({ rootHash, path, index: UInt64.from(depositIndex), value: depositSlot, }); console.log('Prepared ContractDepositAttestorInput'); // Prove deposit console.time('ContractDepositAttestor.compute'); // Retype because of erasure at package level :( depositAttestationProof = (await ContractDepositAttestor.compute(depositProofInput)).proof; console.timeEnd('ContractDepositAttestor.compute'); // Verify consensus mpt proof console.log('Loaded sp1PlonkProof and conversionOutputProof'); const ethVerifierInput = new EthInput(decodeConsensusMptProof(consensusMPTProofProof)); console.log('Decoded EthInput from MPT proof'); console.log('Parsing raw SP1 proof using NodeProofLeft.fromJSON'); // Watch out because the ts ignore prevent you seeing if NodeProofLeft has been imported! // @ts-ignore this is silly! why! const rawProof = await NodeProofLeft.fromJSON(consensusMPTProofVerification.proofData); console.log('Parsed raw SP1 proof using NodeProofLeft.fromJSON'); console.log('Computing EthVerifier'); console.time('EthVerifier.compute'); ethVerifierProof = (await EthVerifier.compute(ethVerifierInput, rawProof)).proof; console.timeEnd('EthVerifier.compute'); console.log(`All proofs built needed to mint!`); proofsBuilt = true; } // A mock program which mostly demonstraights the pre-requisites needed to actually mint. let invokedMintMock = false; async function performMintMock() { if (highLevelState !== 'can_mint') return; if (invokedMintMock === true) return; if (proofsBuilt === false) { console.warn('warning proofsBuilt is can_mint but proofsBuilt === false'); return; } invokedMintMock = true; highLevelState = 'minting'; const e2ePrerequisitesInput = new EthDepositProgramInput({ credentialAttestationHash, }); console.time('E2EPrerequisitesProgram.compute'); const e2ePrerequisitesProof = await EthDepositProgram.compute(e2ePrerequisitesInput, ethVerifierProof, depositAttestationProof); console.timeEnd('E2EPrerequisitesProgram.compute'); console.log('Computed E2EPrerequisitesProgram proof'); const { totalLocked, storageDepositRoot, attestationHash } = e2ePrerequisitesProof.proof.publicOutput; // Change these to asserts in future console.log('--- Decoded public output ---'); console.log(`proved [totalLocked] (LE bigint): ${fieldToBigIntLE(totalLocked)}`); console.log('bridge head [totalLocked] (BE bigint):', uint8ArrayToBigIntBE(hexStringToUint8Array(despositSlotRaw.value))); console.log(`proved [attestationHash] (BE hex): ${fieldToHexBE(attestationHash)}`); console.log(`bridge head [attestationHash] (BE hex):`, despositSlotRaw.slot_nested_key_attestation_hash); console.log(`original [attestationHash] (BE Hex):`, attestationBEHex); // Address console.log('original [address]:', ethAddressLowerHex); console.log('bridge head [address]:', despositSlotRaw.slot_key_address); // what about checking depositAttestationProof depositAttestationProof.publicInput.value.address // todo print something to show storageDepositRoot highLevelState = 'minted'; exitPromise.resolve(); } // When the bridge state changes check whether or not we can mint async function trackJobCompletion() { if (![ 'waiting_for_previous_job_completion', 'waiting_for_current_job_completion', ].includes(highLevelState)) return; if (subStage === bridgeState.stage_name) return; if (subStage === TransitionNoticeMessageType.ProofConversionJobSucceeded && highLevelState === 'waiting_for_current_job_completion') { await fetchContractWindowProofsSlotsAndCompute(); } else if (subStage === TransitionNoticeMessageType.EthProcessorTransactionFinalizationSucceeded) { // but here we need to detect if our finalized eth status is beyond the last block number because otherwise we are again waiting for finalization before the job will // resume FIXME if (highLevelState === 'waiting_for_previous_job_completion') { highLevelState = 'waiting_for_current_job_completion'; } else if (highLevelState === 'waiting_for_current_job_completion') { highLevelState = 'can_mint'; } clearInterval(timeTrackInterval); stageWaitTime = 0; } subStage = bridgeState.stage_name; let timeEstimate = Number(bridgeStageTimings[bridgeState.stage_name]) || 15; timeEstimate -= bridgeState.elapsed_sec; stageWaitTime = timeEstimate; clearInterval(timeTrackInterval); timeTrackInterval = setInterval(() => { timeEstimate--; stageWaitTime = timeEstimate; if (subStage === TransitionNoticeMessageType.EthProcessorTransactionFinalizationSucceeded && timeEstimate === -1) { // if we go negative then here we can assume that // we are awaiting eth finality (FIXME make this more robust) timeEstimate = 384; stageWaitTime = timeEstimate; } }, 1000); } // Websocket server wss.nori.it.com event handlers. // Callback method for subscribed events from wss.nori.it.com async function onData(event) { try { const data = JSON.parse(event.data); // Eth finalization status has changes if (data.topic === 'state.eth') { console.log('state.eth', data); ethState = data.extension; determineDepositMintReadyness(); } // The bridge has progressed with its jobs else if (data.topic === 'state.bridge') { console.log('state.bridge', data); bridgeState = data.extension; clearInterval(stageElapsedSecIncrementorTimeout); stageElapsedSecIncrementorTimeout = setTimeout(() => { bridgeState.elapsed_sec += 1; }, 1000); await trackJobCompletion(); await performMintMock(); } // We have an update to timing estimates for the various bridge stages else if (data.topic === 'timings.notices.transition') { //console.log('timings.notices.transition', data); bridgeStageTimings = data.extension; } // If we have enough of a picture of the bridge head stage allow the user to lock tokens. if (ethState && ethState.latest_finality_block_number !== 'unknown' && bridgeState && bridgeState.stage_name !== 'unknown' && bridgeStageTimings) initPromise.resolve(); } catch (e) { const error = e; console.error(error.stack); exitPromise.reject(error); initPromise.reject(error); } } // On wss.nori.it.com websocket close handler. function onClose(event) { console.error('Connection closed', event); exitPromise.reject(event); } // Create a websocket connect to wss.nori.it.com const webSocket = await connectWebsocket(onData, onClose); // Subscribe to relevant topics needed to facilitate bridging. webSocket.send(JSON.stringify({ method: 'subscribe', topic: 'state.eth', })); webSocket.send(JSON.stringify({ method: 'subscribe', topic: 'state.bridge', })); webSocket.send(JSON.stringify({ method: 'subscribe', topic: 'timings.notices.transition', })); // Start printing status let stageWaitPrinterHandler; function printStageWaitTime() { clearInterval(stageWaitPrinterHandler); stageWaitPrinterHandler = setInterval(() => { if (subStage) { console.log([ `Deposit block number: ${depositBlockNumber ?? 'unknown'}`, `State: ${highLevelState}`, `Stage: ${subStage}`, `Stage wait time: ${stageWaitTime}`, ].join(' | ')); } else { console.log([ `Deposit block number: ${depositBlockNumber ?? 'unknown'}`, `State: ${highLevelState}`, `Stage wait time: ${stageWaitTime}`, ].join(' | ')); } }, 1000); } printStageWaitTime(); // Pre compile programs ----------------------------------------------------------- console.time('ContractDepositAttestor compile'); const { verificationKey: contractDepositAttestorVerificationKey } = await ContractDepositAttestor.compile({ forceRecompile: true }); console.timeEnd('ContractDepositAttestor compile'); console.log(`ContractDepositAttestor contract compiled vk: '${contractDepositAttestorVerificationKey.hash}'.`); console.time('EthVerifier compile'); const { verificationKey: ethVerifierVerificationKey } = await EthVerifier.compile({ forceRecompile: true }); console.timeEnd('EthVerifier compile'); console.log(`EthVerifier compiled vk: '${ethVerifierVerificationKey.hash}'.`); console.time('E2EPrerequisitesProgram compile'); const { verificationKey: e2ePrerequisitesVerificationKey } = await EthDepositProgram.compile({ forceRecompile: true }); console.timeEnd('E2EPrerequisitesProgram compile'); console.log(`E2EPrerequisitesProgram contract compiled vk: '${e2ePrerequisitesVerificationKey.hash}'.`); // We have a good enough picture of the bridges state to allow the user to mint. await initPromise.promise; // Lock user tokens highLevelState = 'locking_tokens'; console.log('locking tokens...'); depositBlockNumber = await lockTokens(credentialAttestationHash, 0.000001); // 4175324; // hard code this for now for testing.... console.log('locked tokens .... deposit_block_number', depositBlockNumber); // Start the process of determining when we can mint. determineDepositMintReadyness(); // Block exit until the mint has occured await exitPromise.promise; console.log('Minted successfully!'); // Cleanup clearInterval(stageWaitPrinterHandler); webSocket.close(); // OK so this will do for now just need to do the following // Create an ecdsa credential + proof // Fetch the converted proof + storage data. proof conversion finished for current window. // Compute a merkle proof / witness of our inclusion of our deposit. // Post window when we are allowed to mint but before the window is exceeded.... do the mint }, 1000000000); }); //# sourceMappingURL=e2e.wss.prerequisites.spec.js.map