// Simple server service with core protocol methods using WASM SDK import Database from './database.service'; import * as wasm from '../pkg/sdk_client'; import { ApiReturn, Device, HandshakeMessage, Member, MerkleProofResult, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../pkg/sdk_client'; import { RelayManager } from './relay-manager'; import { config } from './config'; import { EMPTY32BYTES } from './utils'; const DEFAULTAMOUNT = 1000n; const DEVICE_KEY = 'main_device'; export class Service { private static instance: Service; private processes: Map = new Map(); private membersList: any = {}; private relayManager: RelayManager; private storages: string[] = []; // storage urls private constructor() { console.log('🔧 Service initialized'); this.relayManager = RelayManager.getInstance(); this.relayManager.setHandshakeCallback((url: string, message: any) => { this.handleHandshakeMsg(url, message); }); this.initWasm(); // Removed automatic relay initialization - will connect when needed } private initWasm() { try { console.log('🔧 Initializing WASM SDK...'); wasm.setup(); console.log('✅ WASM SDK initialized successfully'); } catch (error) { console.error('❌ Failed to initialize WASM SDK:', error); throw error; } } static getInstance(): Service { if (!Service.instance) { Service.instance = new Service(); } return Service.instance; } // Handle the handshake message public async handleHandshakeMsg(url: string, parsedMsg: any) { try { const handshakeMsg: HandshakeMessage = JSON.parse(parsedMsg.content); if (handshakeMsg.sp_address) { this.relayManager.updateRelay(url, handshakeMsg.sp_address); } 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 if (handshakeMsg.peers_list) { for (const [processId, member] of Object.entries(handshakeMsg.peers_list)) { this.membersList[processId] = member as Member; } } } setTimeout(async () => { if (handshakeMsg.processes_list) { const newProcesses: OutPointProcessMap = handshakeMsg.processes_list; if (!newProcesses || Object.keys(newProcesses).length === 0) { console.debug('Received empty processes list from', url); return; } if (this.processes.size === 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'] as unknown as number[]); // 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); } } public async connectToRelays(): Promise { const { relayUrls } = config; console.log(`🔗 Connecting to ${relayUrls.length} relays...`); for (let i = 0; i < relayUrls.length; i++) { const wsUrl = relayUrls[i].trim(); const relayId = `default-relay-${i}`; try { const success = await this.relayManager.connectToRelay(relayId, wsUrl, ''); if (success) { console.log(`✅ Connected to relay: ${relayId} at ${wsUrl}`); } else { console.warn(`⚠️ Failed to connect to relay: ${relayId}`); } } catch (error) { console.error(`❌ Error connecting to relay ${relayId}:`, error); } } } /** * Connect to relays and wait for at least one handshake to complete. * This guarantees that relay addresses are properly updated in memory. * @param timeoutMs - Timeout for handshake completion (default: 10000) * @returns Promise that resolves when at least one handshake is completed */ public async connectToRelaysAndWaitForHandshake(timeoutMs: number = 10000): Promise { console.log(`🔗 Connecting to relays and waiting for handshake...`); // First connect to all relays await this.connectToRelays(); // Then wait for at least one handshake to complete try { await this.relayManager.waitForHandshake(timeoutMs); console.log(`✅ Successfully connected and received handshake from at least one relay`); } catch (error) { console.error(`❌ Failed to receive handshake within ${timeoutMs}ms:`, error); throw new Error(`No handshake received from any relay within ${timeoutMs}ms`); } } /** * Connect to a specific relay and wait for its handshake to complete. * @param relayId - The relay ID to connect to * @param wsUrl - The WebSocket URL of the relay * @param spAddress - The SP address of the relay * @param timeoutMs - Timeout for handshake completion (default: 10000) * @returns Promise that resolves when the relay's handshake is completed */ public async connectToRelayAndWaitForHandshake( relayId: string, wsUrl: string, spAddress: string, timeoutMs: number = 10000 ): Promise { console.log(`🔗 Connecting to relay ${relayId} and waiting for handshake...`); // Connect to the relay const success = await this.relayManager.connectToRelay(relayId, wsUrl, spAddress); if (!success) { throw new Error(`Failed to connect to relay ${relayId}`); } // Wait for handshake completion try { await this.relayManager.waitForRelayHandshake(relayId, timeoutMs); console.log(`✅ Successfully connected and received handshake from relay ${relayId}`); } catch (error) { console.error(`❌ Failed to receive handshake from relay ${relayId} within ${timeoutMs}ms:`, error); throw new Error(`No handshake received from relay ${relayId} within ${timeoutMs}ms`); } } /** * Verify that at least one relay has completed handshake and has a valid SP address. * @returns True if at least one relay has completed handshake with valid SP address */ public hasValidRelayConnection(): boolean { const connectedRelays = this.relayManager.getConnectedRelays(); const handshakeCompletedRelays = this.relayManager.getHandshakeCompletedRelays(); // Check if we have at least one connected relay with completed handshake for (const relay of connectedRelays) { if (handshakeCompletedRelays.includes(relay.id) && relay.spAddress && relay.spAddress.trim() !== '') { return true; } } return false; } /** * Get the first relay that has completed handshake and has a valid SP address. * @returns The relay connection or null if none found */ public getFirstValidRelay(): { id: string; url: string; spAddress: string } | null { const connectedRelays = this.relayManager.getConnectedRelays(); const handshakeCompletedRelays = this.relayManager.getHandshakeCompletedRelays(); for (const relay of connectedRelays) { if (handshakeCompletedRelays.includes(relay.id) && relay.spAddress && relay.spAddress.trim() !== '') { return { id: relay.id, url: relay.url, spAddress: relay.spAddress }; } } return null; } /** * Get all connected relays from RelayManager. * @returns An array of objects containing relay information. */ public getAllRelays(): { wsurl: string; spAddress: string }[] { const connectedRelays = this.relayManager.getConnectedRelays(); return connectedRelays.map(relay => ({ wsurl: relay.url, spAddress: relay.spAddress })); } /** * Get relay statistics from RelayManager. * @returns Statistics about connected relays */ public getRelayStats(): any { return this.relayManager.getStats(); } public async getSecretForAddress(address: string): Promise { const db = await Database.getInstance(); return await db.getObject('shared_secrets', address); } private async getTokensFromFaucet(): Promise { try { await this.ensureSufficientAmount(); } catch (e) { console.error('Failed to get tokens from relay, check connection'); return; } } public getAllMembers(): Record { return this.membersList; } public getAddressesForMemberId(memberId: string): string[] | null { try { return this.membersList[memberId].sp_addresses; } catch (e) { return null; } } public async checkConnections(members: Member[]): Promise { // Ensure the amount is available before proceeding await this.getTokensFromFaucet(); let unconnectedAddresses = []; 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.push(address); } } } if (unconnectedAddresses && unconnectedAddresses.length != 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 wasm.create_transaction(addresses, 1); } catch (e) { console.error('Failed to connect member:', e); throw e; } } private async ensureSufficientAmount(): Promise { const availableAmt: BigInt = wasm.get_available_amount(); const target: BigInt = DEFAULTAMOUNT * BigInt(10); if (availableAmt < target) { // Ensure we have a relay connection before sending faucet message if (!this.hasValidRelayConnection()) { console.log('No valid relay connection found, attempting to connect...'); await this.connectToRelaysAndWaitForHandshake(); } try { const faucetMsg = wasm.create_faucet_msg(); this.relayManager.sendFaucetMessage(faucetMsg); } catch (e) { throw new Error('Failed to create faucet message'); } await this.waitForAmount(target); } } private async waitForAmount(target: BigInt): Promise { let attempts = 3; while (attempts > 0) { const amount: BigInt = wasm.get_available_amount(); 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'); } 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 createNewDevice() { try { const spAddress = wasm.create_new_device(0, 'signet'); const device = wasm.dump_device(); await this.saveDeviceInDatabase(device); return spAddress; } catch (e) { throw new Error(`Failed to create new device: ${e}`); } } public 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, DEVICE_KEY); } await db.addObject({ storeName: walletStore, object: { device_id: DEVICE_KEY, device_address: wasm.get_address(), created_at: new Date().toISOString(), device }, key: DEVICE_KEY, }); } catch (e) { console.error('Failed to save device to database:', e); } } public getPairingProcessId(): string { try { return wasm.get_pairing_process_id(); } catch (e) { throw new Error(`Failed to get pairing process: ${e}`); } } public async createPairingProcess(userName: string, pairWith: string[]): Promise { if (wasm.is_paired()) { throw new Error('Device already paired'); } const myAddress: string = wasm.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: { "stub_validation_rule": { id: "stub_validation_rule", quorum: 1.0, field_name: "validation_field", rule_type: "custom" as any, role_id: "stub_role", parameters: { min_sig_member: 1.0 }, }, } }, }; try { return this.createProcess( privateData, publicData, roles ); } catch (e) { throw new Error(`Creating process failed:, ${e}`); } } public async createProcess( privateData: Record, publicData: Record, roles: Record, ): Promise { // Ensure we have a valid relay connection with completed handshake if (!this.hasValidRelayConnection()) { console.log('No valid relay connection found, attempting to connect and wait for handshake...'); await this.connectToRelaysAndWaitForHandshake(); } const validRelay = this.getFirstValidRelay(); if (!validRelay) { throw new Error('No valid relay connection found after handshake'); } const relayAddress = validRelay.spAddress; 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 = { ...wasm.encode_json(privateSplitData.jsonCompatibleData), ...wasm.encode_binary(privateSplitData.binaryData) }; const encodedPublicData = { ...wasm.encode_json(publicSplitData.jsonCompatibleData), ...wasm.encode_binary(publicSplitData.binaryData) }; 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({ id: "stub_member", name: "stub_member", public_key: "stub_key", process_id: "stub_process", roles: [], sp_addresses: memberAddresses }); } } } await this.checkConnections([...members]); const result = wasm.create_new_process ( encodedPrivateData, roles, encodedPublicData, relayAddress, feeRate, this.getAllMembers() ); return(result); } async parseCipher(message: string): Promise { const membersList = this.getAllMembers(); const processes = Object.fromEntries(this.getProcesses()); try { const apiReturn = wasm.parse_cipher(message, membersList, processes); await this.handleApiReturn(apiReturn); } catch (e) { console.error(`Failed to parse cipher: ${e}`); } } async parseNewTx(tx: string): Promise { const membersList = this.getAllMembers(); try { // TODO: get the block height somewhere to pass it const parsedTx = wasm.parse_new_tx(tx, 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.error('Failed to parse new tx', e); } } async parseFaucet(faucetResponse: string) { try { console.log('🪙 Parsing faucet response:', faucetResponse); // The faucet response should contain transaction data that updates the device's amount // Parse it similar to how we parse new transactions const membersList = this.getAllMembers(); const parsedTx = wasm.parse_new_tx(faucetResponse, 0, membersList); if (parsedTx) { await this.handleApiReturn(parsedTx); // Update device in database after faucet response const newDevice = this.dumpDeviceFromMemory(); await this.saveDeviceInDatabase(newDevice); console.log('✅ Faucet response processed successfully'); } else { console.warn('⚠️ No transaction data in faucet response'); } } catch (e) { console.error('❌ Failed to parse faucet response:', e); } } // Core protocol method: Create PRD Update async createPrdUpdate(processId: string, stateId: string): Promise { console.log(`📢 Creating PRD update for process ${processId}, state ${stateId}`); try { const process = await this.getProcess(processId); if (!process) { throw new Error('Process not found'); } const result = wasm.create_update_message(process, stateId, this.membersList); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error || 'Unknown error'); throw new Error(errorMessage); } } // Core protocol method: Approve Change (Validate State) async approveChange(processId: string, stateId: string): Promise { console.log(`✅ Approving change for process ${processId}, state ${stateId}`); try { const process = this.processes.get(processId); if (!process) { throw new Error('Process not found'); } const result = wasm.validate_state(process, stateId, this.membersList); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error || 'Unknown error'); throw new Error(errorMessage); } } // Core protocol method: Update Process async updateProcess( process: any, privateData: Record, publicData: Record, roles: Record | null ): Promise { console.log(`🔄 Updating process ${process.states[0]?.state_id || 'unknown'}`); console.log('Private data:', privateData); console.log('Public data:', publicData); console.log('Roles:', roles); try { // Convert data to WASM format const newAttributes = wasm.encode_json(privateData); const newPublicData = wasm.encode_json(publicData); const newRoles = roles || process.states[0]?.roles || {}; // Use WASM function to update process const result = wasm.update_process(process, newAttributes, newRoles, newPublicData, this.membersList); if (result.updated_process) { // Update our cache this.processes.set(result.updated_process.process_id, result.updated_process.current_process); // Save to database await this.saveProcessToDb(result.updated_process.process_id, result.updated_process.current_process); return result; } else { throw new Error('Failed to update process'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error || 'Unknown error'); throw new Error(errorMessage); } } 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 newMyProcesses = new Set(); // MyProcesses automatically contains pairing process newMyProcesses.add(pairingProcessId); for (const [processId, process] of Object.entries(this.processes)) { try { const roles = this.getRoles(process); if (roles && this.rolesContainsMember(roles, pairingProcessId)) { newMyProcesses.add(processId); } } catch (e) { console.error(e); } } return Array.from(newMyProcesses); } catch (e) { console.error("Failed to get processes:", e); return null; } } // Utility method: Get Process async getProcess(processId: string): Promise { // First check in-memory cache const cachedProcess = this.processes.get(processId); if (cachedProcess) { return cachedProcess; } // If not in cache, try to get from database try { const db = await Database.getInstance(); const dbProcess = await db.getObject('processes', processId); if (dbProcess) { // Cache it for future use this.processes.set(processId, dbProcess); return dbProcess; } } catch (error) { console.error('Error getting process from database:', error); } return null; } // Database method: Save Process async saveProcessToDb(processId: string, process: any): Promise { try { const db = await Database.getInstance(); await db.addObject({ storeName: 'processes', object: process, key: processId }); // Update in-memory cache this.processes.set(processId, process); console.log(`💾 Process ${processId} saved to database`); } catch (error) { console.error('Error saving process to database:', error); throw error; } } public getProcesses(): Map { return this.processes; } async getAllProcessesFromDb(): Promise> { try { const db = await Database.getInstance(); const processes = await db.dumpStore('processes'); // Update in-memory cache with all processes for (const [processId, process] of Object.entries(processes)) { this.processes.set(processId, process as any); } return processes; } catch (error) { console.error('Error getting all processes from database:', error); return {}; } } // Utility method: Create a test process async createTestProcess(processId: string): Promise { console.log(`🔧 Creating test process: ${processId}`); try { // Create test data const privateData = wasm.encode_json({ secret: 'initial_secret' }); const publicData = wasm.encode_json({ name: 'Test Process', created: Date.now() }); const roles = { admin: { members: [], validation_rules: [], storages: [] } }; const relayAddress = 'test_relay_address'; const feeRate = 1; // Use WASM to create new process const result = wasm.create_new_process(privateData, roles, publicData, relayAddress, feeRate, this.membersList); if (result.updated_process) { const process = result.updated_process.current_process; this.processes.set(processId, process); // Save to database await this.saveProcessToDb(processId, process); console.log(`✅ Test process created: ${processId}`); return process; } else { throw new Error('Failed to create test process'); } } catch (error) { console.error('Error creating test process:', error); throw error; } } public async getDeviceFromDatabase(): Promise { const db = await Database.getInstance(); const walletStore = 'wallet'; try { const dbRes = await db.getObject(walletStore, DEVICE_KEY); if (dbRes) { return dbRes['device']; } else { return null; } } catch (e) { throw new Error(`Failed to retrieve device from db: ${e}`); } } public async getDeviceMetadata(): Promise<{ device_id: string; device_address: string; created_at: string } | null> { const db = await Database.getInstance(); const walletStore = 'wallet'; try { const dbRes = await db.getObject(walletStore, DEVICE_KEY); if (dbRes) { return { device_id: dbRes['device_id'], device_address: dbRes['device_address'], created_at: dbRes['created_at'] }; } else { return null; } } catch (e) { console.error('Failed to retrieve device metadata from db:', e); return null; } } public async hasDevice(): Promise { const device = await this.getDeviceFromDatabase(); return device !== null; } public async restoreDeviceFromDatabase(device: Device): Promise { try { wasm.restore_device(device); console.log('✅ Device restored in WASM successfully'); } catch (e) { throw new Error(`Failed to restore device in WASM: ${e}`); } } public pairDevice(processId: string, addresses: string[]): void { try { wasm.pair_device(processId, addresses); } catch (e) { throw new Error(`Failed to pair device: ${e}`); } } public isPaired(): boolean { try { return wasm.is_paired(); } catch (error) { console.error('Error checking if paired:', error); throw error; } } public getLastCommitedState(process: Process): ProcessState | null { const index = this.getLastCommitedStateIndex(process); if (index === null) return null; return process.states[index]; } 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] as any).commited_in !== processTip) { return i; } } return null; } 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 rolesContainsUs(roles: Record): boolean { let us; try { us = wasm.get_pairing_process_id(); } catch (e) { throw e; } return this.rolesContainsMember(roles, us); } public rolesContainsMember(roles: Record, pairingProcessId: string): boolean { for (const roleDef of Object.values(roles)) { if (roleDef.members.includes(pairingProcessId)) { return true; } } return false; } // Utility method: Add member to the members list addMember(outpoint: string, member: any) { this.membersList[outpoint] = member; } // Utility method: Get device address getDeviceAddress(): string { try { return wasm.get_address(); } catch (error) { console.error('Error getting device address:', error); throw error; } } // WebSocket message methods using Relay Manager async sendNewTxMessage(message: string) { console.log('📤 Sending NewTx message:', message); this.relayManager.sendNewTxMessage(message); } async sendCommitMessage(message: string) { console.log('📤 Sending Commit message:', message); this.relayManager.sendCommitMessage(message); } async sendCipherMessages(ciphers: string[]) { console.log('📤 Sending Cipher messages:', ciphers.length, 'ciphers'); this.relayManager.sendCipherMessages(ciphers); } // Blob and data storage methods 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}`); } } async getBlobFromDb(hash: string): Promise { const db = await Database.getInstance(); try { return await db.getObject('data', hash); } catch (e) { return null; } } async saveDataToStorage(hash: string, data: Blob, ttl: number | null) { console.log('💾 Saving data to storage:', hash); // TODO: Implement actual storage service // const storages = [STORAGEURL]; // try { // await storeData(storages, hash, data, ttl); // } catch (e) { // console.error(`Failed to store data with hash ${hash}: ${e}`); // } } async saveDiffsToDb(diffs: any[]) { 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 diffs: ${e}`); } } // Utility methods for data conversion hexToBlob(hexString: string): Blob { const uint8Array = this.hexToUInt8Array(hexString); return new Blob([uint8Array], { type: "application/octet-stream" }); } 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 handleApiReturn(apiReturn: ApiReturn) { // Check for errors in the returned objects if (apiReturn.new_tx_to_send && apiReturn.new_tx_to_send.error) { const error = apiReturn.new_tx_to_send.error; const errorMessage = typeof error === 'object' && error !== null ? (error as any).GenericError || JSON.stringify(error) : String(error); throw new Error(`Transaction error: ${errorMessage}`); } if (apiReturn.commit_to_send && apiReturn.commit_to_send.error) { const error = apiReturn.commit_to_send.error; const errorMessage = typeof error === 'object' && error !== null ? (error as any).GenericError || JSON.stringify(error) : String(error); throw new Error(`Commit error: ${errorMessage}`); } if (apiReturn.partial_tx) { try { const res = wasm.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) { await 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; } } } 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)) { if (typeof cipher === 'string') { 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; await this.sendCommitMessage(JSON.stringify(commit)); } if (apiReturn.ciphers_to_send && apiReturn.ciphers_to_send.length != 0) { await this.sendCipherMessages(apiReturn.ciphers_to_send); } } 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 })) }); // Update the processes Map with the new processes for (const [key, value] of Object.entries(processes)) { this.processes.set(key, value); } } catch (e) { throw e; } } private lookForStateId(process: Process, stateId: string): boolean { for (const state of process.states) { if (state.state_id === stateId) { return true; } } return false; } public async requestDataFromPeers(processId: string, stateIds: string[], roles: Record[]) { console.log('Requesting data from peers'); const membersList = this.getAllMembers(); try { const res = wasm.request_data(processId, stateIds, Object.keys(roles), membersList); await this.handleApiReturn(res); } catch (e) { console.error(e); } } 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 (typeof rule === 'object' && rule !== null && 'fields' in rule && Array.isArray(rule.fields)) { 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 = wasm.decrypt_data(keyUIntArray, cipher); if (clear) { // deserialize the result to get the actual data const decoded = wasm.decode_value(clear); return decoded; } else { throw new Error('decrypt_data returned null'); } } catch (e) { console.error(`Failed to decrypt data: ${e}`); } } } return null; } decodeValue(value: number[]): any | null { try { return wasm.decode_value(new Uint8Array(value)); } catch (e) { console.error(`Failed to decode value: ${e}`); return null; } } 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())) { // Note: unpairDevice method doesn't exist in current service, skipping for now return; } // We can update the device with the new addresses wasm.unpair_device(); wasm.pair_device(myPairingProcessId, spAddressList); const newDevice = this.dumpDeviceFromMemory(); await this.saveDeviceInDatabase(newDevice); } } public dumpDeviceFromMemory(): Device { try { return wasm.dump_device(); } catch (e) { throw new Error(`Failed to dump device: ${e}`); } } }