1300 lines
42 KiB
TypeScript
1300 lines
42 KiB
TypeScript
// 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<string, any> = 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<string, Process> = {};
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
const db = await Database.getInstance();
|
|
return await db.getObject('shared_secrets', address);
|
|
}
|
|
|
|
private async getTokensFromFaucet(): Promise<void> {
|
|
try {
|
|
await this.ensureSufficientAmount();
|
|
} catch (e) {
|
|
console.error('Failed to get tokens from relay, check connection');
|
|
return;
|
|
}
|
|
}
|
|
|
|
public getAllMembers(): Record<string, Member> {
|
|
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<void> {
|
|
// 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<ApiReturn> {
|
|
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<void> {
|
|
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<BigInt> {
|
|
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<string, any>) {
|
|
const jsonCompatibleData: Record<string, any> = {};
|
|
const binaryData: Record<string, { type: string; data: Uint8Array }> = {};
|
|
|
|
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<void> {
|
|
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<ApiReturn> {
|
|
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<string, RoleDefinition> = {
|
|
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<string, any>,
|
|
publicData: Record<string, any>,
|
|
roles: Record<string, RoleDefinition>,
|
|
): Promise<ApiReturn> {
|
|
// 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<Member> = 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<void> {
|
|
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<void> {
|
|
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<ApiReturn> {
|
|
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<ApiReturn> {
|
|
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<string, any>,
|
|
publicData: Record<string, any>,
|
|
roles: Record<string, any> | null
|
|
): Promise<ApiReturn> {
|
|
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<string[] | null> {
|
|
// 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<string>();
|
|
// 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<any | null> {
|
|
// 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<void> {
|
|
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<string, Process> {
|
|
return this.processes;
|
|
}
|
|
|
|
async getAllProcessesFromDb(): Promise<Record<string, any>> {
|
|
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<any> {
|
|
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<Device | null> {
|
|
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<boolean> {
|
|
const device = await this.getDeviceFromDatabase();
|
|
return device !== null;
|
|
}
|
|
|
|
public async restoreDeviceFromDatabase(device: Device): Promise<void> {
|
|
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<string, RoleDefinition> | 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<string, RoleDefinition>): boolean {
|
|
let us;
|
|
try {
|
|
us = wasm.get_pairing_process_id();
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
|
|
return this.rolesContainsMember(roles, us);
|
|
}
|
|
|
|
public rolesContainsMember(roles: Record<string, RoleDefinition>, 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<Blob | null> {
|
|
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<string, Process>) {
|
|
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<string, RoleDefinition>[]) {
|
|
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<any | null> {
|
|
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<void> {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|