diff --git a/src/models/process.model.ts b/src/models/process.model.ts index f6faa6c..6bcdf3c 100755 --- a/src/models/process.model.ts +++ b/src/models/process.model.ts @@ -39,6 +39,8 @@ export enum MessageType { DATA_RETRIEVED = 'DATA_RETRIEVED', DECODE_PUBLIC_DATA = 'DECODE_PUBLIC_DATA', PUBLIC_DATA_DECODED = 'PUBLIC_DATA_DECODED', + GET_MEMBER_ADDRESSES = 'GET_MEMBER_ADDRESSES', + MEMBER_ADDRESSES_RETRIEVED = 'MEMBER_ADDRESSES_RETRIEVED', // Processes CREATE_PROCESS = 'CREATE_PROCESS', PROCESS_CREATED = 'PROCESS_CREATED', @@ -53,4 +55,9 @@ export enum MessageType { VALUE_HASHED = 'VALUE_HASHED', GET_MERKLE_PROOF = 'GET_MERKLE_PROOF', MERKLE_PROOF_RETRIEVED = 'MERKLE_PROOF_RETRIEVED', + VALIDATE_MERKLE_PROOF = 'VALIDATE_MERKLE_PROOF', + MERKLE_PROOF_VALIDATED = 'MERKLE_PROOF_VALIDATED', + // Account management + ADD_DEVICE = 'ADD_DEVICE', + DEVICE_ADDED = 'DEVICE_ADDED', } diff --git a/src/router.ts b/src/router.ts index 8f6d549..8720137 100755 --- a/src/router.ts +++ b/src/router.ts @@ -10,6 +10,7 @@ import { prepareAndSendPairingTx } from './utils/sp-address.utils'; import ModalService from './services/modal.service'; import { MessageType } from './models/process.model'; import { splitPrivateData, isValid32ByteHex } from './utils/service.utils'; +import { MerkleProofResult } from 'pkg/sdk_client'; const routes: { [key: string]: string } = { home: '/src/pages/home/home.html', @@ -139,17 +140,22 @@ export async function init(): Promise { (window as any).myService = services; const db = await Database.getInstance(); db.registerServiceWorker('/src/service-workers/database.worker.js'); - let device = await services.getDeviceFromDatabase(); + const device = await services.getDeviceFromDatabase(); console.log('🚀 ~ setTimeout ~ device:', device); if (!device) { - device = await services.createNewDevice(); + await services.createNewDevice(); } else { services.restoreDevice(device); } + + // If we create a new device, we most probably don't have anything in db, but just in case await services.restoreProcessesFromDB(); await services.restoreSecretsFromDB(); + // We connect to all relays now + await services.connectAllRelays(); + // We register all the event listeners if we run in an iframe if (window.self !== window.top) { await registerAllListeners(); @@ -591,9 +597,28 @@ export async function registerAllListeners() { if (!process) { throw new Error('Process not found'); } - const lastState = services.getLastCommitedState(process); + let lastState = services.getLastCommitedState(process); if (!lastState) { - throw new Error('Process doesn\'t have a commited state yet'); + const firstState = process.states[0]; + const roles = firstState.roles; + if (services.rolesContainsUs(roles)) { + const approveChangeRes= await services.approveChange(processId, firstState.state_id); + await services.handleApiReturn(approveChangeRes); + const prdUpdateRes = await services.createPrdUpdate(processId, firstState.state_id); + await services.handleApiReturn(prdUpdateRes); + } else { + if (firstState.validation_tokens.length > 0) { + // Try to send it again anyway + const res = await services.createPrdUpdate(processId, firstState.state_id); + await services.handleApiReturn(res); + } + } + // Wait a couple seconds + await new Promise(resolve => setTimeout(resolve, 2000)); + lastState = services.getLastCommitedState(process); + if (!lastState) { + throw new Error('Process doesn\'t have a commited state yet'); + } } const lastStateIndex = services.getLastCommitedStateIndex(process); if (lastStateIndex === null) { @@ -675,7 +700,7 @@ export async function registerAllListeners() { throw new Error('Invalid or expired session token'); } - const decodedData = await services.decodeValue(encodedData); + const decodedData = services.decodeValue(encodedData); window.parent.postMessage( { @@ -722,8 +747,6 @@ export async function registerAllListeners() { const handleGetMerkleProof = async (event: MessageEvent) => { if (event.data.type !== MessageType.GET_MERKLE_PROOF) return; - console.log('handleGetMerkleProof', event.data); - try { const { accessToken, processState, attributeName } = event.data; @@ -747,6 +770,41 @@ export async function registerAllListeners() { } } + const handleValidateMerkleProof = async (event: MessageEvent) => { + if (event.data.type !== MessageType.VALIDATE_MERKLE_PROOF) return; + + try { + const { accessToken, merkleProof, documentHash } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + // Try to parse the proof + // We will validate it's a MerkleProofResult in the wasm + let parsedMerkleProof: MerkleProofResult; + try { + parsedMerkleProof= JSON.parse(merkleProof); + } catch (e) { + throw new Error('Provided merkleProof is not a valid json object'); + } + + const res = services.validateMerkleProof(parsedMerkleProof, documentHash); + + window.parent.postMessage( + { + type: MessageType.MERKLE_PROOF_VALIDATED, + isValid: res, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to get merkle proof: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + window.removeEventListener('message', handleMessage); window.addEventListener('message', handleMessage); @@ -795,6 +853,9 @@ export async function registerAllListeners() { case MessageType.GET_MERKLE_PROOF: await handleGetMerkleProof(event); break; + case MessageType.VALIDATE_MERKLE_PROOF: + await handleValidateMerkleProof(event); + break; default: console.warn(`Unhandled message type: ${event.data.type}`); } diff --git a/src/service-workers/database.worker.js b/src/service-workers/database.worker.js index 54b432c..a9e0548 100755 --- a/src/service-workers/database.worker.js +++ b/src/service-workers/database.worker.js @@ -45,6 +45,21 @@ self.addEventListener('message', async (event) => { } catch (error) { event.ports[0].postMessage({ status: 'error', message: error.message }); } + } else if (data.type === 'BATCH_WRITING') { + const { storeName, objects } = data.payload; + const db = await openDatabase(); + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + + for (const { key, object } of objects) { + if (key) { + await store.put(object, key); + } else { + await store.put(object); + } + } + + await tx.done; } }); diff --git a/src/services/database.service.ts b/src/services/database.service.ts index 663de5e..c6cd73e 100755 --- a/src/services/database.service.ts +++ b/src/services/database.service.ts @@ -147,7 +147,7 @@ export class Database { const activeWorker = this.serviceWorkerRegistration?.active || (await this.waitForServiceWorkerActivation(this.serviceWorkerRegistration!)); const service = await Services.getInstance(); const payload = await service.getMyProcesses(); - if (payload!.length != 0) { + if (payload && payload.length != 0) { activeWorker?.postMessage({ type: 'SCAN', payload }); } }, 5000); @@ -323,6 +323,38 @@ export class Database { }); } + public batchWriting(payload: { storeName: string; objects: { key: any; object: any }[] }): Promise { + return new Promise(async (resolve, reject) => { + if (!this.serviceWorkerRegistration) { + this.serviceWorkerRegistration = await navigator.serviceWorker.ready; + } + + const activeWorker = await this.waitForServiceWorkerActivation(this.serviceWorkerRegistration); + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + if (event.data.status === 'success') { + resolve(); + } else { + const error = event.data.message; + reject(new Error(error || 'Unknown error occurred while adding objects')); + } + }; + + try { + activeWorker?.postMessage( + { + type: 'BATCH_WRITING', + payload, + }, + [messageChannel.port2], + ); + } catch (error) { + reject(new Error(`Failed to send message to service worker: ${error}`)); + } + }); + } + public async getObject(storeName: string, key: string): Promise { const db = await this.getDb(); const tx = db.transaction(storeName, 'readonly'); @@ -341,23 +373,25 @@ export class Database { const store = tx.objectStore(storeName); try { - // Wait for both getAllKeys() and getAll() to resolve - const [keys, values] = await Promise.all([ - new Promise((resolve, reject) => { - const request = store.getAllKeys(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }), - new Promise((resolve, reject) => { - const request = store.getAll(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }), - ]); + return new Promise((resolve, reject) => { + const result: Record = {}; + const cursor = store.openCursor(); - // Combine keys and values into an object - const result: Record = Object.fromEntries(keys.map((key, index) => [key, values[index]])); - return result; + cursor.onsuccess = (event) => { + const request = event.target as IDBRequest; + const cursor = request.result; + if (cursor) { + result[cursor.key as string] = cursor.value; + cursor.continue(); + } else { + resolve(result); + } + }; + + cursor.onerror = () => { + reject(cursor.error); + }; + }); } catch (error) { console.error('Error fetching data from IndexedDB:', error); throw error; diff --git a/src/services/modal.service.ts b/src/services/modal.service.ts index 3c2e6c6..284bd70 100755 --- a/src/services/modal.service.ts +++ b/src/services/modal.service.ts @@ -132,7 +132,7 @@ export default class ModalService { console.log("MEMBERS:", members); // We take all the addresses except our own const service = await Services.getInstance(); - const localAddress = await service.getDeviceAddress(); + const localAddress = service.getDeviceAddress(); for (const member of members) { if (member.sp_addresses) { for (const address of member.sp_addresses) { diff --git a/src/services/service.ts b/src/services/service.ts index 3d08856..9020242 100755 --- a/src/services/service.ts +++ b/src/services/service.ts @@ -1,7 +1,7 @@ import { INotification } from '~/models/notification.model'; import { IProcess } from '~/models/process.model'; import { initWebsocket, sendMessage } from '../websockets'; -import { ApiReturn, Device, HandshakeMessage, Member, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../../pkg/sdk_client'; +import { ApiReturn, Device, HandshakeMessage, Member, MerkleProofResult, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../../pkg/sdk_client'; import ModalService from './modal.service'; import Database from './database.service'; import { navigate } from '../router'; @@ -23,6 +23,7 @@ export default class Services { private processId: string | null = null; private stateId: string | null = null; private sdkClient: any; + private processesCache: Record = {}; private myProcesses: Set = new Set(); private notifications: any[] | null = null; private subscriptions: { element: Element; event: string; eventHandler: string }[] = []; @@ -61,7 +62,6 @@ export default class Services { for (const wsurl of Object.values(BOOTSTRAPURL)) { this.updateRelay(wsurl, ''); } - await this.connectAllRelays(); } public setProcessId(processId: string | null) { @@ -201,7 +201,7 @@ export default class Services { // Ensure the amount is available before proceeding await this.getTokensFromFaucet(); let unconnectedAddresses = []; - const myAddress = await this.getDeviceAddress(); + const myAddress = this.getDeviceAddress(); for (const member of members) { const sp_addresses = member.sp_addresses; if (!sp_addresses || sp_addresses.length === 0) continue; @@ -386,6 +386,19 @@ export default class Services { members.add(member) } } + if (members.size === 0) { + // This must be a pairing process + // Check if we have a pairedAddresses in the public data + const publicData = this.getPublicData(process); + if (!publicData || !publicData['pairedAddresses']) { + throw new Error('Not a pairing process'); + } + const decodedAddresses = this.decodeValue(publicData['pairedAddresses']); + if (decodedAddresses.length === 0) { + throw new Error('Not a pairing process'); + } + members.add({ sp_addresses: decodedAddresses }); + } await this.checkConnections([...members]); const privateSplitData = this.splitData(privateData); const publicSplitData = this.splitData(publicData); @@ -660,6 +673,41 @@ export default class Services { await navigate('account'); } + public async updateDevice(): Promise { + let myPairingProcessId: string; + try { + myPairingProcessId = this.getPairingProcessId(); + } catch (e) { + console.error('Failed to get pairing process id'); + return; + } + + const myPairingProcess = await this.getProcess(myPairingProcessId); + if (!myPairingProcess) { + console.error('Unknown pairing process'); + return; + } + const myPairingState = this.getLastCommitedState(myPairingProcess); + if (myPairingState) { + const encodedSpAddressList = myPairingState.public_data['pairedAddresses']; + const spAddressList = this.decodeValue(encodedSpAddressList); + if (spAddressList.length === 0) { + console.error('Empty pairedAddresses'); + return; + } + // We can check if our address is included and simply unpair if it's not + if (!spAddressList.includes(this.getDeviceAddress())) { + await this.unpairDevice(); + return; + } + // We can update the device with the new addresses + this.sdkClient.unpair_device(); + this.sdkClient.pair_device(myPairingProcessId, spAddressList); + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + } + } + public async pairDevice() { if (!this.processId) { console.error('No processId set'); @@ -673,7 +721,19 @@ export default class Services { let spAddressList: string[] = []; try { - const encodedSpAddressList = process.states[0].public_data['pairedAddresses']; + let encodedSpAddressList: number[] = []; + if (this.stateId) { + const state = process.states.find(state => state.state_id === this.stateId); + if (state) { + encodedSpAddressList = state.public_data['pairedAddresses']; + } + } else { + // We assume it's the last commited state + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState) { + encodedSpAddressList = lastCommitedState.public_data['pairedAddresses']; + } + } spAddressList = this.sdkClient.decode_value(encodedSpAddressList); if (!spAddressList || spAddressList.length == 0) { throw new Error('Empty pairedAddresses'); @@ -693,11 +753,15 @@ export default class Services { return amount; } - async getDeviceAddress() { - return await this.sdkClient.get_address(); + getDeviceAddress(): string { + try { + return this.sdkClient.get_address(); + } catch (e) { + throw new Error(`Failed to get device address: ${e}`); + } } - public dumpDeviceFromMemory(): string { + public dumpDeviceFromMemory(): Device { try { return this.sdkClient.dump_device(); } catch (e) { @@ -722,7 +786,7 @@ export default class Services { } } - async saveDeviceInDatabase(device: any): Promise { + async saveDeviceInDatabase(device: Device): Promise { const db = await Database.getInstance(); const walletStore = 'wallet'; try { @@ -740,14 +804,13 @@ export default class Services { } } - async getDeviceFromDatabase(): Promise { + async getDeviceFromDatabase(): Promise { const db = await Database.getInstance(); const walletStore = 'wallet'; try { const dbRes = await db.getObject(walletStore, '1'); if (dbRes) { - const wallet = dbRes['device']; - return wallet; + return dbRes['device']; } else { return null; } @@ -760,8 +823,7 @@ export default class Services { try { const device = await this.getDeviceFromDatabase(); if (device) { - const parsed: Device = JSON.parse(device); - const pairedMember = parsed['paired_member']; + const pairedMember = device['paired_member']; return pairedMember.sp_addresses; } else { return null; @@ -785,30 +847,22 @@ export default class Services { rolesContainsUs(roles: Record): boolean { let us; try { - us = this.sdkClient.get_member(); + us = this.sdkClient.get_pairing_process_id(); } catch (e) { throw e; } - return this.rolesContainsMember(roles, us.sp_addresses); + return this.rolesContainsMember(roles, us); } - rolesContainsMember(roles: Record, member: string[]): boolean { - let res = false; - for (const [roleName, roleDef] of Object.entries(roles)) { - for (const otherMember of roleDef.members) { - if (res) { return true } - // Get the addresses for the member - const otherMemberAddresses: string[] | null = this.getAddressesForMemberId(otherMember); - if (!otherMemberAddresses) { - // console.error('Failed to get addresses for member', otherMember); - continue; - } - res = this.compareMembers(member, otherMemberAddresses); + rolesContainsMember(roles: Record, pairingProcessId: string): boolean { + for (const roleDef of Object.values(roles)) { + if (roleDef.members.includes(pairingProcessId)) { + return true; } } - return res; + return false; } async dumpWallet() { @@ -834,10 +888,9 @@ export default class Services { return spAddress; } - restoreDevice(device: string) { + public restoreDevice(device: Device) { try { this.sdkClient.restore_device(device); - const spAddress = this.sdkClient.get_address(); } catch (e) { console.error(e); } @@ -854,14 +907,33 @@ export default class Services { } } + public async batchSaveProcessesToDb(processes: Record) { + if (Object.keys(processes).length === 0) { + return; + } + + const db = await Database.getInstance(); + const storeName = 'processes'; + try { + await db.batchWriting({ storeName, objects: Object.entries(processes).map(([key, value]) => ({ key, object: value })) }); + this.processesCache = { ...this.processesCache, ...processes }; + } catch (e) { + throw e; + } + } + public async saveProcessToDb(processId: string, process: Process) { const db = await Database.getInstance(); + const storeName = 'processes'; try { await db.addObject({ - storeName: 'processes', + storeName, object: process, key: processId, }); + + // Update the process in the cache + this.processesCache[processId] = process; } catch (e) { console.error(`Failed to save process ${processId}: ${e}`); } @@ -927,43 +999,36 @@ export default class Services { } public async getProcess(processId: string): Promise { - const db = await Database.getInstance(); - return await db.getObject('processes', processId); + if (this.processesCache[processId]) { + return this.processesCache[processId]; + } else { + const db = await Database.getInstance(); + const process = await db.getObject('processes', processId); + return process; + } } public async getProcesses(): Promise> { - const db = await Database.getInstance(); - - const processes: Record = await db.dumpStore('processes'); - return processes; + if (Object.keys(this.processesCache).length > 0) { + return this.processesCache; + } else { + try { + const db = await Database.getInstance(); + this.processesCache = await db.dumpStore('processes'); + return this.processesCache; + } catch (e) { + throw e; + } + } } - // TODO rewrite that it's a mess and we don't use it now - // public async getChildrenOfProcess(processId: string): Promise { - // const processes = await this.getProcesses(); - - // const res = []; - // for (const [hash, process] of Object.entries(processes)) { - // const firstState = process.states[0]; - // const pcdCommitment = firstState['pcd_commitment']; - // try { - // const parentIdHash = pcdCommitment['parent_id']; - // const diff = await this.getDiffByValue(parentIdHash); - // if (diff && diff['new_value'] === processId) { - // res.push(JSON.stringify(process)); - // } - // } catch (e) { - // continue; - // } - // } - - // return res; - // } - public async restoreProcessesFromBackUp(processes: Record) { const db = await Database.getInstance(); - for (const [commitedIn, process] of Object.entries(processes)) { - await db.addObject({ storeName: 'processes', object: process, key: commitedIn}); + const storeName = 'processes'; + try { + await db.batchWriting({ storeName, objects: Object.entries(processes).map(([key, value]) => ({ key, object: value })) }); + } catch (e) { + throw e; } await this.restoreProcessesFromDB(); @@ -976,6 +1041,7 @@ export default class Services { const processes: Record = await db.dumpStore('processes'); if (processes && Object.keys(processes).length != 0) { console.log(`Restoring ${Object.keys(processes).length} processes`); + this.processesCache = processes; this.sdkClient.set_process_cache(processes); } else { console.log('No processes to restore!'); @@ -1033,7 +1099,7 @@ export default class Services { } } - async decodeValue(value: number[]): Promise { + decodeValue(value: number[]): any | null { try { return this.sdkClient.decode_value(value); } catch (e) { @@ -1215,7 +1281,21 @@ export default class Services { setTimeout(async () => { const newProcesses: OutPointProcessMap = handshakeMsg.processes_list; - if (newProcesses && Object.keys(newProcesses).length !== 0) { + if (!newProcesses || Object.keys(newProcesses).length === 0) { + console.debug('Received empty processes list from', url); + return; + } + + if (this.processesCache && Object.keys(this.processesCache).length === 0) { + // We restored db but cache is empty, meaning we're starting from scratch + try { + await this.batchSaveProcessesToDb(newProcesses); + } catch (e) { + console.error('Failed to save processes to db:', e); + } + } else { + // We need to update our processes with what relay provides + const toSave: Record = {}; for (const [processId, process] of Object.entries(newProcesses)) { const existing = await this.getProcess(processId); if (existing) { @@ -1235,6 +1315,25 @@ export default class Services { if (new_states.length != 0) { // We request the new states await this.requestDataFromPeers(processId, new_states, roles); + toSave[processId] = process; + } + + // Just to be sure check if that's a pairing process + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.public_data && lastCommitedState.public_data['pairedAddresses']) { + // This is a pairing process + try { + const pairedAddresses = this.decodeValue(lastCommitedState.public_data['pairedAddresses']); + // Are we part of it? + if (pairedAddresses && pairedAddresses.length > 0 && pairedAddresses.includes(this.getDeviceAddress())) { + // We save the process to db + await this.saveProcessToDb(processId, process as Process); + // We update the device + await this.updateDevice(); + } + } catch (e) { + console.error('Failed to check for pairing process:', e); + } } // Otherwise we're probably just in the initial loading at page initialization @@ -1246,9 +1345,11 @@ export default class Services { } else { // We add it to db console.log(`Saving ${processId} to db`); - await this.saveProcessToDb(processId, process as Process); + toSave[processId] = process; } } + + await this.batchSaveProcessesToDb(toSave); } }, 500) } catch (e) { @@ -1301,9 +1402,12 @@ export default class Services { const content = JSON.parse(response); const error = content.error; const errorMsg = error['GenericError']; - if (errorMsg === 'State is identical to the previous state') { - return; - } else if (errorMsg === 'Not enough valid proofs') { return; } + const dontRetry = [ + 'State is identical to the previous state', + 'Not enough valid proofs', + 'Not enough members to validate', + ]; + if (dontRetry.includes(errorMsg)) { return; } // Wait and retry setTimeout(async () => { await this.sendCommitMessage(JSON.stringify(content)); @@ -1340,7 +1444,7 @@ export default class Services { const lastCommitedState = this.getLastCommitedState(process); if (lastCommitedState && lastCommitedState.public_data) { const processName = lastCommitedState!.public_data['processName']; - if (processName) { return processName } + if (processName) { return this.decodeValue(processName) } else { return null } } else { return null; @@ -1348,24 +1452,32 @@ export default class Services { } public async getMyProcesses(): Promise { + // If we're not paired yet, just skip it + try { + this.getPairingProcessId(); + } catch (e) { + return null; + } try { const processes = await this.getProcesses(); + const newMyProcesses = new Set(this.myProcesses || []); for (const [processId, process] of Object.entries(processes)) { // We use myProcesses attribute to not reevaluate all processes everytime - if (this.myProcesses && this.myProcesses.has(processId)) { + if (newMyProcesses.has(processId)) { continue; } try { const roles = this.getRoles(process); if (roles && this.rolesContainsUs(roles)) { - this.myProcesses.add(processId); + newMyProcesses.add(processId); } } catch (e) { console.error(e); } } + this.myProcesses = newMyProcesses; // atomic update return Array.from(this.myProcesses); } catch (e) { console.error("Failed to get processes:", e); @@ -1418,6 +1530,14 @@ export default class Services { return this.sdkClient.get_merkle_proof(processState, attributeName); } + public validateMerkleProof(proof: MerkleProofResult, hash: string): boolean { + try { + return this.sdkClient.validate_merkle_proof(proof, hash); + } catch (e) { + throw new Error(`Failed to validate merkle proof: ${e}`); + } + } + public getLastCommitedState(process: Process): ProcessState | null { if (process.states.length === 0) return null; const processTip = process.states[process.states.length - 1].commited_in; @@ -1440,6 +1560,13 @@ export default class Services { return null; } + public getUncommitedStates(process: Process): ProcessState[] { + if (process.states.length === 0) return []; + const processTip = process.states[process.states.length - 1].commited_in; + const res = process.states.filter(state => state.commited_in === processTip); + return res.filter(state => state.state_id !== EMPTY32BYTES); + } + public getStateFromId(process: Process, stateId: string): ProcessState | null { if (process.states.length === 0) return null; const state = process.states.find(state => state.state_id === stateId);