UNPKG

@cronstamp/clientlib

Version:

Client library for cronstamp, a blockchain-based document timestamping and verification service.

164 lines 8.73 kB
import { createDefaultRpcTransport, createSolanaRpcFromTransport, signature } from '@solana/web3.js'; import BaseConverter from 'base-x'; import { AdditionalDataType, ErrorTypes, MessageType, ProcessError } from '../lifecycle/manager.js'; import { BlockchainBase } from './blockchainBase.js'; import { ExponentialBackoff, sleep_ms } from '../util/time.js'; // base-x uses a different method than standard hex or base64, with extra padding in some cases. const R_B58_DICT = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const base58 = BaseConverter(R_B58_DICT); // Retries per server const MAX_ATTEMPTS_PER_SERVER = 2; export class Solana extends BlockchainBase { getName() { return 'solana'; } getDisplayName() { return 'Solana'; } // Since rpcClients store the currently used url, but do not keep an active connection, we store them for each unique configuration rpcClientList = new Map(); /** * returns the memo text from a solana transaction with the given * transactionId or null if no memo is present on the transaction * @param transaction * @private */ static getTransactionMemo(transaction) { // find memo instruction: // 1. get programIdIndex for memo program function getKeyByValue(map, value) { for (let [key, val] of map.entries()) { if (val.toString() === value) { return key; } } return null; } const memoProgramIdIndex = getKeyByValue(transaction.transaction.message.accountKeys, 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'); if (memoProgramIdIndex === null) { return null; } // 2. get instruction of memo program const memoInstruction = transaction.transaction.message.instructions.find((instruction) => { return instruction.programIdIndex === memoProgramIdIndex; }); // 3. decode memo text from memo instruction data const memoTextBytes = base58.decode(memoInstruction.data); return new TextDecoder().decode(memoTextBytes); } async verifyRootHash(rootHash, transactionTimestamp, transactionHash, verificationProcess) { // retry using exponential backoff for at most 15 api requests => about 4 minutes max total wait time const exponentialBackoff = new ExponentialBackoff(15, 1.35); // check for transactions in the future if (this.getTransactionUnixTimestamp(transactionTimestamp) > Date.now()) { this.log.warn(`The solana transaction timestamp ${transactionTimestamp} ` + `(${this.getTransactionDate(transactionTimestamp).toISOString()}) for the transaction ` + `${transactionHash} is in the future. This could be caused by an inaccurate system clock.`, MessageType.PROGRESS); } let solanaResult = undefined; do { await exponentialBackoff.waitForNextAttempt(); solanaResult = await this.getRpcClient(verificationProcess.config.BLOCKCHAIN_SOLANA_SERVERS) .getTransaction(signature(transactionHash), { commitment: 'finalized', maxSupportedTransactionVersion: 0 }) .send(); // use a valid transaction result immediately if (solanaResult !== null) { break; } // a null result for getTransaction can be because the transaction has not fully propagated to all nodes, so we retry for a fixed amount of time verificationProcess.sendMessage(`Got "null" result for transaction from solana rpc api. Waiting for additional ${exponentialBackoff.getDelayForNextAttempt()} seconds` + `, for the transaction to propagate to the rpc node. Total time waited: ${Math.round((Date.now() - exponentialBackoff.startTime) / 1000)} `, MessageType.PROGRESS); } while (Date.now() < this.getTransactionUnixTimestamp(transactionTimestamp) + exponentialBackoff.getTotalTime()); if (!solanaResult) { throw new ProcessError(`The solana transaction ${transactionHash} does not exist.`, ErrorTypes.DOCUMENT_ALTERED); } verificationProcess.sendMessage('Got result from solana network: ', MessageType.PROGRESS, AdditionalDataType.HTTP_RESPONSE_JSON, solanaResult); let blockchainRootHash = Solana.getTransactionMemo(solanaResult); if (blockchainRootHash === null) { throw new ProcessError(`No memoData with root hash present on transaction ${transactionHash}`, ErrorTypes.DOCUMENT_ALTERED); } let solanaTime = Number(solanaResult.blockTime); let date = this.getTransactionDate(solanaTime); if (solanaTime !== transactionTimestamp) { return verificationProcess.sendError('The certificate time does not match the time in the blockchain! ' + 'Cert: ' + this.getTransactionDate(transactionTimestamp).toISOString() + ' Blockchain: ' + date.toISOString(), AdditionalDataType.TIMESTAMP, solanaTime); } if (blockchainRootHash !== rootHash) { return verificationProcess.sendError('The certificate is invalid! The hash in the blockchain and certificate do not match. Cert: ' + rootHash + ' Blockchain: ' + blockchainRootHash + ' This means the document did not exist at time ' + date.toISOString(), AdditionalDataType.HASH, blockchainRootHash); } return verificationProcess.sendMessage('The certificate is valid! The hash in the blockchain and certificate match. This means the document existed at time ' + date.toISOString(), MessageType.SUCCESS); } getRpcClient(rpcServers) { const log = this.log; const configKey = JSON.stringify(rpcServers); // Failover transport starting with a random transport if (this.rpcClientList.has(configKey)) { this.log.debug('Using existing config: ' + configKey); return this.rpcClientList.get(configKey); } // Create an HTTP transport for each RPC server. const transports = rpcServers.map((rpcServer) => createDefaultRpcTransport({ url: rpcServer })); let currentTransport = Math.floor(Math.random() * transports.length); log.debug(`Create new solana connection config with the initial server id ${currentTransport}`); // Retrying transport that will retry up to MAX_ATTEMPTS times before failing. async function retryingTransport(baseTransport, ...args) { let requestError; for (let attempt = 1; attempt <= MAX_ATTEMPTS_PER_SERVER; attempt++) { try { return await baseTransport(...args); } catch (err) { log.warn(`Failed attempt ${attempt} for solana server id "${currentTransport}": "`, err); requestError = err; // Only sleep if we have more attempts remaining. if (attempt < MAX_ATTEMPTS_PER_SERVER) { // Exponential backoff with a maximum of 1.5 seconds. await sleep_ms(Math.min(200 * Math.pow(2, attempt), 1500)); } } } throw requestError; } async function failoverTransport(...args) { const startingTransport = currentTransport; let requestError; do { const transport = transports[currentTransport]; // on failure switch to next transport try { return await retryingTransport(transport, ...args); } catch (err) { requestError = err; currentTransport = (currentTransport + 1) % transports.length; log.warn(`Switching to solana RPC server id "${currentTransport}}"`); } } while (startingTransport != currentTransport); throw requestError; } let rpcClient = createSolanaRpcFromTransport(failoverTransport); // store config this.rpcClientList.set(configKey, rpcClient); // Create an RPC client using the failover transport. return rpcClient; } getTransactionDate(blockTimestamp) { return new Date(this.getTransactionUnixTimestamp(blockTimestamp)); } getTransactionUnixTimestamp(blockTimestamp) { return blockTimestamp * 1000; } } //# sourceMappingURL=solana.js.map