ihm_client/src/services/service.ts

1702 lines
54 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `https://demo.4nkweb.com`;
const BOOTSTRAPURL = [`${BASEURL}/ws/`];
const STORAGEURL = `${BASEURL}/storage`
const DEFAULTAMOUNT = 1000n;
const EMPTY32BYTES = String('').padStart(64, '0');
export default class Services {
private static initializing: Promise<Services> | null = null;
private static instance: Services;
private processId: string | null = null;
private stateId: string | null = null;
private sdkClient: any;
private processesCache: Record<string, Process> = {};
private myProcesses: Set<string> = 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<string, Member> = {};
// Private constructor to prevent direct instantiation from outside
private constructor() {}
// Method to access the singleton instance of Services
public static async getInstance(): Promise<Services> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | null> {
const db = await Database.getInstance();
return await db.getObject('shared_secrets', address);
}
public async getAllSecrets(): Promise<SecretsStore> {
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<Record<string, UserDiff>> {
const db = await Database.getInstance();
return await db.dumpStore('diffs');
}
public async getDiffByValue(value: string): Promise<UserDiff | null> {
const db = await Database.getInstance();
const store = 'diffs';
const res = await db.getObject(store, value);
return res;
}
private async getTokensFromFaucet(): Promise<void> {
try {
await this.ensureSufficientAmount();
} catch (e) {
console.error('Failed to get tokens from relay, check connection');
return;
}
}
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 this.sdkClient.create_transaction(addresses, 1);
} catch (e) {
console.error('Failed to connect member:', e);
throw e;
}
}
private async ensureSufficientAmount(): Promise<void> {
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<BigInt> {
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<ApiReturn> {
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<string, RoleDefinition> = {
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<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 createProcess(
privateData: Record<string, any>,
publicData: Record<string, any>,
roles: Record<string, RoleDefinition>,
): Promise<ApiReturn> {
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<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({ 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<string, any>, publicData: Record<string, any>, roles: Record<string, RoleDefinition> | null): Promise<ApiReturn> {
// 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<Member> = 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<ApiReturn> {
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<ApiReturn> {
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<ApiReturn> {
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<ApiReturn> {
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<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())) {
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(): string {
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: any): Promise<void> {
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<string | null> {
const db = await Database.getInstance();
const walletStore = 'wallet';
try {
const dbRes = await db.getObject(walletStore, '1');
if (dbRes) {
const wallet = dbRes['device'];
return wallet;
} else {
return null;
}
} catch (e) {
throw new Error(`Failed to retrieve device from db: ${e}`);
}
}
async getMemberFromDevice(): Promise<string[] | null> {
try {
const device = await this.getDeviceFromDatabase();
if (device) {
const parsed: Device = JSON.parse(device);
const pairedMember = parsed['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<string, RoleDefinition>): boolean {
let us;
try {
us = this.sdkClient.get_pairing_process_id();
} catch (e) {
throw e;
}
return this.rolesContainsMember(roles, us);
}
rolesContainsMember(roles: Record<string, RoleDefinition>, 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;
}
restoreDevice(device: string) {
try {
this.sdkClient.restore_device(device);
const spAddress = this.sdkClient.get_address();
} catch (e) {
console.error(e);
}
}
private async removeProcess(processId: string): Promise<void> {
const db = await Database.getInstance();
const storeName = 'processes';
try {
await db.deleteObject(storeName, processId);
} catch (e) {
console.error(e);
}
}
public async batchSaveProcessesToDb(processes: Record<string, Process>) {
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<Blob | null> {
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<any | null> {
const storages = [STORAGEURL];
return await retrieveData(storages, hash);
}
public async testDataInStorage(hash: string): Promise<Record<string, boolean | null> | 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<Process | null> {
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<Record<string, Process>> {
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<string, Process>) {
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<string, Process> = 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<string, string> = 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<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 (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<void> {
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<BackUp | null> {
// 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<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']);
// 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<string, Member> {
return Object.fromEntries(
Object.entries(this.membersList).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
);
}
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 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<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 getPublicData(process: Process): Record<string, any> | 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<string[] | null> {
// 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<string>(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<string, RoleDefinition>[]) {
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<string> {
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<string, RoleDefinition>): 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<ApiReturn> {
const publicData = {
'memberPublicName': newName
};
return await this.updateProcess(process, {}, publicData, null);
}
public async generateProcessPdf(processId: string, processState: ProcessState): Promise<void> {
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 à laide dune 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);
}
}