@cronstamp/clientlib
Version:
Client library for cronstamp, a blockchain-based document timestamping and verification service.
164 lines • 8.73 kB
JavaScript
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