diff --git a/src/.service.bak.ts b/src/.service.bak.ts new file mode 100755 index 0000000..37eca54 --- /dev/null +++ b/src/.service.bak.ts @@ -0,0 +1,1703 @@ +import { INotification } from '~/models/notification.model'; +import { IProcess } from '~/models/process.model'; +import { initWebsocket, sendMessage } from '../websockets'; +import { ApiReturn, Device, HandshakeMessage, Member, MerkleProofResult, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../../pkg/sdk_client'; +import ModalService from './modal.service'; +import Database from './database.service'; +import { navigate } from '../router'; +import { storeData, retrieveData, testData } from './storage.service'; +import { BackUp } from '~/models/backup.model'; +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; + +export const U32_MAX = 4294967295; + +const BASEURL = `http://localhost`; +// const BASEURL = `https://demo.4nkweb.com`; +const BOOTSTRAPURL = [`${BASEURL}:8090`]; +const STORAGEURL = `${BASEURL}:8081` +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 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. + */ + public async connectAllRelays(): Promise { + for (const wsurl of Object.keys(this.relayAddresses)) { + try { + console.log(`Connecting to: ${wsurl}`); + await this.addWebsocketConnection(wsurl); + console.log(`Successfully connected to: ${wsurl}`); + } catch (error) { + console.error(`Failed to connect to ${wsurl}:`, error); + } + } + } + + 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 = []; + 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 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 { + const relayAddress = this.getAllRelays()[0]['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 = { + ...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) + }; + + 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 }); + } + } + } + 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'); + } + + async sendNewTxMessage(message: string) { + sendMessage('NewTx', message); + } + + async sendCommitMessage(message: string) { + sendMessage('Commit', message); + } + + async 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(); + try { + // console.log('parsing new cipher'); + const apiReturn = this.sdkClient.parse_cipher(message, membersList); + 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(tx: string) { + const membersList = this.getAllMembers(); + try { + const parsedTx = this.sdkClient.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.trace(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) { + 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; + } + + // 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; + 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 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 { + 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); + } + } + + 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 process in wasm with 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; + this.sdkClient.set_process_cache(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 = JSON.stringify(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 deviceStr = await this.getDeviceFromDatabase(); + if (!deviceStr) { + console.error('No device loaded'); + return null; + } + + const device: Device = JSON.parse(deviceStr); + + // 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); + 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; + } + + /** + * 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 () => { + await 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 + try { + this.getPairingProcessId(); + } catch (e) { + return null; + } + try { + const processes = await this.getProcesses(); + + const newMyProcesses = new Set(this.myProcesses || []); + for (const [processId, process] of Object.entries(processes)) { + // We use myProcesses attribute to not reevaluate all processes everytime + if (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); + } + + public async generateProcessPdf(processId: string, processState: ProcessState): Promise { + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([595.28, 841.89]); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const drawText = (text: string, x: number, y: number, opts: { size?: number, bold?: boolean } = {}) => { + const fontSize = opts.size || 12; + const usedFont = opts.bold ? fontBold : font; + page.drawText(text, { x, y, size: fontSize, font: usedFont, color: rgb(0, 0, 0) }); + }; + + let y = 800; + + // Header + drawText("Cabinet de Maître Jean Dupont", 50, y, { bold: true }); + drawText("Notaire à Paris", 50, y -= 15); + drawText("12 rue des Archives", 50, y -= 15); + drawText("75003 Paris", 50, y -= 15); + drawText("Téléphone : 01 23 45 67 89", 50, y -= 15); + drawText("Email : contact@notairedupont.fr", 50, y -= 15); + + // Client + y -= 30; + drawText("Client : Mme Sophie Martin", 50, y); + drawText("8 avenue de la Liberté", 50, y -= 15); + drawText("69003 Lyon", 50, y -= 15); + drawText("Email : sophie.martin@email.com", 50, y -= 15); + + // Title + y -= 40; + drawText("Certificat de Validation de Données", 50, y, { size: 14, bold: true }); + + // Certification paragraph + y -= 40; + const certText = `Je soussigné, Maître Jean Dupont, notaire à Paris, certifie par la présente que les données suivantes ont été vérifiées et horodatées à l’aide d’une empreinte cryptographique enregistrée sur la blockchain Bitcoin.`; + page.drawText(certText, { + x: 50, y, + size: 11, + font, + lineHeight: 14, + maxWidth: 500 + }); + + // Dossier number + y -= 60; + drawText("Numéro de dossier : N-2025-0456-PAR", 50, y); + + // Process ID + y -= 40; + drawText(`Identifiant du process: ${processId.split(':')[0]}`, 50, y); + + // Hash table + y -= 40; + + drawText("Nom", 50, y -= 20, { bold: true }); + drawText("Empreinte cryptographique (SHA-256)", 150, y, { bold: true }); + + for (const [label, hash] of Object.entries(processState.pcd_commitment)) { + drawText(label, 50, y -= 18); + drawText(hash, 150, y); + } + + // Add the state id as hash total + drawText('Ensemble', 50, y -= 18); + drawText(processState.state_id, 150, y); + + // Transaction + y -= 40; + drawText("Transaction enregistrée sur la blockchain Bitcoin :", 50, y); + drawText(processState.commited_in, 50, y -= 15); + + // Date & signature + y -= 50; + drawText("Fait à Paris, le 10 juin 2025.", 50, y); + drawText("Généré automatiquement par lecoffre.io", 50, y -= 30); + // drawText("Signature du notaire : ___________________________", 50, y -= 30); + + const pdfBytes = await pdfDoc.save(); + + // Download + const blob = new Blob([pdfBytes], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Certificat_Validation_${processId.slice(0,8)}-${processState.state_id.slice(0,8)}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +} diff --git a/src/index.ts b/src/index.ts index d7bdf1e..e5f03e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ // Main entry point for the SDK Signer server export { Service } from './service'; export { config } from './config'; -export { MessageType } from './models'; +export { MessageType, AnkFlag } from './models'; export { isValid32ByteHex } from './utils'; +export { RelayManager } from './relay-manager'; // Re-export the main server class export { Server } from './simple-server'; \ No newline at end of file diff --git a/src/models.ts b/src/models.ts index de90b03..31f2506 100644 --- a/src/models.ts +++ b/src/models.ts @@ -37,4 +37,24 @@ export enum MessageType { // Account management ADD_DEVICE = 'ADD_DEVICE', DEVICE_ADDED = 'DEVICE_ADDED', +} + +// Re-export AnkFlag from WASM for relay message typing +export { AnkFlag } from '../pkg/sdk_client'; + +// Message priority levels +export enum MessagePriority { + LOW = 0, + NORMAL = 1, + HIGH = 2, + CRITICAL = 3, +} + +// Message delivery status +export enum DeliveryStatus { + PENDING = 'PENDING', + SENT = 'SENT', + DELIVERED = 'DELIVERED', + FAILED = 'FAILED', + RETRY = 'RETRY', } \ No newline at end of file diff --git a/src/relay-example.ts b/src/relay-example.ts new file mode 100644 index 0000000..ed6bd70 --- /dev/null +++ b/src/relay-example.ts @@ -0,0 +1,96 @@ +// Example usage of the RelayManager for outbound relay connections +import { RelayManager } from './relay-manager'; +import { AnkFlag } from './models'; + +async function exampleRelayUsage() { + const relayManager = RelayManager.getInstance(); + + // 1. Connect to relays + console.log('🔗 Connecting to relays...'); + + // Connect to a faucet relay + await relayManager.connectToRelay( + 'faucet-relay-1', + 'wss://faucet-relay.example.com/ws', + 'sp1qfaucetrelayaddress...' + ); + + // Connect to a validator relay + await relayManager.connectToRelay( + 'validator-relay-1', + 'wss://validator-relay.example.com/ws', + 'sp1qvalidatorrelayaddress...' + ); + + // 2. Send messages using protocol-specific methods + console.log('📤 Sending protocol messages...'); + + // Send faucet message + relayManager.sendFaucetMessage('faucet_request_message'); + + // Send new transaction + relayManager.sendNewTxMessage('new_transaction_data'); + + // Send commit message + relayManager.sendCommitMessage('commit_data'); + + // Send cipher messages + relayManager.sendCipherMessages(['cipher1', 'cipher2']); + + // 3. Send messages using generic method with AnkFlag + console.log('📤 Sending generic messages...'); + + relayManager.sendMessage('NewTx' as AnkFlag, 'transaction_data', 'validator-relay-1'); + relayManager.sendMessage('Faucet' as AnkFlag, 'faucet_data', 'faucet-relay-1'); + relayManager.sendMessage('Cipher' as AnkFlag, 'cipher_data'); // Broadcast to all + + // 4. Check relay status + const stats = relayManager.getStats(); + console.log('📊 Relay stats:', stats); + + // 5. Get connected relays + const connectedRelays = relayManager.getConnectedRelays(); + console.log('🔗 Connected relays:', connectedRelays.map(r => r.id)); + + // 6. Send to specific relay + const success = relayManager.sendToRelay('faucet-relay-1', 'Faucet' as AnkFlag, 'specific_message'); + console.log('✅ Message sent to specific relay:', success); + + // 7. Broadcast to all relays + const sentCount = relayManager.broadcastToAllRelays('Sync' as AnkFlag, 'sync_data'); + console.log(`📡 Broadcasted to ${sentCount} relays`); +} + +// Example of how to add relay addresses to the service +async function addRelayAddresses() { + // This would typically be done in the service configuration + // For now, we'll show the structure + + const relayAddresses = { + 'wss://faucet-relay.example.com/ws': 'sp1qfaucetrelayaddress...', + 'wss://validator-relay.example.com/ws': 'sp1qvalidatorrelayaddress...', + 'wss://network-relay.example.com/ws': 'sp1qnetworkrelayaddress...' + }; + + console.log('📝 Relay addresses configured:', Object.keys(relayAddresses)); +} + +// Example of storage API usage (separate from relay system) +async function storageExample() { + // Storage is handled via REST API, not WebSocket + console.log('💾 Storage operations would use REST API endpoints:'); + console.log(' - POST /storage/push'); + console.log(' - GET /storage/retrieve/{hash}'); + console.log(' - DELETE /storage/delete/{hash}'); + console.log(' - GET /storage/list'); +} + +// Run examples +if (require.main === module) { + exampleRelayUsage() + .then(() => addRelayAddresses()) + .then(() => storageExample()) + .catch(console.error); +} + +export { exampleRelayUsage, addRelayAddresses, storageExample }; \ No newline at end of file diff --git a/src/relay-manager.ts b/src/relay-manager.ts new file mode 100644 index 0000000..9b4e14a --- /dev/null +++ b/src/relay-manager.ts @@ -0,0 +1,396 @@ +import WebSocket from 'ws'; +import { AnkFlag } from '../pkg/sdk_client'; + +interface RelayConnection { + id: string; + ws: WebSocket; + url: string; + spAddress: string; + isConnected: boolean; + lastHeartbeat: number; + reconnectAttempts: number; + maxReconnectAttempts: number; +} + +interface QueuedMessage { + id: string; + flag: AnkFlag; + payload: any; + targetRelayId?: string; + timestamp: number; + expiresAt?: number; + retryCount: number; + maxRetries: number; +} + +export class RelayManager { + private static instance: RelayManager; + private relays: Map = new Map(); + private messageQueue: QueuedMessage[] = []; + private processingQueue = false; + private heartbeatInterval: NodeJS.Timeout | null = null; + private queueProcessingInterval: NodeJS.Timeout | null = null; + private reconnectInterval: NodeJS.Timeout | null = null; + + private constructor() { + this.startHeartbeat(); + this.startQueueProcessing(); + this.startReconnectMonitoring(); + } + + static getInstance(): RelayManager { + if (!RelayManager.instance) { + RelayManager.instance = new RelayManager(); + } + return RelayManager.instance; + } + + // Relay Management - Outbound Connections + public async connectToRelay(relayId: string, wsUrl: string, spAddress: string): Promise { + try { + console.log(`🔗 Connecting to relay ${relayId} at ${wsUrl}`); + + const ws = new WebSocket(wsUrl); + + const relay: RelayConnection = { + id: relayId, + ws, + url: wsUrl, + spAddress, + isConnected: false, + lastHeartbeat: Date.now(), + reconnectAttempts: 0, + maxReconnectAttempts: 5 + }; + + // Set up WebSocket event handlers + ws.on('open', () => { + console.log(`✅ Connected to relay ${relayId}`); + relay.isConnected = true; + relay.reconnectAttempts = 0; + relay.lastHeartbeat = Date.now(); + }); + + ws.on('message', (data: WebSocket.Data) => { + try { + const message = JSON.parse(data.toString()); + this.handleRelayMessage(relayId, message); + } catch (error) { + console.error(`❌ Error parsing message from relay ${relayId}:`, error); + } + }); + + ws.on('close', () => { + console.log(`🔌 Disconnected from relay ${relayId}`); + relay.isConnected = false; + this.scheduleReconnect(relayId); + }); + + ws.on('error', (error) => { + console.error(`❌ WebSocket error for relay ${relayId}:`, error); + relay.isConnected = false; + }); + + this.relays.set(relayId, relay); + + // Wait for connection to establish + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 5000); + + ws.once('open', () => { + clearTimeout(timeout); + resolve(true); + }); + }); + + } catch (error) { + console.error(`❌ Failed to connect to relay ${relayId}:`, error); + return false; + } + } + + public disconnectFromRelay(relayId: string): void { + const relay = this.relays.get(relayId); + if (relay) { + relay.isConnected = false; + relay.ws.close(); + this.relays.delete(relayId); + console.log(`🔌 Disconnected from relay ${relayId}`); + } + } + + public getRelayById(relayId: string): RelayConnection | undefined { + return this.relays.get(relayId); + } + + public getConnectedRelays(): RelayConnection[] { + return Array.from(this.relays.values()).filter(relay => relay.isConnected); + } + + // Message Sending Methods using AnkFlag + public sendMessage(flag: AnkFlag, payload: any, targetRelayId?: string): void { + const msg: QueuedMessage = { + id: this.generateMessageId(), + flag, + payload, + targetRelayId, + timestamp: Date.now(), + expiresAt: Date.now() + 30000, // 30 seconds + retryCount: 0, + maxRetries: 3 + }; + + this.queueMessage(msg); + } + + public sendToRelay(relayId: string, flag: AnkFlag, payload: any): boolean { + const relay = this.relays.get(relayId); + if (!relay || !relay.isConnected) { + console.warn(`⚠️ Cannot send to relay ${relayId}: not connected`); + return false; + } + + try { + const message = { + flag, + payload, + messageId: this.generateMessageId() + }; + + relay.ws.send(JSON.stringify(message)); + return true; + } catch (error) { + console.error(`❌ Failed to send message to relay ${relayId}:`, error); + return false; + } + } + + public broadcastToAllRelays(flag: AnkFlag, payload: any): number { + const connectedRelays = this.getConnectedRelays(); + let sentCount = 0; + + for (const relay of connectedRelays) { + if (this.sendToRelay(relay.id, flag, payload)) { + sentCount++; + } + } + + console.log(`📡 Broadcasted to ${sentCount}/${connectedRelays.length} relays`); + return sentCount; + } + + // Protocol-Specific Message Methods + public sendNewTxMessage(message: string, targetRelayId?: string): void { + // Use appropriate AnkFlag for new transaction + this.sendMessage("NewTx" as AnkFlag, message, targetRelayId); + } + + public sendCommitMessage(message: string, targetRelayId?: string): void { + // Use appropriate AnkFlag for commit + this.sendMessage("Commit" as AnkFlag, message, targetRelayId); + } + + public sendCipherMessages(ciphers: string[], targetRelayId?: string): void { + for (const cipher of ciphers) { + // Use appropriate AnkFlag for cipher + this.sendMessage("Cipher" as AnkFlag, cipher, targetRelayId); + } + } + + public sendFaucetMessage(message: string, targetRelayId?: string): void { + // Use appropriate AnkFlag for faucet + this.sendMessage("Faucet" as AnkFlag, message, targetRelayId); + } + + // Message Queue Management + private queueMessage(message: QueuedMessage): void { + this.messageQueue.push(message); + console.log(`📬 Queued message ${message.id} with flag ${message.flag}`); + } + + private async processQueue(): Promise { + if (this.processingQueue || this.messageQueue.length === 0) { + return; + } + + this.processingQueue = true; + + try { + const message = this.messageQueue.shift(); + if (!message) { + return; + } + + // Check if message has expired + if (message.expiresAt && Date.now() > message.expiresAt) { + console.warn(`⏰ Message ${message.id} expired`); + return; + } + + await this.deliverMessage(message); + } finally { + this.processingQueue = false; + } + } + + private async deliverMessage(message: QueuedMessage): Promise { + try { + let delivered = false; + + if (message.targetRelayId) { + // Send to specific relay + delivered = this.sendToRelay(message.targetRelayId, message.flag, message.payload); + } else { + // Broadcast to all connected relays + const sentCount = this.broadcastToAllRelays(message.flag, message.payload); + delivered = sentCount > 0; + } + + if (!delivered) { + throw new Error('No suitable relay available'); + } + + console.log(`✅ Message ${message.id} delivered`); + } catch (error) { + console.error(`❌ Failed to deliver message ${message.id}:`, error); + message.retryCount++; + + if (message.retryCount < message.maxRetries) { + // Re-queue with exponential backoff + setTimeout(() => { + this.queueMessage(message); + }, Math.pow(2, message.retryCount) * 1000); + } else { + console.error(`💀 Message ${message.id} failed after ${message.maxRetries} retries`); + } + } + } + + // Relay Message Handling + private handleRelayMessage(relayId: string, message: any): void { + console.log(`📨 Received message from relay ${relayId}:`, message); + + // Handle different types of relay responses + if (message.flag) { + // Handle protocol-specific responses + this.handleProtocolMessage(relayId, message); + } else if (message.type === 'heartbeat') { + // Update heartbeat + const relay = this.relays.get(relayId); + if (relay) { + relay.lastHeartbeat = Date.now(); + } + } + } + + private handleProtocolMessage(relayId: string, message: any): void { + // Handle different AnkFlag responses + switch (message.flag) { + case "NewTx": + console.log(`📨 NewTx response from relay ${relayId}`); + break; + case "Commit": + console.log(`📨 Commit response from relay ${relayId}`); + break; + case "Cipher": + console.log(`📨 Cipher response from relay ${relayId}`); + break; + case "Faucet": + console.log(`📨 Faucet response from relay ${relayId}`); + break; + default: + console.log(`📨 Unknown flag response from relay ${relayId}:`, message.flag); + } + } + + // Reconnection Logic + private scheduleReconnect(relayId: string): void { + const relay = this.relays.get(relayId); + if (!relay || relay.reconnectAttempts >= relay.maxReconnectAttempts) { + console.log(`💀 Max reconnection attempts reached for relay ${relayId}`); + this.relays.delete(relayId); + return; + } + + const delay = Math.pow(2, relay.reconnectAttempts) * 1000; // Exponential backoff + console.log(`🔄 Scheduling reconnect to relay ${relayId} in ${delay}ms (attempt ${relay.reconnectAttempts + 1})`); + + setTimeout(async () => { + relay.reconnectAttempts++; + await this.connectToRelay(relayId, relay.url, relay.spAddress); + }, delay); + } + + private startReconnectMonitoring(): void { + this.reconnectInterval = setInterval(() => { + // Check for disconnected relays and attempt reconnection + for (const [relayId, relay] of this.relays) { + if (!relay.isConnected && relay.reconnectAttempts < relay.maxReconnectAttempts) { + this.scheduleReconnect(relayId); + } + } + }, 10000); // Check every 10 seconds + } + + // Heartbeat Management + private startHeartbeat(): void { + this.heartbeatInterval = setInterval(() => { + const now = Date.now(); + const heartbeatMessage = { + type: 'heartbeat', + timestamp: now + }; + + for (const [relayId, relay] of this.relays) { + if (relay.isConnected) { + try { + relay.ws.send(JSON.stringify(heartbeatMessage)); + relay.lastHeartbeat = now; + } catch (error) { + console.error(`❌ Heartbeat failed for relay ${relayId}:`, error); + relay.isConnected = false; + } + } + } + }, 30000); // 30 seconds + } + + private startQueueProcessing(): void { + this.queueProcessingInterval = setInterval(() => { + this.processQueue(); + }, 100); // Process queue every 100ms + } + + // Utility Methods + private generateMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + public getStats(): any { + return { + totalRelays: this.relays.size, + connectedRelays: this.getConnectedRelays().length, + queuedMessages: this.messageQueue.length + }; + } + + public shutdown(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + if (this.queueProcessingInterval) { + clearInterval(this.queueProcessingInterval); + } + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + } + + for (const [relayId] of this.relays) { + this.disconnectFromRelay(relayId); + } + + console.log('🛑 Relay manager shutdown complete'); + } +} \ No newline at end of file diff --git a/src/service.ts b/src/service.ts index c6a015d..894b92a 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2,14 +2,20 @@ 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'; + +const DEFAULTAMOUNT = 1000n; 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.initWasm(); } @@ -31,6 +37,323 @@ export class Service { return Service.instance; } + public async connectToRelays(): Promise { + const relays = this.getAllRelays(); + + console.log(`🔗 Connecting to ${relays.length} relays...`); + + for (const relay of relays) { + try { + const success = await this.relayManager.connectToRelay( + relay.spAddress, // Use spAddress as relay ID + relay.wsurl, + relay.spAddress + ); + + if (success) { + console.log(`✅ Connected to relay: ${relay.spAddress}`); + } else { + console.warn(`⚠️ Failed to connect to relay: ${relay.spAddress}`); + } + } catch (error) { + console.error(`❌ Error connecting to relay ${relay.spAddress}:`, error); + } + } + } + + /** + * 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, + })); + } + + /** + * Add a relay address to the RelayManager. + * @param relayId - Unique identifier for the relay + * @param wsUrl - WebSocket URL of the relay + * @param spAddress - Silent Payment address of the relay + */ + public async addRelayAddress(relayId: string, wsUrl: string, spAddress: string): Promise { + try { + const success = await this.relayManager.connectToRelay(relayId, wsUrl, spAddress); + if (success) { + console.log(`✅ Added relay: ${relayId} at ${wsUrl}`); + } else { + console.warn(`⚠️ Failed to add relay: ${relayId}`); + } + return success; + } catch (error) { + console.error(`❌ Error adding relay ${relayId}:`, error); + return false; + } + } + + /** + * Remove a relay from the RelayManager. + * @param relayId - Unique identifier for the relay to remove + */ + public removeRelayAddress(relayId: string): void { + this.relayManager.disconnectFromRelay(relayId); + console.log(`🔌 Removed relay: ${relayId}`); + } + + /** + * 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) { + 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, "1"); + } + await db.addObject({ + storeName: walletStore, + object: { pre_id: '1', device }, + key: null, + }); + } catch (e) { + console.error(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: [ + { + quorum: 1.0, + fields: validation_fields, + min_sig_member: 1.0, + }, + ], + storages: this.storages + }, + }; + 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 { + const relayAddress = this.getAllRelays()[0]['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({ sp_addresses: memberAddresses }); + } + } + } + await this.checkConnections([...members]); + + const result = wasm.create_new_process ( + encodedPrivateData, + roles, + encodedPublicData, + relayAddress, + feeRate, + this.getAllMembers() + ); + + return(result); + } + // Core protocol method: Create PRD Update async createPrdUpdate(processId: string, stateId: string): Promise { console.log(`📢 Creating PRD update for process ${processId}, state ${stateId}`); @@ -234,8 +557,31 @@ export class Service { } } - // Utility method: Check if device is paired - isPaired(): boolean { + public 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}`); + } + } + + 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 isPaired(): boolean { try { return wasm.is_paired(); } catch (error) { @@ -244,29 +590,45 @@ export class Service { } } - // Utility method: Get last committed state - getLastCommitedState(process: any): any { - return process.states.find((s: any) => s.commited_in) || null; + public getLastCommitedState(process: Process): ProcessState | null { + const index = this.getLastCommitedStateIndex(process); + if (index === null) return null; + return process.states[index]; } - // Utility method: Get last committed state index - getLastCommitedStateIndex(process: any): number | null { - const index = process.states.findIndex((s: any) => s.commited_in); - return index >= 0 ? index : null; - } - - // Utility method: Check if roles contain current user - rolesContainsUs(roles: Record): boolean { - try { - // This would need to be implemented based on your user management - // For now, return true for testing - return true; - } catch (error) { - console.error('Error checking roles:', error); - return true; // Fallback to true for testing + 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 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; @@ -282,24 +644,20 @@ export class Service { } } - // WebSocket message methods (stubs for now) + // WebSocket message methods using Relay Manager async sendNewTxMessage(message: string) { console.log('📤 Sending NewTx message:', message); - // TODO: Implement actual WebSocket sending + this.relayManager.sendNewTxMessage(message); } async sendCommitMessage(message: string) { - console.log('�� Sending Commit message:', message); - // TODO: Implement actual WebSocket sending + console.log('📤 Sending Commit message:', message); + this.relayManager.sendCommitMessage(message); } async sendCipherMessages(ciphers: string[]) { console.log('📤 Sending Cipher messages:', ciphers.length, 'ciphers'); - for (let i = 0; i < ciphers.length; i++) { - const cipher = ciphers[i]; - console.log('📤 Sending Cipher:', cipher); - // TODO: Implement actual WebSocket sending - } + this.relayManager.sendCipherMessages(ciphers); } // Blob and data storage methods @@ -368,7 +726,6 @@ export class Service { return uint8Array; } - // Main handleApiReturn method public async handleApiReturn(apiReturn: ApiReturn) { console.log('🔄 Handling API return:', apiReturn); diff --git a/src/simple-server.ts b/src/simple-server.ts index cd23097..a6036d3 100644 --- a/src/simple-server.ts +++ b/src/simple-server.ts @@ -2,7 +2,8 @@ import WebSocket from 'ws'; import { MessageType } from './models'; import { config } from './config'; import { Service } from './service'; -import { ApiReturn } from '../pkg/sdk_client'; +import { ApiReturn, Process } from '../pkg/sdk_client'; +import { EMPTY32BYTES } from './utils'; interface ServerMessageEvent { data: { @@ -58,15 +59,10 @@ class SimpleProcessHandlers { throw new Error('Device not paired'); } - // Create test process if it doesn't exist - let process = await this.service.getProcess(processId); - if (!process) { - process = this.service.createTestProcess(processId); - } - let res: ApiReturn; try { res = await this.service.createPrdUpdate(processId, stateId); + await this.service.handleApiReturn(res); } catch (e) { throw new Error(e as string); } @@ -77,7 +73,7 @@ class SimpleProcessHandlers { }; } catch (e) { const errorMsg = `Failed to notify update for process: ${e}`; - return this.errorResponse(errorMsg, event.clientId, event.data.messageId); + throw new Error(errorMsg); } } @@ -98,17 +94,10 @@ class SimpleProcessHandlers { throw new Error('Device not paired'); } - // Create test process if it doesn't exist - let process = await this.service.getProcess(processId); - if (!process) { - process = this.service.createTestProcess(processId); - } - - // Execute actual protocol logic let res: ApiReturn; try { res = await this.service.approveChange(processId, stateId); - + await this.service.handleApiReturn(res); } catch (e) { throw new Error(e as string); } @@ -120,7 +109,7 @@ class SimpleProcessHandlers { }; } catch (e) { const errorMsg = `Failed to validate process: ${e}`; - return this.errorResponse(errorMsg, event.clientId, event.data.messageId); + throw new Error(errorMsg); } } @@ -129,93 +118,79 @@ class SimpleProcessHandlers { throw new Error('Invalid message type'); } + if (!this.service.isPaired()) { + throw new Error('Device not paired'); + } + try { + // privateFields is only used if newData contains new fields + // roles can be empty meaning that roles from the last commited state are kept const { processId, newData, privateFields, roles, apiKey } = event.data; if (!apiKey || !this.validateApiKey(apiKey)) { throw new Error('Invalid API key'); } - // Check if device is paired - if (!this.service.isPaired()) { - throw new Error('Device not paired'); - } - - // Get or create the process - let process = await this.service.getProcess(processId); + // Check if the new data is already in the process or if it's a new field + const process = await this.service.getProcess(processId); if (!process) { - process = this.service.createTestProcess(processId); + throw new Error('Process not found'); } - - // Get the last committed state - let lastState = this.service.getLastCommitedState(process); + const lastState = this.service.getLastCommitedState(process); if (!lastState) { - const firstState = process.states[0]; - const roles = firstState.roles; - if (this.service.rolesContainsUs(roles)) { - const approveChangeRes = await this.service.approveChange(processId, firstState.state_id); - const prdUpdateRes = await this.service.createPrdUpdate(processId, firstState.state_id); - } else { - if (firstState.validation_tokens.length > 0) { - const res = await this.service.createPrdUpdate(processId, firstState.state_id); - } - } - // Wait a couple seconds - await new Promise(resolve => setTimeout(resolve, 2000)); - const updatedProcess = await this.service.getProcess(processId); - if (!updatedProcess) { - throw new Error('Failed to get updated process'); - } - process = updatedProcess; - lastState = this.service.getLastCommitedState(process); - if (!lastState) { - throw new Error('Process doesn\'t have a committed state yet'); - } + throw new Error('Process doesn\'t have a commited state yet'); } - const lastStateIndex = this.service.getLastCommitedStateIndex(process); if (lastStateIndex === null) { - throw new Error('Process doesn\'t have a committed state yet'); - } + throw new Error('Process doesn\'t have a commited state yet'); + } - // Split data into private and public const privateData: Record = {}; const publicData: Record = {}; for (const field of Object.keys(newData)) { // Public data are carried along each new state + // TODO I hope that at some point we stop doing that + // So the first thing we can do is check if the new data is public data if (lastState.public_data[field]) { + // Add it to public data publicData[field] = newData[field]; continue; } - // If it's not a public data, it may be either a private data update, or a new field + // If it's not a public data, it may be either a private data update, or a new field (public of private) + // Caller gave us a list of new private fields, if we see it here this is a new private field if (privateFields.includes(field)) { + // Add it to private data privateData[field] = newData[field]; continue; } - // Check if field exists in previous states private data + // Now it can be an update of private data or a new public data + // We check that the field exists in previous states private data for (let i = lastStateIndex; i >= 0; i--) { const state = process.states[i]; if (state.pcd_commitment[field]) { + // We don't even check if it's a public field, we would have seen it in the last state + // TODO maybe that's an issue if we remove a public field at some point? That's not explicitly forbidden but not really supported yet privateData[field] = newData[field]; break; + } else { + // This attribute was not modified in that state, we go back to the previous state + continue; } } if (privateData[field]) continue; - // It's a new public field + // We've get back all the way to the first state without seeing it, it's a new public field publicData[field] = newData[field]; } - let res: ApiReturn; - try { - res = await this.service.updateProcess(process, privateData, publicData, roles); - } catch (e) { - throw new Error(e as string); - } + // We'll let the wasm check if roles are consistent + + const res = await this.service.updateProcess(process, privateData, publicData, roles); + await this.service.handleApiReturn(res); return { type: MessageType.PROCESS_UPDATED, @@ -224,11 +199,11 @@ class SimpleProcessHandlers { }; } catch (e) { const errorMsg = `Failed to update process: ${e}`; - return this.errorResponse(errorMsg, event.clientId, event.data.messageId); + throw new Error(errorMsg); } } - async handleMessage(event: ServerMessageEvent): Promise { + async handleMessage(event: ServerMessageEvent): Promise { try { switch (event.data.type) { case MessageType.NOTIFY_UPDATE: @@ -241,8 +216,7 @@ class SimpleProcessHandlers { throw new Error(`Unhandled message type: ${event.data.type}`); } } catch (error) { - const errorMsg = `Error handling message: ${error}`; - return this.errorResponse(errorMsg, event.clientId, event.data.messageId); + return this.errorResponse(error as string, event.clientId, event.data.messageId); } } } @@ -269,6 +243,33 @@ export class Server { // Setup WebSocket handlers this.setupWebSocketHandlers(); + + // Check if we have a device + const device = await service.getDeviceFromDatabase(); + if (!device) { + const spAddress = await service.createNewDevice(); + console.log('🔑 New device created:', spAddress); + } else { + console.log('🔑 Device found, restoring from database...'); + await service.restoreDeviceFromDatabase(device); + console.log('🔑 Device restored successfully'); + } + + // Check if we are paired + if (!service.isPaired()) { + console.log('🔑 Not paired, creating pairing process...'); + try { + const pairingResult = await service.createPairingProcess('', []); + console.log('🔑 Pairing process created successfully'); + } catch (error) { + console.error('❌ Failed to create pairing process:', error); + } + } else { + console.log('🔑 Already paired with id:', service.getPairingProcessId()); + } + + // Connect to relays + await service.connectToRelays(); console.log(`✅ Simple server running on port ${this.wss.options.port}`); console.log('📋 Supported operations: UPDATE_PROCESS, NOTIFY_UPDATE, VALIDATE_STATE'); @@ -305,8 +306,7 @@ export class Server { }; const response = await this.handlers.handleMessage(serverEvent); - this.sendToClient(ws, response); - + this.sendToClient(ws, response); } catch (error) { console.error(`❌ Error handling message from ${clientId}:`, error); this.sendToClient(ws, { diff --git a/src/utils.ts b/src/utils.ts index cc243bb..2fb30e5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ // Server-specific utility functions +export const EMPTY32BYTES = String('').padStart(64, '0'); export function isValid32ByteHex(value: string): boolean { // Check if the value is a valid 32-byte hex string (64 characters)