diff --git a/src/services/service.ts b/src/services/service.ts index 55334c2..f0e925c 100755 --- a/src/services/service.ts +++ b/src/services/service.ts @@ -1,1748 +1,1752 @@ -import { INotification } from '~/models/notification.model'; -import { IProcess } from '~/models/process.model'; -import { initWebsocket, sendMessage } from '../websockets'; -import type { ApiReturn, Device, HandshakeMessage, Member, MerkleProofResult, NewTxMessage, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../../pkg/sdk_client'; -import ModalService from './modal.service'; -import Database from './database.service'; -import { navigate } from '../router'; -import { storeData, retrieveData, testData } from './storage.service'; -import { BackUp } from '~/models/backup.model'; - -export const U32_MAX = 4294967295; - -const BASEURL = `http://localhost`; -const BOOTSTRAPURL = [`${BASEURL}:8090`]; -const STORAGEURL = `${BASEURL}:8081`; -const BLINDBITURL = `${BASEURL}:8000`; -const DEFAULTAMOUNT = 1000n; -const EMPTY32BYTES = String('').padStart(64, '0'); - -export default class Services { - private static initializing: Promise | null = null; - private static instance: 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 }[] = []; - private database: any; - private routingInstance!: ModalService; - private relayAddresses: { [wsurl: string]: string } = {}; - private membersList: Record = {}; - private currentBlockHeight: number = -1; - // Private constructor to prevent direct instantiation from outside - private constructor() {} - - // Method to access the singleton instance of Services - public static async getInstance(): Promise { - if (Services.instance) { - return Services.instance; - } - - if (!Services.initializing) { - Services.initializing = (async () => { - const instance = new Services(); - await instance.init(); - instance.routingInstance = await ModalService.getInstance(); - return instance; - })(); - } - - console.log('initializing services'); - Services.instance = await Services.initializing; - Services.initializing = null; // Reset for potential future use - return Services.instance; - } - - public async init(): Promise { - this.notifications = this.getNotifications(); - this.sdkClient = await import('../../pkg/sdk_client'); - this.sdkClient.setup(); - - const params = new URLSearchParams(window.location.search); - const isE2E = params.has('e2e'); - if (isE2E) { - // Mode E2E: ne pas tenter de connexion aux relays - return; - } - - for (const wsurl of Object.values(BOOTSTRAPURL)) { - this.updateRelay(wsurl, ''); - } - } - - public setProcessId(processId: string | null) { - this.processId = processId; - } - - public setStateId(stateId: string | null) { - this.stateId = stateId; - } - - public getProcessId(): string | null { - return this.processId; - } - - public getStateId(): string | null { - return this.stateId; - } - - /** - * Calls `this.addWebsocketConnection` for each `wsurl` in relayAddresses. - * Waits for at least one handshake message before returning. - */ - public async connectAllRelays(): Promise { - const connectedUrls: string[] = []; - - // Connect to all relays - for (const wsurl of Object.keys(this.relayAddresses)) { - try { - console.log(`Connecting to: ${wsurl}`); - await this.addWebsocketConnection(wsurl); - connectedUrls.push(wsurl); - console.log(`Successfully connected to: ${wsurl}`); - } catch (error) { - console.error(`Failed to connect to ${wsurl}:`, error); - } - } - - // Wait for at least one handshake message if we have connections - if (connectedUrls.length > 0) { - await this.waitForHandshakeMessage(); - } - } - - public async addWebsocketConnection(url: string): Promise { - console.log('Opening new websocket connection'); - await initWebsocket(url); - } - - /** - * Add or update a key/value pair in relayAddresses. - * @param wsurl - The WebSocket URL (key). - * @param spAddress - The SP Address (value). - */ - public updateRelay(wsurl: string, spAddress: string): void { - this.relayAddresses[wsurl] = spAddress; - console.log(`Updated: ${wsurl} -> ${spAddress}`); - } - - /** - * Retrieve the spAddress for a given wsurl. - * @param wsurl - The WebSocket URL to look up. - * @returns The SP Address if found, or undefined if not. - */ - public getSpAddress(wsurl: string): string | undefined { - return this.relayAddresses[wsurl]; - } - - /** - * Get all key/value pairs from relayAddresses. - * @returns An array of objects containing wsurl and spAddress. - */ - public getAllRelays(): { wsurl: string; spAddress: string }[] { - return Object.entries(this.relayAddresses).map(([wsurl, spAddress]) => ({ - wsurl, - spAddress, - })); - } - - /** - * Print all key/value pairs for debugging. - */ - public printAllRelays(): void { - console.log('Current relay addresses:'); - for (const [wsurl, spAddress] of Object.entries(this.relayAddresses)) { - console.log(`${wsurl} -> ${spAddress}`); - } - } - - public isPaired(): boolean { - try { - return this.sdkClient.is_paired(); - } catch (e) { - throw new Error(`isPaired ~ Error: ${e}`); - } - } - - public async unpairDevice(): Promise { - try { - this.sdkClient.unpair_device(); - const newDevice = this.dumpDeviceFromMemory(); - await this.saveDeviceInDatabase(newDevice); - } catch (e) { - throw new Error(`Failed to unpair device: ${e}`); - } - } - - public async getSecretForAddress(address: string): Promise { - const db = await Database.getInstance(); - return await db.getObject('shared_secrets', address); - } - - public async getAllSecrets(): Promise { - const db = await Database.getInstance(); - const sharedSecrets = await db.dumpStore('shared_secrets'); - const unconfirmedSecrets = await db.dumpStore('unconfirmed_secrets'); // keys are numeric values - - const secretsStore = { - shared_secrets: sharedSecrets, - unconfirmed_secrets: Object.values(unconfirmedSecrets), - }; - - return secretsStore; - } - - public async getAllDiffs(): Promise> { - const db = await Database.getInstance(); - return await db.dumpStore('diffs'); - } - - public async getDiffByValue(value: string): Promise { - const db = await Database.getInstance(); - const store = 'diffs'; - const res = await db.getObject(store, value); - return res; - } - - private async getTokensFromFaucet(): Promise { - try { - await this.ensureSufficientAmount(); - } catch (e) { - console.error('Failed to get tokens from relay, check connection'); - return; - } - } - - public async checkConnections(members: Member[]): Promise { - // Ensure the amount is available before proceeding - await this.getTokensFromFaucet(); - let unconnectedAddresses: Set = new Set(); - const myAddress = this.getDeviceAddress(); - for (const member of members) { - const sp_addresses = member.sp_addresses; - if (!sp_addresses || sp_addresses.length === 0) continue; - for (const address of sp_addresses) { - // For now, we ignore our own device address, although there might be use cases for having a secret with ourselves - if (address === myAddress) continue; - const sharedSecret = await this.getSecretForAddress(address); - if (!sharedSecret) { - unconnectedAddresses.add(address); - } - } - } - if (unconnectedAddresses && unconnectedAddresses.size != 0) { - const apiResult = await this.connectAddresses([...unconnectedAddresses]); - await this.handleApiReturn(apiResult); - } - } - - public async connectAddresses(addresses: string[]): Promise { - if (addresses.length === 0) { - throw new Error('Trying to connect to empty addresses list'); - } - - try { - return this.sdkClient.create_transaction(addresses, 1); - } catch (e) { - console.error('Failed to connect member:', e); - throw e; - } - } - - private async ensureSufficientAmount(): Promise { - const availableAmt = this.getAmount(); - const target: BigInt = DEFAULTAMOUNT * BigInt(10); - - if (availableAmt < target) { - const faucetMsg = this.createFaucetMessage(); - this.sendFaucetMessage(faucetMsg); - - await this.waitForAmount(target); - } - } - - private async waitForAmount(target: BigInt): Promise { - let attempts = 3; - - while (attempts > 0) { - const amount = this.getAmount(); - if (amount >= target) { - return amount; - } - - attempts--; - if (attempts > 0) { - await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second - } - } - - throw new Error('Amount is still 0 after 3 attempts'); - } - - public async createPairingProcess(userName: string, pairWith: string[]): Promise { - if (this.sdkClient.is_paired()) { - throw new Error('Device already paired'); - } - const myAddress: string = this.sdkClient.get_address(); - pairWith.push(myAddress); - const privateData = { - description: 'pairing', - counter: 0, - }; - const publicData = { - memberPublicName: userName, - pairedAddresses: pairWith, - }; - const validation_fields: string[] = [...Object.keys(privateData), ...Object.keys(publicData), 'roles']; - const roles: Record = { - pairing: { - members: [], - validation_rules: [ - { - quorum: 1.0, - fields: validation_fields, - min_sig_member: 1.0, - }, - ], - storages: [STORAGEURL], - }, - }; - try { - return this.createProcess(privateData, publicData, roles); - } catch (e) { - throw new Error(`Creating process failed:, ${e}`); - } - } - - private isFileBlob(value: any): value is { type: string; data: Uint8Array } { - return typeof value === 'object' && value !== null && typeof value.type === 'string' && value.data instanceof Uint8Array; - } - - private splitData(obj: Record) { - const jsonCompatibleData: Record = {}; - const binaryData: Record = {}; - - for (const [key, value] of Object.entries(obj)) { - if (this.isFileBlob(value)) { - binaryData[key] = value; - } else { - jsonCompatibleData[key] = value; - } - } - - return { jsonCompatibleData, binaryData }; - } - - public async createProcess(privateData: Record, publicData: Record, roles: Record): Promise { - let relayAddress = this.getAllRelays()[0]?.spAddress; - - if (!relayAddress || relayAddress === '') { - console.log('No relay address found, connecting to relays...'); - await this.connectAllRelays(); - - // After connectAllRelays completes, relay addresses should be updated - relayAddress = this.getAllRelays()[0]?.spAddress; - if (!relayAddress || relayAddress === '') { - throw new Error('No relay address available after connecting to relays'); - } - } - - const feeRate = 1; - - // We can't encode files as the rest because Uint8Array is not valid json - // So we first take them apart and we will encode them separately and put them back in the right object - // TODO encoding of relatively large binaries (=> 1M) is a bit long now and blocking - const privateSplitData = this.splitData(privateData); - const publicSplitData = this.splitData(publicData); - const encodedPrivateData = { - ...this.sdkClient.encode_json(privateSplitData.jsonCompatibleData), - ...this.sdkClient.encode_binary(privateSplitData.binaryData), - }; - const encodedPublicData = { - ...this.sdkClient.encode_json(publicSplitData.jsonCompatibleData), - ...this.sdkClient.encode_binary(publicSplitData.binaryData), - }; - - console.log('encoded data:', encodedPrivateData); - console.log('encoded data:', encodedPublicData); - - let members: Set = new Set(); - for (const role of Object.values(roles!)) { - for (const member of role.members) { - // Check if we know the member that matches this id - const memberAddresses = this.getAddressesForMemberId(member); - if (memberAddresses && memberAddresses.length != 0) { - members.add({ sp_addresses: memberAddresses }); - } - } - } - console.log('members:', members); - await this.checkConnections([...members]); - - const result = this.sdkClient.create_new_process(encodedPrivateData, roles, encodedPublicData, relayAddress, feeRate, this.getAllMembers()); - - return result; - } - - public async updateProcess(process: Process, privateData: Record, publicData: Record, roles: Record | null): Promise { - // If roles is null, we just take the last commited state roles - if (!roles) { - roles = this.getRoles(process); - } else { - // We should check that we have the right to change the roles here, or maybe it's better leave it to the wasm - console.log('Provided new roles:', JSON.stringify(roles)); - } - let members: Set = new Set(); - for (const role of Object.values(roles!)) { - for (const member of role.members) { - 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); - const encodedPrivateData = { - ...this.sdkClient.encode_json(privateSplitData.jsonCompatibleData), - ...this.sdkClient.encode_binary(privateSplitData.binaryData), - }; - const encodedPublicData = { - ...this.sdkClient.encode_json(publicSplitData.jsonCompatibleData), - ...this.sdkClient.encode_binary(publicSplitData.binaryData), - }; - try { - return this.sdkClient.update_process(process, encodedPrivateData, roles, encodedPublicData, this.getAllMembers()); - } catch (e) { - throw new Error(`Failed to update process: ${e}`); - } - } - - public async createPrdUpdate(processId: string, stateId: string): Promise { - const process = await this.getProcess(processId); - if (!process) { - throw new Error('Unknown process'); - } - try { - return this.sdkClient.create_update_message(process, stateId, this.getAllMembers()); - } catch (e) { - throw new Error(`Failed to create prd update: ${e}`); - } - } - - public async createPrdResponse(processId: string, stateId: string): Promise { - const process = await this.getProcess(processId); - if (!process) { - throw new Error('Unknown process'); - } - try { - return this.sdkClient.create_response_prd(process, stateId, this.getAllMembers()); - } catch (e) { - throw new Error(`Failed to create response prd: ${e}`); - } - } - - public async approveChange(processId: string, stateId: string): Promise { - const process = await this.getProcess(processId); - if (!process) { - throw new Error('Failed to get process from db'); - } - try { - return this.sdkClient.validate_state(process, stateId, this.getAllMembers()); - } catch (e) { - throw new Error(`Failed to create prd response: ${e}`); - } - } - - public async rejectChange(processId: string, stateId: string): Promise { - const process = await this.getProcess(processId); - if (!process) { - throw new Error('Failed to get process from db'); - } - try { - return this.sdkClient.refuse_state(process, stateId); - } catch (e) { - throw new Error(`Failed to create prd response: ${e}`); - } - } - - async resetDevice() { - this.sdkClient.reset_device(); - - // Clear all stores - const db = await Database.getInstance(); - await db.clearStore('wallet'); - await db.clearStore('shared_secrets'); - await db.clearStore('unconfirmed_secrets'); - await db.clearStore('processes'); - await db.clearStore('diffs'); - } - - sendNewTxMessage(message: string) { - sendMessage('NewTx', message); - } - - sendCommitMessage(message: string) { - sendMessage('Commit', message); - } - - sendCipherMessages(ciphers: string[]) { - for (let i = 0; i < ciphers.length; i++) { - const cipher = ciphers[i]; - sendMessage('Cipher', cipher); - } - } - - sendFaucetMessage(message: string): void { - sendMessage('Faucet', message); - } - - async parseCipher(message: string) { - const membersList = this.getAllMembers(); - const processes = await this.getProcesses(); - try { - // console.log('parsing new cipher'); - const apiReturn = this.sdkClient.parse_cipher(message, membersList, processes); - await this.handleApiReturn(apiReturn); - - // Device 1 wait Device 2 - const waitingModal = document.getElementById('waiting-modal'); - if (waitingModal) { - this.device2Ready = true; - } - } catch (e) { - console.error(`Parsed cipher with error: ${e}`); - } - // await this.saveCipherTxToDb(parsedTx) - } - - async parseNewTx(newTxMsg: string) { - const parsedMsg: NewTxMessage = JSON.parse(newTxMsg); - if (parsedMsg.error !== null) { - console.error('Received error in new tx message:', parsedMsg.error); - return; - } - - const membersList = this.getAllMembers(); - try { - // Does the transaction spend the tip of a process? - const prevouts = this.sdkClient.get_prevouts(parsedMsg.transaction); - console.log('prevouts:', prevouts); - for (const process of Object.values(this.processesCache)) { - const tip = process.states[process.states.length - 1].commited_in; - if (prevouts.includes(tip)) { - const processId = process.states[0].commited_in; - const newTip = this.sdkClient.get_txid(parsedMsg.transaction); - console.log('Transaction', newTip, 'spends the tip of process', processId); - // We take the data out of the output - const newStateId = this.sdkClient.get_opreturn(parsedMsg.transaction); - console.log('newStateId:', newStateId); - // We update the relevant process - const updatedProcess = this.sdkClient.process_commit_new_state(process, newStateId, newTip); - this.processesCache[processId] = updatedProcess; - console.log('updatedProcess:', updatedProcess); - break; - } - } - } catch (e) { - console.error('Failed to parse new tx for commitments:', e); - } - - try { - const parsedTx = this.sdkClient.parse_new_tx(newTxMsg, 0, membersList); - if (parsedTx) { - try { - await this.handleApiReturn(parsedTx); - const newDevice = this.dumpDeviceFromMemory(); - await this.saveDeviceInDatabase(newDevice); - } catch (e) { - console.error('Failed to update device with new tx'); - } - } - } catch (e) { - console.debug(e); - } - } - - public async handleApiReturn(apiReturn: ApiReturn) { - console.log(apiReturn); - if (apiReturn.partial_tx) { - try { - const res = this.sdkClient.sign_transaction(apiReturn.partial_tx); - apiReturn.new_tx_to_send = res.new_tx_to_send; - } catch (e) { - console.error('Failed to sign transaction:', e); - } - } - - if (apiReturn.new_tx_to_send && apiReturn.new_tx_to_send.transaction.length != 0) { - this.sendNewTxMessage(JSON.stringify(apiReturn.new_tx_to_send)); - await new Promise((r) => setTimeout(r, 500)); - } - - if (apiReturn.secrets) { - const unconfirmedSecrets = apiReturn.secrets.unconfirmed_secrets; - const confirmedSecrets = apiReturn.secrets.shared_secrets; - - const db = await Database.getInstance(); - for (const secret of unconfirmedSecrets) { - await db.addObject({ - storeName: 'unconfirmed_secrets', - object: secret, - key: null, - }); - } - const entries = Object.entries(confirmedSecrets).map(([key, value]) => ({ key, value })); - for (const entry of entries) { - try { - await db.addObject({ - storeName: 'shared_secrets', - object: entry.value, - key: entry.key, - }); - } catch (e) { - throw e; - } - - // We don't want to throw an error, it could simply be that we registered directly the shared secret - // this.removeUnconfirmedSecret(entry.value); - } - } - - if (apiReturn.updated_process) { - const updatedProcess = apiReturn.updated_process; - - const processId: string = updatedProcess.process_id; - - if (updatedProcess.encrypted_data && Object.keys(updatedProcess.encrypted_data).length != 0) { - for (const [hash, cipher] of Object.entries(updatedProcess.encrypted_data)) { - const blob = this.hexToBlob(cipher); - try { - await this.saveBlobToDb(hash, blob); - } catch (e) { - console.error(e); - } - } - } - - // Save process to db - await this.saveProcessToDb(processId, updatedProcess.current_process); - - if (updatedProcess.diffs && updatedProcess.diffs.length != 0) { - try { - await this.saveDiffsToDb(updatedProcess.diffs); - } catch (e) { - console.error('Failed to save diffs to db:', e); - } - } - } - - if (apiReturn.push_to_storage && apiReturn.push_to_storage.length != 0) { - for (const hash of apiReturn.push_to_storage) { - const blob = await this.getBlobFromDb(hash); - if (blob) { - await this.saveDataToStorage(hash, blob, null); - } else { - console.error('Failed to get data from db'); - } - } - } - - if (apiReturn.commit_to_send) { - const commit = apiReturn.commit_to_send; - this.sendCommitMessage(JSON.stringify(commit)); - } - - if (apiReturn.ciphers_to_send && apiReturn.ciphers_to_send.length != 0) { - this.sendCipherMessages(apiReturn.ciphers_to_send); - } - } - - public async openPairingConfirmationModal(processId: string) { - const process = await this.getProcess(processId); - if (!process) { - console.error('Failed to find pairing process'); - return; - } - const firstState = process.states[0]; - const roles = firstState.roles; - const stateId = firstState.state_id; - try { - await this.routingInstance.openPairingConfirmationModal(roles, processId, stateId); - } catch (e) { - console.error(e); - } - } - - public async confirmPairing() { - if (!this.processId || !this.stateId) { - console.error('Missing process and/or state ID'); - return; - } - let createPrdUpdateReturn; - try { - createPrdUpdateReturn = await this.createPrdUpdate(this.processId, this.stateId); - } catch (e) { - throw new Error(`createPrdUpdate failed: ${e}`); - } - await this.handleApiReturn(createPrdUpdateReturn); - - let approveChangeReturn; - try { - approveChangeReturn = await this.approveChange(this.processId, this.stateId); - } catch (e) { - throw new Error(`approveChange failed: ${e}`); - } - await this.handleApiReturn(approveChangeReturn); - - await this.pairDevice(); - - this.processId = null; - this.stateId = null; - const newDevice = this.dumpDeviceFromMemory(); - await this.saveDeviceInDatabase(newDevice); - 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'); - return; - } - const process = await this.getProcess(this.processId); - if (!process) { - console.error('Unknown process'); - return; - } - - let spAddressList: string[] = []; - try { - 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'); - } - } catch (e) { - throw new Error(`Failed to get pairedAddresses from process: ${e}`); - } - try { - this.sdkClient.pair_device(this.processId, spAddressList); - } catch (e) { - throw new Error(`Failed to pair device: ${e}`); - } - } - - public getAmount(): BigInt { - const amount = this.sdkClient.get_available_amount(); - return amount; - } - - getDeviceAddress(): string { - try { - return this.sdkClient.get_address(); - } catch (e) { - throw new Error(`Failed to get device address: ${e}`); - } - } - - public dumpDeviceFromMemory(): Device { - try { - return this.sdkClient.dump_device(); - } catch (e) { - throw new Error(`Failed to dump device: ${e}`); - } - } - - public dumpNeuteredDevice(): Device | null { - try { - return this.sdkClient.dump_neutered_device(); - } catch (e) { - console.error(`Failed to dump device: ${e}`); - return null; - } - } - - public getPairingProcessId(): string { - try { - return this.sdkClient.get_pairing_process_id(); - } catch (e) { - throw new Error(`Failed to get pairing process: ${e}`); - } - } - - async saveDeviceInDatabase(device: Device): Promise { - const db = await Database.getInstance(); - const walletStore = 'wallet'; - try { - const prevDevice = await this.getDeviceFromDatabase(); - if (prevDevice) { - await db.deleteObject(walletStore, '1'); - } - await db.addObject({ - storeName: walletStore, - object: { pre_id: '1', device }, - key: null, - }); - } catch (e) { - console.error(e); - } - } - - async getDeviceFromDatabase(): Promise { - const db = await Database.getInstance(); - const walletStore = 'wallet'; - try { - const dbRes = await db.getObject(walletStore, '1'); - if (dbRes) { - return dbRes['device']; - } else { - return null; - } - } catch (e) { - throw new Error(`Failed to retrieve device from db: ${e}`); - } - } - - async getMemberFromDevice(): Promise { - try { - const device = await this.getDeviceFromDatabase(); - if (device) { - const pairedMember = device['paired_member']; - return pairedMember.sp_addresses; - } else { - return null; - } - } catch (e) { - throw new Error(`Failed to retrieve paired_member from device: ${e}`); - } - } - - isChildRole(parent: any, child: any): boolean { - try { - this.sdkClient.is_child_role(JSON.stringify(parent), JSON.stringify(child)); - } catch (e) { - console.error(e); - return false; - } - - return true; - } - - rolesContainsUs(roles: Record): boolean { - let us; - try { - us = this.sdkClient.get_pairing_process_id(); - } catch (e) { - throw e; - } - - return this.rolesContainsMember(roles, us); - } - - rolesContainsMember(roles: Record, pairingProcessId: string): boolean { - for (const roleDef of Object.values(roles)) { - if (roleDef.members.includes(pairingProcessId)) { - return true; - } - } - - return false; - } - - async dumpWallet() { - const wallet = await this.sdkClient.dump_wallet(); - return wallet; - } - - public createFaucetMessage() { - const message = this.sdkClient.create_faucet_msg(); - return message; - } - - async createNewDevice() { - let spAddress = ''; - try { - // We set birthday later when we have the chain tip from relay - spAddress = await this.sdkClient.create_new_device(0, 'signet'); - const device = this.dumpDeviceFromMemory(); - await this.saveDeviceInDatabase(device); - } catch (e) { - console.error('Services ~ Error:', e); - } - - return spAddress; - } - - public restoreDevice(device: Device) { - try { - this.sdkClient.restore_device(device); - } catch (e) { - console.error(e); - } - } - - public async updateDeviceBlockHeight(): Promise { - if (this.currentBlockHeight === -1) { - throw new Error('Current block height not set'); - } - - let device: Device | null = null; - try { - device = await this.getDeviceFromDatabase(); - } catch (e) { - throw new Error(`Failed to get device from database: ${e}`); - } - - if (!device) { - throw new Error('Device not found'); - } - - const birthday = device.sp_wallet.birthday; - if (birthday === undefined || birthday === null) { - throw new Error('Birthday not found'); - } - - if (birthday === 0) { - // This is a new device, so current chain tip is its birthday - device.sp_wallet.birthday = this.currentBlockHeight; - // We also set last_scan, impossible that we need to scan earlier than this - device.sp_wallet.last_scan = this.currentBlockHeight; - try { - // First set the updated device in memory - this.sdkClient.restore_device(device); - // Then save it to database - await this.saveDeviceInDatabase(device); - } catch (e) { - throw new Error(`Failed to save updated device: ${e}`); - } - } else { - // This is existing device, we need to catch up if last_scan is lagging behind chain_tip - if (device.sp_wallet.last_scan < this.currentBlockHeight) { - // We need to catch up - await this.sdkClient.scan_blocks(this.currentBlockHeight, BLINDBITURL); - } else { - // Up to date, just returns - return; - } - } - } - - private async removeProcess(processId: string): Promise { - const db = await Database.getInstance(); - const storeName = 'processes'; - - try { - await db.deleteObject(storeName, processId); - } catch (e) { - console.error(e); - } - } - - 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, - object: process, - key: processId, - }); - - // Update the process in the cache - this.processesCache[processId] = process; - } catch (e) { - console.error(`Failed to save process ${processId}: ${e}`); - } - } - - public async saveBlobToDb(hash: string, data: Blob) { - const db = await Database.getInstance(); - try { - await db.addObject({ - storeName: 'data', - object: data, - key: hash, - }); - } catch (e) { - console.error(`Failed to save data to db: ${e}`); - } - } - - public async getBlobFromDb(hash: string): Promise { - const db = await Database.getInstance(); - try { - return await db.getObject('data', hash); - } catch (e) { - return null; - } - } - - public async saveDataToStorage(hash: string, data: Blob, ttl: number | null) { - const storages = [STORAGEURL]; - - try { - await storeData(storages, hash, data, ttl); - } catch (e) { - console.error(`Failed to store data with hash ${hash}: ${e}`); - } - } - - public async fetchValueFromStorage(hash: string): Promise { - const storages = [STORAGEURL]; - - return await retrieveData(storages, hash); - } - - public async testDataInStorage(hash: string): Promise | null> { - const storages = [STORAGEURL]; - - return await testData(storages, hash); - } - - public async saveDiffsToDb(diffs: UserDiff[]) { - const db = await Database.getInstance(); - try { - for (const diff of diffs) { - await db.addObject({ - storeName: 'diffs', - object: diff, - key: null, - }); - } - } catch (e) { - throw new Error(`Failed to save process: ${e}`); - } - } - - public async getProcess(processId: string): Promise { - 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> { - 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; - } - } - } - - public async restoreProcessesFromBackUp(processes: Record) { - const db = await Database.getInstance(); - 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(); - } - - // Restore processes cache from persistent storage - public async restoreProcessesFromDB() { - const db = await Database.getInstance(); - try { - 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; - } else { - console.log('No processes to restore!'); - } - } catch (e) { - throw e; - } - } - - public async clearSecretsFromDB() { - const db = await Database.getInstance(); - try { - await db.clearStore('shared_secrets'); - await db.clearStore('unconfirmed_secrets'); - } catch (e) { - console.error(e); - } - } - - public async restoreSecretsFromBackUp(secretsStore: SecretsStore) { - const db = await Database.getInstance(); - - for (const secret of secretsStore.unconfirmed_secrets) { - await db.addObject({ - storeName: 'unconfirmed_secrets', - object: secret, - key: null, - }); - } - const entries = Object.entries(secretsStore.shared_secrets).map(([key, value]) => ({ key, value })); - for (const entry of entries) { - await db.addObject({ - storeName: 'shared_secrets', - object: entry.value, - key: entry.key, - }); - } - - // Now we can transfer them to memory - await this.restoreSecretsFromDB(); - } - - public async restoreSecretsFromDB() { - const db = await Database.getInstance(); - try { - const sharedSecrets: Record = await db.dumpStore('shared_secrets'); - const unconfirmedSecrets = await db.dumpStore('unconfirmed_secrets'); - const secretsStore = { - shared_secrets: sharedSecrets, - unconfirmed_secrets: Object.values(unconfirmedSecrets), - }; - this.sdkClient.set_shared_secrets(JSON.stringify(secretsStore)); - } catch (e) { - throw e; - } - } - - decodeValue(value: number[]): any | null { - try { - return this.sdkClient.decode_value(value); - } catch (e) { - console.error(`Failed to decode value: ${e}`); - return null; - } - } - - async decryptAttribute(processId: string, state: ProcessState, attribute: string): Promise { - let hash = state.pcd_commitment[attribute]; - if (!hash) { - // attribute doesn't exist - return null; - } - let key = state.keys[attribute]; - const pairingProcessId = this.getPairingProcessId(); - - // If key is missing, request an update and then retry - if (!key) { - const roles = state.roles; - let hasAccess = false; - // If we're not supposed to have access to this attribute, ignore - for (const role of Object.values(roles)) { - for (const rule of Object.values(role.validation_rules) as any[]) { - if (rule.fields.includes(attribute)) { - if (role.members.includes(pairingProcessId)) { - // We have access to this attribute - hasAccess = true; - break; - } - } - } - } - - if (!hasAccess) return null; - - // We should have the key, so we're going to ask other members for it - await this.requestDataFromPeers(processId, [state.state_id], [state.roles]); - - const maxRetries = 5; - const retryDelay = 500; // delay in milliseconds - let retries = 0; - - while ((!hash || !key) && retries < maxRetries) { - await new Promise((resolve) => setTimeout(resolve, retryDelay)); - // Re-read hash and key after waiting - hash = state.pcd_commitment[attribute]; - key = state.keys[attribute]; - retries++; - } - } - - if (hash && key) { - const blob = await this.getBlobFromDb(hash); - if (blob) { - // Decrypt the data - const buf = await blob.arrayBuffer(); - const cipher = new Uint8Array(buf); - - const keyUIntArray = this.hexToUInt8Array(key); - - try { - const clear = this.sdkClient.decrypt_data(keyUIntArray, cipher); - if (clear) { - // deserialize the result to get the actual data - const decoded = this.sdkClient.decode_value(clear); - return decoded; - } else { - throw new Error('decrypt_data returned null'); - } - } catch (e) { - console.error(`Failed to decrypt data: ${e}`); - } - } - } - - return null; - } - - getNotifications(): any[] | null { - // return [ - // { - // id: 1, - // title: 'Notif 1', - // description: 'A normal notification', - // sendToNotificationPage: false, - // path: '/notif1', - // }, - // { - // id: 2, - // title: 'Notif 2', - // description: 'A normal notification', - // sendToNotificationPage: false, - // path: '/notif2', - // }, - // { - // id: 3, - // title: 'Notif 3', - // description: 'A normal notification', - // sendToNotificationPage: false, - // path: '/notif3', - // }, - // ]; - return this.notifications; - } - - setNotifications(notifications: any[]) { - this.notifications = notifications; - } - - async importJSON(backup: BackUp): Promise { - const device = backup.device; - - // Reset current device - await this.resetDevice(); - - await this.saveDeviceInDatabase(device); - - this.restoreDevice(device); - - // TODO restore secrets and processes from file - const secretsStore = backup.secrets; - await this.restoreSecretsFromBackUp(secretsStore); - - const processes = backup.processes; - await this.restoreProcessesFromBackUp(processes); - } - - public async createBackUp(): Promise { - // Get the device from indexedDB - const device = await this.getDeviceFromDatabase(); - if (!device) { - console.error('No device loaded'); - return null; - } - - // Get the processes - const processes = await this.getProcesses(); - - // Get the shared secrets - const secrets = await this.getAllSecrets(); - - // Create a backup object - const backUp = { - device: device, - secrets: secrets, - processes: processes, - }; - - return backUp; - } - - // Device 1 wait Device 2 - public device1: boolean = false; - public device2Ready: boolean = false; - - public resetState() { - this.device1 = false; - this.device2Ready = false; - } - - // Handle the handshake message - public async handleHandshakeMsg(url: string, parsedMsg: any) { - try { - const handshakeMsg: HandshakeMessage = JSON.parse(parsedMsg); - this.updateRelay(url, handshakeMsg.sp_address); - this.currentBlockHeight = handshakeMsg.chain_tip; - if (this.membersList && Object.keys(this.membersList).length === 0) { - // We start from an empty list, just copy it over - this.membersList = handshakeMsg.peers_list; - } else { - // We are incrementing our list - for (const [processId, member] of Object.entries(handshakeMsg.peers_list)) { - this.membersList[processId] = member as Member; - } - } - - setTimeout(async () => { - const newProcesses: OutPointProcessMap = handshakeMsg.processes_list; - 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) { - // Look for state id we don't know yet - let new_states: string[] = []; - let roles: Record[] = []; - for (const state of process.states) { - if (!state.state_id || state.state_id === EMPTY32BYTES) { - continue; - } - if (!this.lookForStateId(existing, state.state_id)) { - if (this.rolesContainsUs(state.roles)) { - new_states.push(state.state_id); - roles.push(state.roles); - } - } - } - - 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 - - // We may learn an update for this process - // TODO maybe actually check if what the relay is sending us contains more information than what we have - // relay should always have more info than us, but we never know - // For now let's keep it simple and let the worker do the job - } else { - // We add it to db - console.log(`Saving ${processId} to db`); - toSave[processId] = process; - } - } - - await this.batchSaveProcessesToDb(toSave); - } - }, 500); - } catch (e) { - console.error('Failed to parse init message:', e); - } - } - - private lookForStateId(process: Process, stateId: string): boolean { - for (const state of process.states) { - if (state.state_id === stateId) { - return true; - } - } - - return false; - } - - /** - * Waits for at least one handshake message to be received from any connected relay. - * This ensures that the relay addresses are fully populated and the member list is updated. - * @returns A promise that resolves when at least one handshake message is received. - */ - private async waitForHandshakeMessage(timeoutMs: number = 10000): Promise { - const startTime = Date.now(); - const pollInterval = 100; // Check every 100ms - - return new Promise((resolve, reject) => { - const checkForHandshake = () => { - // Check if we have any members (indicating handshake was received) - if (Object.keys(this.membersList).length > 0) { - console.log('Handshake message received, members list populated'); - resolve(); - return; - } - - // Check timeout - if (Date.now() - startTime >= timeoutMs) { - reject(new Error(`No handshake message received after ${timeoutMs}ms timeout`)); - return; - } - - // Continue polling - setTimeout(checkForHandshake, pollInterval); - }; - - checkForHandshake(); - }); - } - - /** - * Retourne la liste de tous les membres ordonnés par leur process id - * @returns Un tableau contenant tous les membres - */ - public getAllMembersSorted(): Record { - return Object.fromEntries(Object.entries(this.membersList).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))); - } - - public getAllMembers(): Record { - return this.membersList; - } - - public getAddressesForMemberId(memberId: string): string[] | null { - try { - return this.membersList[memberId].sp_addresses; - } catch (e) { - return null; - } - } - - public compareMembers(memberA: string[], memberB: string[]): boolean { - if (!memberA || !memberB) { - return false; - } - if (memberA.length !== memberB.length) { - return false; - } - - const res = memberA.every((item) => memberB.includes(item)) && memberB.every((item) => memberA.includes(item)); - - return res; - } - - public async handleCommitError(response: string) { - const content = JSON.parse(response); - const error = content.error; - const errorMsg = error['GenericError']; - 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 () => { - this.sendCommitMessage(JSON.stringify(content)); - }, 1000); - } - - public getRoles(process: Process): Record | null { - const lastCommitedState = this.getLastCommitedState(process); - if (lastCommitedState && lastCommitedState.roles && Object.keys(lastCommitedState.roles).length != 0) { - return lastCommitedState!.roles; - } else if (process.states.length === 2) { - const firstState = process.states[0]; - if (firstState && firstState.roles && Object.keys(firstState.roles).length != 0) { - return firstState!.roles; - } - } - return null; - } - - public getPublicData(process: Process): Record | null { - const lastCommitedState = this.getLastCommitedState(process); - if (lastCommitedState && lastCommitedState.public_data && Object.keys(lastCommitedState.public_data).length != 0) { - return lastCommitedState!.public_data; - } else if (process.states.length === 2) { - const firstState = process.states[0]; - if (firstState && firstState.public_data && Object.keys(firstState.public_data).length != 0) { - return firstState!.public_data; - } - } - return null; - } - - public getProcessName(process: Process): string | null { - const lastCommitedState = this.getLastCommitedState(process); - if (lastCommitedState && lastCommitedState.public_data) { - const processName = lastCommitedState!.public_data['processName']; - if (processName) { - return this.decodeValue(processName); - } else { - return null; - } - } else { - return null; - } - } - - public async getMyProcesses(): Promise { - // If we're not paired yet, just skip it - let pairingProcessId = null; - try { - pairingProcessId = this.getPairingProcessId(); - } catch (e) { - return null; - } - if (!pairingProcessId) { - return null; - } - - try { - const processes = await this.getProcesses(); - - const newMyProcesses = new Set(this.myProcesses || []); - // MyProcesses automatically contains pairing process - newMyProcesses.add(pairingProcessId); - for (const [processId, process] of Object.entries(processes)) { - // We use myProcesses attribute to not reevaluate all processes everytime - if (newMyProcesses.has(processId)) { - continue; - } - try { - const roles = this.getRoles(process); - - if (roles && this.rolesContainsUs(roles)) { - 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); - return null; - } - } - - public async requestDataFromPeers(processId: string, stateIds: string[], roles: Record[]) { - console.log('Requesting data from peers'); - const membersList = this.getAllMembers(); - try { - const res = this.sdkClient.request_data(processId, stateIds, roles, membersList); - await this.handleApiReturn(res); - } catch (e) { - console.error(e); - } - } - - public hexToBlob(hexString: string): Blob { - const uint8Array = this.hexToUInt8Array(hexString); - // Crée un ArrayBuffer standard et copie les données pour éviter ArrayBufferLike/SharedArrayBuffer - const ab = new ArrayBuffer(uint8Array.length); - const view = new Uint8Array(ab); - view.set(uint8Array); - return new Blob([ab], { type: 'application/octet-stream' }); - } - - public hexToUInt8Array(hexString: string): Uint8Array { - if (hexString.length % 2 !== 0) { - throw new Error('Invalid hex string: length must be even'); - } - const uint8Array = new Uint8Array(hexString.length / 2); - for (let i = 0; i < hexString.length; i += 2) { - uint8Array[i / 2] = parseInt(hexString.substr(i, 2), 16); - } - - return uint8Array; - } - - public async blobToHex(blob: Blob): Promise { - const buffer = await blob.arrayBuffer(); - const bytes = new Uint8Array(buffer); - return Array.from(bytes) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); - } - - public getHashForFile(commitedIn: string, label: string, fileBlob: { type: string; data: Uint8Array }): string { - return this.sdkClient.hash_value(fileBlob, commitedIn, label); - } - - public getMerkleProofForFile(processState: ProcessState, attributeName: string): MerkleProofResult { - 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; - const lastCommitedState = process.states.findLast((state: ProcessState) => state.commited_in !== processTip); - if (lastCommitedState) { - return lastCommitedState; - } else { - return null; - } - } - - public getLastCommitedStateIndex(process: Process): number | null { - if (process.states.length === 0) return null; - const processTip = process.states[process.states.length - 1].commited_in; - for (let i = process.states.length - 1; i >= 0; i--) { - if (process.states[i].commited_in !== processTip) { - return i; - } - } - 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: ProcessState) => state.commited_in === processTip); - return res.filter((state: ProcessState) => 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: ProcessState) => state.state_id === stateId); - if (state) { - return state; - } else { - return null; - } - } - - public getNextStateAfterId(process: Process, stateId: string): ProcessState | null { - if (process.states.length === 0) return null; - - const index = process.states.findIndex((state: ProcessState) => state.state_id === stateId); - - if (index !== -1 && index < process.states.length - 1) { - return process.states[index + 1]; - } - - return null; - } - - public isPairingProcess(roles: Record): boolean { - if (Object.keys(roles).length != 1) { - return false; - } - const pairingRole = roles['pairing']; - if (pairingRole) { - // For now that's enough, we should probably test more things - return true; - } else { - return false; - } - } - - public async updateMemberPublicName(process: Process, newName: string): Promise { - const publicData = { - memberPublicName: newName, - }; - - return await this.updateProcess(process, {}, publicData, null); - } -} +import { INotification } from '~/models/notification.model'; +import { IProcess } from '~/models/process.model'; +import { initWebsocket, sendMessage } from '../websockets'; +import { ApiReturn, Device, HandshakeMessage, Member, MerkleProofResult, NewTxMessage, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../../pkg/sdk_client'; +import ModalService from './modal.service'; +import Database from './database.service'; +import { navigate } from '../router'; +import { storeData, retrieveData, testData } from './storage.service'; +import { BackUp } from '~/models/backup.model'; + +export const U32_MAX = 4294967295; + +const BASEURL = `http://localhost`; +const BOOTSTRAPURL = [`${BASEURL}:8090`]; +const STORAGEURL = `${BASEURL}:8081` +const BLINDBITURL = `${BASEURL}:8000` +const DEFAULTAMOUNT = 1000n; +const EMPTY32BYTES = String('').padStart(64, '0'); + +export default class Services { + private static initializing: Promise | null = null; + private static instance: 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 }[] = []; + private database: any; + private routingInstance!: ModalService; + private relayAddresses: { [wsurl: string]: string } = {}; + private membersList: Record = {}; + private currentBlockHeight: number = -1; + // Private constructor to prevent direct instantiation from outside + private constructor() {} + + // Method to access the singleton instance of Services + public static async getInstance(): Promise { + if (Services.instance) { + return Services.instance; + } + + if (!Services.initializing) { + Services.initializing = (async () => { + const instance = new Services(); + await instance.init(); + instance.routingInstance = await ModalService.getInstance(); + return instance; + })(); + } + + console.log('initializing services'); + Services.instance = await Services.initializing; + Services.initializing = null; // Reset for potential future use + return Services.instance; + } + + public async init(): Promise { + this.notifications = this.getNotifications(); + this.sdkClient = await import('../../pkg/sdk_client'); + this.sdkClient.setup(); + for (const wsurl of Object.values(BOOTSTRAPURL)) { + this.updateRelay(wsurl, ''); + } + } + + public setProcessId(processId: string | null) { + this.processId = processId; + } + + public setStateId(stateId: string | null) { + this.stateId = stateId; + } + + public getProcessId(): string | null { + return this.processId; + } + + public getStateId(): string | null { + return this.stateId; + } + + /** + * Calls `this.addWebsocketConnection` for each `wsurl` in relayAddresses. + * Waits for at least one handshake message before returning. + */ + public async connectAllRelays(): Promise { + const connectedUrls: string[] = []; + + // Connect to all relays + for (const wsurl of Object.keys(this.relayAddresses)) { + try { + console.log(`Connecting to: ${wsurl}`); + await this.addWebsocketConnection(wsurl); + connectedUrls.push(wsurl); + console.log(`Successfully connected to: ${wsurl}`); + } catch (error) { + console.error(`Failed to connect to ${wsurl}:`, error); + } + } + + // Wait for at least one handshake message if we have connections + if (connectedUrls.length > 0) { + await this.waitForHandshakeMessage(); + } + } + + public async addWebsocketConnection(url: string): Promise { + console.log('Opening new websocket connection'); + await initWebsocket(url); + } + + /** + * Add or update a key/value pair in relayAddresses. + * @param wsurl - The WebSocket URL (key). + * @param spAddress - The SP Address (value). + */ + public updateRelay(wsurl: string, spAddress: string): void { + this.relayAddresses[wsurl] = spAddress; + console.log(`Updated: ${wsurl} -> ${spAddress}`); + } + + /** + * Retrieve the spAddress for a given wsurl. + * @param wsurl - The WebSocket URL to look up. + * @returns The SP Address if found, or undefined if not. + */ + public getSpAddress(wsurl: string): string | undefined { + return this.relayAddresses[wsurl]; + } + + /** + * Get all key/value pairs from relayAddresses. + * @returns An array of objects containing wsurl and spAddress. + */ + public getAllRelays(): { wsurl: string; spAddress: string }[] { + return Object.entries(this.relayAddresses).map(([wsurl, spAddress]) => ({ + wsurl, + spAddress, + })); + } + + /** + * Print all key/value pairs for debugging. + */ + public printAllRelays(): void { + console.log("Current relay addresses:"); + for (const [wsurl, spAddress] of Object.entries(this.relayAddresses)) { + console.log(`${wsurl} -> ${spAddress}`); + } + } + + public isPaired(): boolean { + try { + return this.sdkClient.is_paired(); + } catch (e) { + throw new Error(`isPaired ~ Error: ${e}`); + } + } + + public async unpairDevice(): Promise { + try { + this.sdkClient.unpair_device(); + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + } catch (e) { + throw new Error(`Failed to unpair device: ${e}`); + } + } + + public async getSecretForAddress(address: string): Promise { + const db = await Database.getInstance(); + return await db.getObject('shared_secrets', address); + } + + public async getAllSecrets(): Promise { + const db = await Database.getInstance(); + const sharedSecrets = await db.dumpStore('shared_secrets'); + const unconfirmedSecrets = await db.dumpStore('unconfirmed_secrets'); // keys are numeric values + + const secretsStore = { + shared_secrets: sharedSecrets, + unconfirmed_secrets: Object.values(unconfirmedSecrets), + }; + + return secretsStore; + } + + public async getAllDiffs(): Promise> { + const db = await Database.getInstance(); + return await db.dumpStore('diffs'); + } + + public async getDiffByValue(value: string): Promise { + const db = await Database.getInstance(); + const store = 'diffs'; + const res = await db.getObject(store, value); + return res; + } + + private async getTokensFromFaucet(): Promise { + try { + await this.ensureSufficientAmount(); + } catch (e) { + console.error('Failed to get tokens from relay, check connection'); + return; + } + } + + public async checkConnections(members: Member[]): Promise { + // Ensure the amount is available before proceeding + await this.getTokensFromFaucet(); + let unconnectedAddresses: Set = new Set(); + const myAddress = this.getDeviceAddress(); + for (const member of members) { + const sp_addresses = member.sp_addresses; + if (!sp_addresses || sp_addresses.length === 0) continue; + for (const address of sp_addresses) { + // For now, we ignore our own device address, although there might be use cases for having a secret with ourselves + if (address === myAddress) continue; + const sharedSecret = await this.getSecretForAddress(address); + if (!sharedSecret) { + unconnectedAddresses.add(address); + } + } + } + if (unconnectedAddresses && unconnectedAddresses.size != 0) { + const apiResult = await this.connectAddresses([...unconnectedAddresses]); + await this.handleApiReturn(apiResult); + } + } + + public async connectAddresses(addresses: string[]): Promise { + if (addresses.length === 0) { + throw new Error('Trying to connect to empty addresses list'); + } + + try { + return this.sdkClient.create_transaction(addresses, 1); + } catch (e) { + console.error('Failed to connect member:', e); + throw e; + } + } + + private async ensureSufficientAmount(): Promise { + const availableAmt = this.getAmount(); + const target: BigInt = DEFAULTAMOUNT * BigInt(10); + + if (availableAmt < target) { + const faucetMsg = this.createFaucetMessage(); + this.sendFaucetMessage(faucetMsg); + + await this.waitForAmount(target); + } + } + + private async waitForAmount(target: BigInt): Promise { + let attempts = 3; + + while (attempts > 0) { + const amount = this.getAmount(); + if (amount >= target) { + return amount; + } + + attempts--; + if (attempts > 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second + } + } + + throw new Error('Amount is still 0 after 3 attempts'); + } + + public async createPairingProcess(userName: string, pairWith: string[]): Promise { + if (this.sdkClient.is_paired()) { + throw new Error('Device already paired'); + } + const myAddress: string = this.sdkClient.get_address(); + pairWith.push(myAddress); + const privateData = { + description: 'pairing', + counter: 0, + }; + const publicData = { + memberPublicName: userName, + pairedAddresses: pairWith, + }; + const validation_fields: string[] = [...Object.keys(privateData), ...Object.keys(publicData), 'roles']; + const roles: Record = { + pairing: { + members: [], + validation_rules: [ + { + quorum: 1.0, + fields: validation_fields, + min_sig_member: 1.0, + }, + ], + storages: [STORAGEURL] + }, + }; + try { + return this.createProcess( + privateData, + publicData, + roles + ); + } catch (e) { + throw new Error(`Creating process failed:, ${e}`); + } + } + + private isFileBlob(value: any): value is { type: string, data: Uint8Array } { + return ( + typeof value === 'object' && + value !== null && + typeof value.type === 'string' && + value.data instanceof Uint8Array + ); + } + + private splitData(obj: Record) { + const jsonCompatibleData: Record = {}; + const binaryData: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + if (this.isFileBlob(value)) { + binaryData[key] = value; + } else { + jsonCompatibleData[key] = value; + } + } + + return { jsonCompatibleData, binaryData }; + } + + public async createProcess( + privateData: Record, + publicData: Record, + roles: Record, + ): Promise { + let relayAddress = this.getAllRelays()[0]?.spAddress; + + if (!relayAddress || relayAddress === '') { + console.log('No relay address found, connecting to relays...'); + await this.connectAllRelays(); + + // After connectAllRelays completes, relay addresses should be updated + relayAddress = this.getAllRelays()[0]?.spAddress; + if (!relayAddress || relayAddress === '') { + throw new Error('No relay address available after connecting to relays'); + } + } + + const feeRate = 1; + + // We can't encode files as the rest because Uint8Array is not valid json + // So we first take them apart and we will encode them separately and put them back in the right object + // TODO encoding of relatively large binaries (=> 1M) is a bit long now and blocking + const privateSplitData = this.splitData(privateData); + const publicSplitData = this.splitData(publicData); + const encodedPrivateData = { + ...this.sdkClient.encode_json(privateSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(privateSplitData.binaryData) + }; + const encodedPublicData = { + ...this.sdkClient.encode_json(publicSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(publicSplitData.binaryData) + }; + + console.log('encoded data:', encodedPrivateData); + console.log('encoded data:', encodedPublicData); + + let members: Set = new Set(); + for (const role of Object.values(roles!)) { + for (const member of role.members) { + // Check if we know the member that matches this id + const memberAddresses = this.getAddressesForMemberId(member); + if (memberAddresses && memberAddresses.length != 0) { + members.add({ sp_addresses: memberAddresses }); + } + } + } + console.log('members:', members); + await this.checkConnections([...members]); + + const result = this.sdkClient.create_new_process ( + encodedPrivateData, + roles, + encodedPublicData, + relayAddress, + feeRate, + this.getAllMembers() + ); + + return(result); + } + + public async updateProcess(process: Process, privateData: Record, publicData: Record, roles: Record | null): Promise { + // If roles is null, we just take the last commited state roles + if (!roles) { + roles = this.getRoles(process); + } else { + // We should check that we have the right to change the roles here, or maybe it's better leave it to the wasm + console.log('Provided new roles:', JSON.stringify(roles)); + } + let members: Set = new Set(); + for (const role of Object.values(roles!)) { + for (const member of role.members) { + 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); + const encodedPrivateData = { + ...this.sdkClient.encode_json(privateSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(privateSplitData.binaryData) + }; + const encodedPublicData = { + ...this.sdkClient.encode_json(publicSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(publicSplitData.binaryData) + }; + try { + return this.sdkClient.update_process(process, encodedPrivateData, roles, encodedPublicData, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to update process: ${e}`); + } + } + + public async createPrdUpdate(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Unknown process'); + } + try { + return this.sdkClient.create_update_message(process, stateId, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to create prd update: ${e}`); + } + } + + public async createPrdResponse(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Unknown process'); + } + try { + return this.sdkClient.create_response_prd(process, stateId, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to create response prd: ${e}`); + } + } + + public async approveChange(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Failed to get process from db'); + } + try { + return this.sdkClient.validate_state(process, stateId, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to create prd response: ${e}`); + } + } + + public async rejectChange(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Failed to get process from db'); + } + try { + return this.sdkClient.refuse_state(process, stateId); + } catch (e) { + throw new Error(`Failed to create prd response: ${e}`); + } + } + + async resetDevice() { + this.sdkClient.reset_device(); + + // Clear all stores + const db = await Database.getInstance(); + await db.clearStore('wallet'); + await db.clearStore('shared_secrets'); + await db.clearStore('unconfirmed_secrets'); + await db.clearStore('processes'); + await db.clearStore('diffs'); + } + + sendNewTxMessage(message: string) { + sendMessage('NewTx', message); + } + + sendCommitMessage(message: string) { + sendMessage('Commit', message); + } + + sendCipherMessages(ciphers: string[]) { + for (let i = 0; i < ciphers.length; i++) { + const cipher = ciphers[i]; + sendMessage('Cipher', cipher); + } + } + + sendFaucetMessage(message: string): void { + sendMessage('Faucet', message); + } + + async parseCipher(message: string) { + const membersList = this.getAllMembers(); + const processes = await this.getProcesses(); + try { + // console.log('parsing new cipher'); + const apiReturn = this.sdkClient.parse_cipher(message, membersList, processes); + await this.handleApiReturn(apiReturn); + + // Device 1 wait Device 2 + const waitingModal = document.getElementById('waiting-modal'); + if (waitingModal) { + this.device2Ready = true; + } + + } catch (e) { + console.error(`Parsed cipher with error: ${e}`); + } + // await this.saveCipherTxToDb(parsedTx) + } + + async parseNewTx(newTxMsg: string) { + const parsedMsg: NewTxMessage = JSON.parse(newTxMsg); + if (parsedMsg.error !== null) { + console.error('Received error in new tx message:', parsedMsg.error); + return; + } + + const membersList = this.getAllMembers(); + try { + // Does the transaction spend the tip of a process? + const prevouts = this.sdkClient.get_prevouts(parsedMsg.transaction); + console.log('prevouts:', prevouts); + for (const process of Object.values(this.processesCache)) { + const tip = process.states[process.states.length - 1].commited_in; + if (prevouts.includes(tip)) { + const processId = process.states[0].commited_in; + const newTip = this.sdkClient.get_txid(parsedMsg.transaction); + console.log('Transaction', newTip, 'spends the tip of process', processId); + // We take the data out of the output + const newStateId = this.sdkClient.get_opreturn(parsedMsg.transaction); + console.log('newStateId:', newStateId); + // We update the relevant process + const updatedProcess = this.sdkClient.process_commit_new_state(process, newStateId, newTip); + this.processesCache[processId] = updatedProcess; + console.log('updatedProcess:', updatedProcess); + break; + } + } + } catch (e) { + console.error('Failed to parse new tx for commitments:', e); + } + + try { + const parsedTx = this.sdkClient.parse_new_tx(newTxMsg, 0, membersList); + if (parsedTx) { + try { + await this.handleApiReturn(parsedTx); + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + } catch (e) { + console.error('Failed to update device with new tx'); + } + } + } catch (e) { + console.debug(e); + } + } + + public async handleApiReturn(apiReturn: ApiReturn) { + console.log(apiReturn); + if (apiReturn.partial_tx) { + try { + const res = this.sdkClient.sign_transaction(apiReturn.partial_tx); + apiReturn.new_tx_to_send = res.new_tx_to_send; + } catch (e) { + console.error('Failed to sign transaction:', e); + } + } + + if (apiReturn.new_tx_to_send && apiReturn.new_tx_to_send.transaction.length != 0) { + this.sendNewTxMessage(JSON.stringify(apiReturn.new_tx_to_send)); + await new Promise(r => setTimeout(r, 500)); + } + + if (apiReturn.secrets) { + const unconfirmedSecrets = apiReturn.secrets.unconfirmed_secrets; + const confirmedSecrets = apiReturn.secrets.shared_secrets; + + const db = await Database.getInstance(); + for (const secret of unconfirmedSecrets) { + await db.addObject({ + storeName: 'unconfirmed_secrets', + object: secret, + key: null, + }); + } + const entries = Object.entries(confirmedSecrets).map(([key, value]) => ({ key, value })); + for (const entry of entries) { + try { + await db.addObject({ + storeName: 'shared_secrets', + object: entry.value, + key: entry.key, + }); + } catch (e) { + throw e; + } + + // We don't want to throw an error, it could simply be that we registered directly the shared secret + // this.removeUnconfirmedSecret(entry.value); + } + } + + if (apiReturn.updated_process) { + const updatedProcess = apiReturn.updated_process; + + const processId: string = updatedProcess.process_id; + + if (updatedProcess.encrypted_data && Object.keys(updatedProcess.encrypted_data).length != 0) { + for (const [hash, cipher] of Object.entries(updatedProcess.encrypted_data)) { + const blob = this.hexToBlob(cipher); + try { + await this.saveBlobToDb(hash, blob); + } catch (e) { + console.error(e); + } + } + } + + // Save process to db + await this.saveProcessToDb(processId, updatedProcess.current_process); + + if (updatedProcess.diffs && updatedProcess.diffs.length != 0) { + try { + await this.saveDiffsToDb(updatedProcess.diffs); + } catch (e) { + console.error('Failed to save diffs to db:', e); + } + } + } + + if (apiReturn.push_to_storage && apiReturn.push_to_storage.length != 0) { + for (const hash of apiReturn.push_to_storage) { + const blob = await this.getBlobFromDb(hash); + if (blob) { + await this.saveDataToStorage(hash, blob, null); + } else { + console.error('Failed to get data from db'); + } + } + } + + if (apiReturn.commit_to_send) { + const commit = apiReturn.commit_to_send; + this.sendCommitMessage(JSON.stringify(commit)); + } + + if (apiReturn.ciphers_to_send && apiReturn.ciphers_to_send.length != 0) { + this.sendCipherMessages(apiReturn.ciphers_to_send); + } + } + + public async openPairingConfirmationModal(processId: string) { + const process = await this.getProcess(processId); + if (!process) { + console.error('Failed to find pairing process'); + return; + } + const firstState = process.states[0]; + const roles = firstState.roles; + const stateId = firstState.state_id; + try { + await this.routingInstance.openPairingConfirmationModal(roles, processId, stateId); + } catch (e) { + console.error(e); + } + } + + public async confirmPairing() { + if (!this.processId || !this.stateId) { + console.error('Missing process and/or state ID'); + return; + } + let createPrdUpdateReturn; + try { + createPrdUpdateReturn = await this.createPrdUpdate(this.processId, this.stateId); + } catch (e) { + throw new Error(`createPrdUpdate failed: ${e}`); + } + await this.handleApiReturn(createPrdUpdateReturn); + + let approveChangeReturn; + try { + approveChangeReturn = await this.approveChange(this.processId, this.stateId); + } catch (e) { + throw new Error(`approveChange failed: ${e}`); + } + await this.handleApiReturn(approveChangeReturn); + + await this.pairDevice(); + + this.processId = null; + this.stateId = null; + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + 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'); + return; + } + const process = await this.getProcess(this.processId); + if (!process) { + console.error('Unknown process'); + return; + } + + let spAddressList: string[] = []; + try { + 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'); + } + } catch (e) { + throw new Error(`Failed to get pairedAddresses from process: ${e}`); + } + try { + this.sdkClient.pair_device(this.processId, spAddressList); + } catch (e) { + throw new Error(`Failed to pair device: ${e}`); + } + } + + public getAmount(): BigInt { + const amount = this.sdkClient.get_available_amount(); + return amount; + } + + getDeviceAddress(): string { + try { + return this.sdkClient.get_address(); + } catch (e) { + throw new Error(`Failed to get device address: ${e}`); + } + } + + public dumpDeviceFromMemory(): Device { + try { + return this.sdkClient.dump_device(); + } catch (e) { + throw new Error(`Failed to dump device: ${e}`); + } + } + + public dumpNeuteredDevice(): Device | null { + try { + return this.sdkClient.dump_neutered_device(); + } catch (e) { + console.error(`Failed to dump device: ${e}`); + return null; + } + } + + public getPairingProcessId(): string { + try { + return this.sdkClient.get_pairing_process_id(); + } catch (e) { + throw new Error(`Failed to get pairing process: ${e}`); + } + } + + async saveDeviceInDatabase(device: Device): Promise { + const db = await Database.getInstance(); + const walletStore = 'wallet'; + try { + const prevDevice = await this.getDeviceFromDatabase(); + if (prevDevice) { + await db.deleteObject(walletStore, "1"); + } + await db.addObject({ + storeName: walletStore, + object: { pre_id: '1', device }, + key: null, + }); + } catch (e) { + console.error(e); + } + } + + async getDeviceFromDatabase(): Promise { + const db = await Database.getInstance(); + const walletStore = 'wallet'; + try { + const dbRes = await db.getObject(walletStore, '1'); + if (dbRes) { + return dbRes['device']; + } else { + return null; + } + } catch (e) { + throw new Error(`Failed to retrieve device from db: ${e}`); + } + } + + async getMemberFromDevice(): Promise { + try { + const device = await this.getDeviceFromDatabase(); + if (device) { + const pairedMember = device['paired_member']; + return pairedMember.sp_addresses; + } else { + return null; + } + } catch (e) { + throw new Error(`Failed to retrieve paired_member from device: ${e}`); + } + } + + isChildRole(parent: any, child: any): boolean { + try { + this.sdkClient.is_child_role(JSON.stringify(parent), JSON.stringify(child)); + } catch (e) { + console.error(e); + return false; + } + + return true; + } + + rolesContainsUs(roles: Record): boolean { + let us; + try { + us = this.sdkClient.get_pairing_process_id(); + } catch (e) { + throw e; + } + + return this.rolesContainsMember(roles, us); + } + + rolesContainsMember(roles: Record, pairingProcessId: string): boolean { + for (const roleDef of Object.values(roles)) { + if (roleDef.members.includes(pairingProcessId)) { + return true; + } + } + + return false; + } + + async dumpWallet() { + const wallet = await this.sdkClient.dump_wallet(); + return wallet; + } + + public createFaucetMessage() { + const message = this.sdkClient.create_faucet_msg(); + return message; + } + + async createNewDevice() { + let spAddress = ''; + try { + // We set birthday later when we have the chain tip from relay + spAddress = await this.sdkClient.create_new_device(0, 'signet'); + const device = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(device); + } catch (e) { + console.error('Services ~ Error:', e); + } + + return spAddress; + } + + public restoreDevice(device: Device) { + try { + this.sdkClient.restore_device(device); + } catch (e) { + console.error(e); + } + } + + public async updateDeviceBlockHeight(): Promise { + if (this.currentBlockHeight === -1) { + throw new Error('Current block height not set'); + } + + let device: Device | null = null; + try { + device = await this.getDeviceFromDatabase(); + } catch (e) { + throw new Error(`Failed to get device from database: ${e}`); + } + + if (!device) { + throw new Error('Device not found'); + } + + const birthday = device.sp_wallet.birthday; + if (birthday === undefined || birthday === null) { + throw new Error('Birthday not found'); + } + + if (birthday === 0) { + // This is a new device, so current chain tip is its birthday + device.sp_wallet.birthday = this.currentBlockHeight; + // We also set last_scan, impossible that we need to scan earlier than this + device.sp_wallet.last_scan = this.currentBlockHeight; + try { + // First set the updated device in memory + this.sdkClient.restore_device(device); + // Then save it to database + await this.saveDeviceInDatabase(device); + } catch (e) { + throw new Error(`Failed to save updated device: ${e}`); + } + } else { + // This is existing device, we need to catch up if last_scan is lagging behind chain_tip + if (device.sp_wallet.last_scan < this.currentBlockHeight) { + // We need to catch up + await this.sdkClient.scan_blocks(this.currentBlockHeight, BLINDBITURL); + } else { + // Up to date, just returns + return; + } + } + } + + private async removeProcess(processId: string): Promise { + const db = await Database.getInstance(); + const storeName = 'processes'; + + try { + await db.deleteObject(storeName, processId); + } catch (e) { + console.error(e); + } + } + + 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, + object: process, + key: processId, + }); + + // Update the process in the cache + this.processesCache[processId] = process; + } catch (e) { + console.error(`Failed to save process ${processId}: ${e}`); + } + } + + public async saveBlobToDb(hash: string, data: Blob) { + const db = await Database.getInstance(); + try { + await db.addObject({ + storeName: 'data', + object: data, + key: hash, + }); + } catch (e) { + console.error(`Failed to save data to db: ${e}`); + } + } + + public async getBlobFromDb(hash: string): Promise { + const db = await Database.getInstance(); + try { + return await db.getObject('data', hash); + } catch (e) { + return null; + } + } + + public async saveDataToStorage(hash: string, data: Blob, ttl: number | null) { + const storages = [STORAGEURL]; + + try { + await storeData(storages, hash, data, ttl); + } catch (e) { + console.error(`Failed to store data with hash ${hash}: ${e}`); + } + } + + public async fetchValueFromStorage(hash: string): Promise { + const storages = [STORAGEURL]; + + return await retrieveData(storages, hash); + } + + public async testDataInStorage(hash: string): Promise | null> { + const storages = [STORAGEURL]; + + return await testData(storages, hash); + } + + public async saveDiffsToDb(diffs: UserDiff[]) { + const db = await Database.getInstance(); + try { + for (const diff of diffs) { + await db.addObject({ + storeName: 'diffs', + object: diff, + key: null, + }); + } + } catch (e) { + throw new Error(`Failed to save process: ${e}`); + } + } + + public async getProcess(processId: string): Promise { + 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> { + 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; + } + } + } + + public async restoreProcessesFromBackUp(processes: Record) { + const db = await Database.getInstance(); + 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(); + } + + // Restore processes cache from persistent storage + public async restoreProcessesFromDB() { + const db = await Database.getInstance(); + try { + 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; + } else { + console.log('No processes to restore!'); + } + } catch (e) { + throw e; + } + } + + public async clearSecretsFromDB() { + const db = await Database.getInstance(); + try { + await db.clearStore('shared_secrets'); + await db.clearStore('unconfirmed_secrets'); + } catch (e) { + console.error(e); + } + } + + public async restoreSecretsFromBackUp(secretsStore: SecretsStore) { + const db = await Database.getInstance(); + + for (const secret of secretsStore.unconfirmed_secrets) { + await db.addObject({ + storeName: 'unconfirmed_secrets', + object: secret, + key: null, + }); + } + const entries = Object.entries(secretsStore.shared_secrets).map(([key, value]) => ({ key, value })); + for (const entry of entries) { + await db.addObject({ + storeName: 'shared_secrets', + object: entry.value, + key: entry.key, + }); + } + + // Now we can transfer them to memory + await this.restoreSecretsFromDB(); + } + + public async restoreSecretsFromDB() { + const db = await Database.getInstance(); + try { + const sharedSecrets: Record = await db.dumpStore('shared_secrets'); + const unconfirmedSecrets = await db.dumpStore('unconfirmed_secrets'); + const secretsStore = { + shared_secrets: sharedSecrets, + unconfirmed_secrets: Object.values(unconfirmedSecrets), + }; + this.sdkClient.set_shared_secrets(JSON.stringify(secretsStore)); + } catch (e) { + throw e; + } + } + + decodeValue(value: number[]): any | null { + try { + return this.sdkClient.decode_value(value); + } catch (e) { + console.error(`Failed to decode value: ${e}`); + return null; + } + } + + async decryptAttribute(processId: string, state: ProcessState, attribute: string): Promise { + let hash = state.pcd_commitment[attribute]; + if (!hash) { + // attribute doesn't exist + return null; + } + let key = state.keys[attribute]; + const pairingProcessId = this.getPairingProcessId(); + + // If key is missing, request an update and then retry + if (!key) { + const roles = state.roles; + let hasAccess = false; + // If we're not supposed to have access to this attribute, ignore + for (const role of Object.values(roles)) { + for (const rule of Object.values(role.validation_rules)) { + if (rule.fields.includes(attribute)) { + if (role.members.includes(pairingProcessId)) { + // We have access to this attribute + hasAccess = true; + break; + } + } + } + } + + if (!hasAccess) return null; + + // We should have the key, so we're going to ask other members for it + await this.requestDataFromPeers(processId, [state.state_id], [state.roles]); + + const maxRetries = 5; + const retryDelay = 500; // delay in milliseconds + let retries = 0; + + while ((!hash || !key) && retries < maxRetries) { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + // Re-read hash and key after waiting + hash = state.pcd_commitment[attribute]; + key = state.keys[attribute]; + retries++; + } + } + + if (hash && key) { + const blob = await this.getBlobFromDb(hash); + if (blob) { + // Decrypt the data + const buf = await blob.arrayBuffer(); + const cipher = new Uint8Array(buf); + + const keyUIntArray = this.hexToUInt8Array(key); + + try { + const clear = this.sdkClient.decrypt_data(keyUIntArray, cipher); + if (clear) { + // deserialize the result to get the actual data + const decoded = this.sdkClient.decode_value(clear); + return decoded; + } else { + throw new Error('decrypt_data returned null'); + } + } catch (e) { + console.error(`Failed to decrypt data: ${e}`); + } + } + } + + return null; + } + + getNotifications(): any[] | null { + // return [ + // { + // id: 1, + // title: 'Notif 1', + // description: 'A normal notification', + // sendToNotificationPage: false, + // path: '/notif1', + // }, + // { + // id: 2, + // title: 'Notif 2', + // description: 'A normal notification', + // sendToNotificationPage: false, + // path: '/notif2', + // }, + // { + // id: 3, + // title: 'Notif 3', + // description: 'A normal notification', + // sendToNotificationPage: false, + // path: '/notif3', + // }, + // ]; + return this.notifications; + } + + setNotifications(notifications: any[]) { + this.notifications = notifications; + } + + async importJSON(backup: BackUp): Promise { + const device = backup.device; + + // Reset current device + await this.resetDevice(); + + await this.saveDeviceInDatabase(device); + + this.restoreDevice(device); + + // TODO restore secrets and processes from file + const secretsStore = backup.secrets; + await this.restoreSecretsFromBackUp(secretsStore); + + const processes = backup.processes; + await this.restoreProcessesFromBackUp(processes); + } + + public async createBackUp(): Promise { + // Get the device from indexedDB + const device = await this.getDeviceFromDatabase(); + if (!device) { + console.error('No device loaded'); + return null; + } + + // Get the processes + const processes = await this.getProcesses(); + + // Get the shared secrets + const secrets = await this.getAllSecrets(); + + // Create a backup object + const backUp = { + device: device, + secrets: secrets, + processes: processes, + }; + + return backUp; + } + + // Device 1 wait Device 2 + public device1: boolean = false; + public device2Ready: boolean = false; + + public resetState() { + this.device1 = false; + this.device2Ready = false; + } + + + // Handle the handshake message + public async handleHandshakeMsg(url: string, parsedMsg: any) { + try { + const handshakeMsg: HandshakeMessage = JSON.parse(parsedMsg); + this.updateRelay(url, handshakeMsg.sp_address); + this.currentBlockHeight = handshakeMsg.chain_tip; + if (this.membersList && Object.keys(this.membersList).length === 0) { + // We start from an empty list, just copy it over + this.membersList = handshakeMsg.peers_list; + } else { + // We are incrementing our list + for (const [processId, member] of Object.entries(handshakeMsg.peers_list)) { + this.membersList[processId] = member as Member; + } + } + + setTimeout(async () => { + const newProcesses: OutPointProcessMap = handshakeMsg.processes_list; + 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) { + // Look for state id we don't know yet + let new_states = []; + let roles = []; + for (const state of process.states) { + if (!state.state_id || state.state_id === EMPTY32BYTES) { continue; } + if (!this.lookForStateId(existing, state.state_id)) { + if (this.rolesContainsUs(state.roles)) { + new_states.push(state.state_id); + roles.push(state.roles); + } + } + } + + 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 + + // We may learn an update for this process + // TODO maybe actually check if what the relay is sending us contains more information than what we have + // relay should always have more info than us, but we never know + // For now let's keep it simple and let the worker do the job + } else { + // We add it to db + console.log(`Saving ${processId} to db`); + toSave[processId] = process; + } + } + + await this.batchSaveProcessesToDb(toSave); + } + }, 500) + } catch (e) { + console.error('Failed to parse init message:', e); + } + } + + private lookForStateId(process: Process, stateId: string): boolean { + for (const state of process.states) { + if (state.state_id === stateId) { + return true; + } + } + + return false; + } + + /** + * Waits for at least one handshake message to be received from any connected relay. + * This ensures that the relay addresses are fully populated and the member list is updated. + * @returns A promise that resolves when at least one handshake message is received. + */ + private async waitForHandshakeMessage(timeoutMs: number = 10000): Promise { + const startTime = Date.now(); + const pollInterval = 100; // Check every 100ms + + return new Promise((resolve, reject) => { + const checkForHandshake = () => { + // Check if we have any members (indicating handshake was received) + if (Object.keys(this.membersList).length > 0) { + console.log('Handshake message received, members list populated'); + resolve(); + return; + } + + // Check timeout + if (Date.now() - startTime >= timeoutMs) { + reject(new Error(`No handshake message received after ${timeoutMs}ms timeout`)); + return; + } + + // Continue polling + setTimeout(checkForHandshake, pollInterval); + }; + + checkForHandshake(); + }); + } + + /** + * Retourne la liste de tous les membres ordonnés par leur process id + * @returns Un tableau contenant tous les membres + */ + public getAllMembersSorted(): Record { + return Object.fromEntries( + Object.entries(this.membersList).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + ); + } + + public getAllMembers(): Record { + return this.membersList; + } + + public getAddressesForMemberId(memberId: string): string[] | null { + try { + return this.membersList[memberId].sp_addresses; + } catch (e) { + return null; + } + } + + public compareMembers(memberA: string[], memberB: string[]): boolean { + if (!memberA || !memberB) { return false } + if (memberA.length !== memberB.length) { return false } + + const res = memberA.every(item => memberB.includes(item)) && memberB.every(item => memberA.includes(item)); + + return res; + } + + public async handleCommitError(response: string) { + const content = JSON.parse(response); + const error = content.error; + const errorMsg = error['GenericError']; + 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 () => { + this.sendCommitMessage(JSON.stringify(content)); + }, 1000) + } + + public getRoles(process: Process): Record | null { + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.roles && Object.keys(lastCommitedState.roles).length != 0) { + return lastCommitedState!.roles; + } else if (process.states.length === 2) { + const firstState = process.states[0]; + if (firstState && firstState.roles && Object.keys(firstState.roles).length != 0) { + return firstState!.roles; + } + } + return null; + } + + public getPublicData(process: Process): Record | null { + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.public_data && Object.keys(lastCommitedState.public_data).length != 0) { + return lastCommitedState!.public_data; + } else if (process.states.length === 2) { + const firstState = process.states[0]; + if (firstState && firstState.public_data && Object.keys(firstState.public_data).length != 0) { + return firstState!.public_data; + } + } + return null; + } + + public getProcessName(process: Process): string | null { + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.public_data) { + const processName = lastCommitedState!.public_data['processName']; + if (processName) { return this.decodeValue(processName) } + else { return null } + } else { + return null; + } + } + + public async getMyProcesses(): Promise { + // If we're not paired yet, just skip it + let pairingProcessId = null; + try { + pairingProcessId = this.getPairingProcessId(); + } catch (e) { + return null; + } + if (!pairingProcessId) { + return null; + } + + try { + const processes = await this.getProcesses(); + + const newMyProcesses = new Set(this.myProcesses || []); + // MyProcesses automatically contains pairing process + newMyProcesses.add(pairingProcessId); + for (const [processId, process] of Object.entries(processes)) { + // We use myProcesses attribute to not reevaluate all processes everytime + if (newMyProcesses.has(processId)) { + continue; + } + try { + const roles = this.getRoles(process); + + if (roles && this.rolesContainsUs(roles)) { + 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); + return null; + } + } + + public async requestDataFromPeers(processId: string, stateIds: string[], roles: Record[]) { + console.log('Requesting data from peers'); + const membersList = this.getAllMembers(); + try { + const res = this.sdkClient.request_data(processId, stateIds, roles, membersList); + await this.handleApiReturn(res); + } catch (e) { + console.error(e); + } + } + + public hexToBlob(hexString: string): Blob { + const uint8Array = this.hexToUInt8Array(hexString); + + return new Blob([uint8Array], { type: "application/octet-stream" }); + } + + public hexToUInt8Array(hexString: string): Uint8Array { + if (hexString.length % 2 !== 0) { + throw new Error("Invalid hex string: length must be even"); + } + const uint8Array = new Uint8Array(hexString.length / 2); + for (let i = 0; i < hexString.length; i += 2) { + uint8Array[i / 2] = parseInt(hexString.substr(i, 2), 16); + } + + return uint8Array; + } + + public async blobToHex(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + return Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + } + + public getHashForFile(commitedIn: string, label: string, fileBlob: { type: string; data: Uint8Array }): string { + return this.sdkClient.hash_value(fileBlob, commitedIn, label); + } + + public getMerkleProofForFile(processState: ProcessState, attributeName: string): MerkleProofResult { + 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; + const lastCommitedState = process.states.findLast(state => state.commited_in !== processTip); + if (lastCommitedState) { + return lastCommitedState; + } else { + return null; + } + } + + public getLastCommitedStateIndex(process: Process): number | null { + if (process.states.length === 0) return null; + const processTip = process.states[process.states.length - 1].commited_in; + for (let i = process.states.length - 1; i >= 0; i--) { + if (process.states[i].commited_in !== processTip) { + return i; + } + } + 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); + if (state) { + return state; + } else { + return null; + } + } + + public getNextStateAfterId(process: Process, stateId: string): ProcessState | null { + if (process.states.length === 0) return null; + + const index = process.states.findIndex(state => state.state_id === stateId); + + if (index !== -1 && index < process.states.length - 1) { + return process.states[index + 1]; + } + + return null; + } + + public isPairingProcess(roles: Record): boolean { + if (Object.keys(roles).length != 1) { return false } + const pairingRole = roles['pairing']; + if (pairingRole) { + // For now that's enough, we should probably test more things + return true; + } else { + return false; + } + } + + public async updateMemberPublicName(process: Process, newName: string): Promise { + const publicData = { + 'memberPublicName': newName + }; + + return await this.updateProcess(process, {}, publicData, null); + } +}