UNPKG

@cheqd/sdk

Version:

A TypeScript SDK built with CosmJS to interact with the cheqd network ledger

547 lines 27.4 kB
import { isOfflineDirectSigner, encodePubkey, makeSignDoc, } from '@cosmjs/proto-signing'; import { SigningStargateClient, calculateFee, createProtobufRpcClient, TimeoutError, } from '@cosmjs/stargate'; import { connectComet } from '@cosmjs/tendermint-rpc'; import { createDefaultCheqdRegistry } from './registry.js'; import { MsgCreateDidDocPayload, MsgUpdateDidDocPayload, MsgDeactivateDidDocPayload, } from '@cheqd/ts-proto/cheqd/did/v2/index.js'; import { VerificationMethods } from './types.js'; import { base64ToBytes, EdDSASigner, hexToBytes, ES256Signer, ES256KSigner } from 'did-jwt'; import { assert, assertDefined, sleep } from '@cosmjs/utils'; import { encodeSecp256k1Pubkey } from '@cosmjs/amino'; import { Int53, Uint53 } from '@cosmjs/math'; import { fromBase64 } from '@cosmjs/encoding'; import { AuthInfo, Fee, Tx, TxBody, TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx.js'; import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing.js'; import { ServiceClientImpl, SimulateRequest, } from 'cosmjs-types/cosmos/tx/v1beta1/service.js'; import { CheqdQuerier } from './querier.js'; /** * Calculates the transaction fee for DID operations using gas limit and gas price. * * @param gasLimit - Maximum amount of gas units that can be consumed by the transaction * @param gasPrice - Price per gas unit, either as a string or GasPrice object * @returns DidStdFee object containing the calculated fee structure */ export function calculateDidFee(gasLimit, gasPrice) { return calculateFee(gasLimit, gasPrice); } /** * Creates SignerInfo objects for transaction authentication from signer data. * Each signer info contains the public key, sequence number, and signing mode. * * @param signers - Array of signer objects containing public keys and sequence numbers * @param signMode - Signing mode to use (e.g., SIGN_MODE_DIRECT) * @returns Array of SignerInfo objects for transaction authentication */ export function makeSignerInfos(signers, signMode) { return signers.map(({ pubkey, sequence }) => ({ publicKey: pubkey, modeInfo: { single: { mode: signMode }, }, sequence: BigInt(sequence), })); } /** * Creates encoded AuthInfo bytes for DID transactions with fee payer support. * The AuthInfo contains signer information, fee details, and gas limit. * * @param signers - Array of signer objects with public keys and sequence numbers * @param feeAmount - Array of coins representing the transaction fee * @param gasLimit - Maximum gas units that can be consumed * @param feePayer - Address of the account paying the transaction fees * @param signMode - Signing mode to use, defaults to SIGN_MODE_DIRECT * @returns Encoded AuthInfo as Uint8Array for transaction construction */ export function makeDidAuthInfoBytes(signers, feeAmount, gasLimit, feePayer, signMode = SignMode.SIGN_MODE_DIRECT) { const authInfo = { signerInfos: makeSignerInfos(signers, signMode), fee: { amount: [...feeAmount], gasLimit: gasLimit, payer: feePayer, }, }; return AuthInfo.encode(AuthInfo.fromPartial(authInfo)).finish(); } /** * Extended SigningStargateClient specifically designed for Cheqd blockchain operations. * Provides enhanced transaction signing, broadcasting, and DID-specific functionality * with support for custom fee payers and advanced retry mechanisms. */ export class CheqdSigningStargateClient extends SigningStargateClient { /** Map of DID signing algorithms for different verification method types */ didSigners = {}; /** Gas price configuration for transaction fee calculation */ _gasPrice; /** Offline signer instance for transaction signing */ _signer; /** RPC endpoint URL for blockchain communication */ endpoint; /** Maximum gas limit allowed for transactions */ static maxGasLimit = Number.MAX_SAFE_INTEGER; /** * Creates a new CheqdSigningStargateClient by establishing a connection to the specified endpoint. * This is the primary factory method for creating a signing client instance. * * @param endpoint - RPC endpoint URL or HttpEndpoint object to connect to * @param signer - Offline signer for transaction signing * @param options - Additional client configuration options including registry and gas price * @returns Promise resolving to a connected CheqdSigningStargateClient instance */ static async connectWithSigner(endpoint, signer, options) { const cometClient = await connectComet(endpoint); return new CheqdSigningStargateClient(cometClient, signer, { registry: options?.registry ? options.registry : createDefaultCheqdRegistry(), endpoint: options?.endpoint ? typeof options.endpoint === 'string' ? options.endpoint : options.endpoint.url : undefined, ...options, }); } /** * Constructs a new CheqdSigningStargateClient instance with the provided Comet client and signer. * * @param cometClient - Comet client for blockchain communication * @param signer - Offline signer for transaction signing * @param options - Additional configuration options including registry, gas price, and endpoint */ constructor(cometClient, signer, options = {}) { super(cometClient, signer, options); this._signer = signer; this._gasPrice = options.gasPrice; this.endpoint = options.endpoint; } /** * Signs and broadcasts a transaction to the blockchain network. * Supports automatic fee calculation and custom fee payer functionality. * * @param signerAddress - Address of the account signing the transaction * @param messages - Array of messages to include in the transaction * @param fee - Fee configuration: 'auto' for automatic calculation, number for multiplier, or DidStdFee object * @param memo - Optional transaction memo string * @returns Promise resolving to DeliverTxResponse with transaction results * @throws Error if gas price is not set when using automatic fee calculation */ async signAndBroadcast(signerAddress, messages, fee, memo = '') { let usedFee; if (fee == 'auto' || typeof fee === 'number') { assertDefined(this._gasPrice, 'Gas price must be set in the client options when auto gas is used.'); const gasEstimation = await this.simulate(signerAddress, messages, memo); const multiplier = typeof fee === 'number' ? fee : 1.3; usedFee = calculateDidFee(Math.round(gasEstimation * multiplier), this._gasPrice); usedFee.payer = signerAddress; } else { usedFee = fee; assertDefined(usedFee.payer, 'Payer address must be set when fee is not auto.'); signerAddress = usedFee.payer; } const txRaw = await this.sign(signerAddress, messages, usedFee, memo); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } /** * Signs a transaction without broadcasting it to the network. * Creates a signed transaction that can be broadcast later. * * @param signerAddress - Address of the account signing the transaction * @param messages - Array of messages to include in the transaction * @param fee - Fee configuration for the transaction * @param memo - Transaction memo string * @param explicitSignerData - Optional explicit signer data to avoid querying the chain * @returns Promise resolving to TxRaw containing the signed transaction */ async sign(signerAddress, messages, fee, memo, explicitSignerData) { let signerData; if (explicitSignerData) { signerData = explicitSignerData; } else { const { accountNumber, sequence } = await this.getSequence(signerAddress); const chainId = await this.getChainId(); signerData = { accountNumber: accountNumber, sequence: sequence, chainId: chainId, }; } return this._signDirect(signerAddress, messages, fee, memo, signerData); } /** * Internal method for direct signing of transactions using SIGN_MODE_DIRECT. * Handles the low-level transaction construction and signing process. * * @param signerAddress - Address of the account signing the transaction * @param messages - Array of messages to include in the transaction * @param fee - Fee configuration for the transaction * @param memo - Transaction memo string * @param signerData - Account data including number, sequence, and chain ID * @returns Promise resolving to TxRaw containing the signed transaction * @private */ async _signDirect(signerAddress, messages, fee, memo, { accountNumber, sequence, chainId }) { assert(isOfflineDirectSigner(this._signer)); const accountFromSigner = (await this._signer.getAccounts()).find((account) => account.address === signerAddress); if (!accountFromSigner) { throw new Error('Failed to retrieve account from signer'); } const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); const txBodyEncodeObject = { typeUrl: '/cosmos.tx.v1beta1.TxBody', value: { messages: messages, memo: memo, }, }; const txBodyBytes = this.registry.encode(txBodyEncodeObject); const gasLimit = Int53.fromString(fee.gas).toNumber(); const authInfoBytes = makeDidAuthInfoBytes([{ pubkey, sequence }], fee.amount, BigInt(gasLimit), fee.payer); const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, chainId, accountNumber); const { signature, signed } = await this._signer.signDirect(signerAddress, signDoc); return TxRaw.fromPartial({ bodyBytes: signed.bodyBytes, authInfoBytes: signed.authInfoBytes, signatures: [fromBase64(signature.signature)], }); } /** * Broadcasts a signed transaction to the network and monitors its inclusion in a block, * with support for retrying on failure and graceful timeout handling. * * ## Optimizations over base implementation: * - Implements a retry policy (`maxRetries`, default: 3) to handle transient broadcast errors. * - Prevents double spend by reusing the exact same signed transaction (immutable `Uint8Array`). * - Tracks and returns the last known transaction hash (`txId`), even in the case of timeout or failure. * - Throws a `TimeoutError` if the transaction is not found within `timeoutMs`, attaching the `txHash` if known. * - Polling frequency and timeout are customizable via `pollIntervalMs` and `timeoutMs` parameters. * * @param tx - The signed transaction bytes to broadcast. * @param timeoutMs - Maximum duration (in milliseconds) to wait for block inclusion. Defaults to 60,000 ms. * @param pollIntervalMs - Polling interval (in milliseconds) when checking for transaction inclusion. Defaults to 3,000 ms. * @param maxRetries - Maximum number of times to retry `broadcastTxSync` on failure. Defaults to 3. * * @returns A `Promise` that resolves to `DeliverTxResponse` upon successful inclusion in a block. * @throws `BroadcastTxError` if the transaction is rejected by the node during CheckTx. * @throws `TimeoutError` if the transaction is not found on-chain within the timeout window. Includes `txHash` if available. */ async broadcastTx(tx, timeoutMs = 60_000, pollIntervalMs = 3_000, maxRetries = 3) { // define polling callback const pollForTx = async (txId, startTime) => { // define immutable timeout const timedOut = Date.now() - startTime > timeoutMs; // check if timed out if (timedOut) { // if so, throw timeout error with txId (or transaction hash) throw new TimeoutError(`Transaction with ID ${txId} was submitted but was not yet found on the chain. Waited ${timeoutMs / 1000} seconds.`, txId); } // otherwise, poll for tx await sleep(pollIntervalMs); // query for tx const result = await this.getTx(txId); // return result if found, otherwise poll again return result ? { code: result.code, height: result.height, txIndex: result.txIndex, events: result.events, rawLog: result.rawLog, transactionHash: txId, msgResponses: result.msgResponses, gasUsed: result.gasUsed, gasWanted: result.gasWanted, } : pollForTx(txId, startTime); }; // define immutable array of errors const errors = []; // define last known transaction hash let lastKnownTxHash; // attempt to broadcast tx until maxRetries or tx is found for (const attempt of Array.from({ length: maxRetries }, (_, i) => i + 1)) { try { // broadcast tx const txId = await this.broadcastTxSync(tx); // set last known transaction hash lastKnownTxHash = txId; // recompute start time const startTime = Date.now(); // poll for tx const result = await pollForTx(txId, startTime); // if successful, return result return result; } catch (error) { // if error, push to errors array errors.push(error); // define last error const lastError = error; // if error is not a TimeoutError, throw it if (lastError.name !== 'TimeoutError') throw lastError; // define whether final attempt const isFinalAttempt = attempt === maxRetries; // define enriched error, attaching last known transaction hash const enrichedError = isFinalAttempt && lastKnownTxHash && lastError.name === 'TimeoutError' ? new TimeoutError(`Transaction broadcast failed after ${maxRetries} attempts. Transaction hash: ${lastKnownTxHash}`, lastKnownTxHash) : lastError; // if final attempt and error does not have txHash, throw the last error if (isFinalAttempt) throw lastError; // otherwise, brief backoff before retrying await sleep(1000); } } // should not reach here throw new TimeoutError(`Broadcast failed unexpectedly. Last known transaction hash: ${lastKnownTxHash ?? 'unknown'}`, lastKnownTxHash || 'unknown'); } async simulate(signerAddress, messages, memo) { if (!this.endpoint) { throw new Error('querier: endpoint is not set'); } const querier = await CheqdQuerier.connect(this.endpoint); const anyMsgs = messages.map((msg) => this.registry.encodeAsAny(msg)); const accountFromSigner = (await this._signer.getAccounts()).find((account) => account.address === signerAddress); if (!accountFromSigner) { throw new Error('Failed to retrieve account from signer'); } const pubkey = encodeSecp256k1Pubkey(accountFromSigner.pubkey); const { sequence } = await this.getSequence(signerAddress); const gasLimit = this.endpoint.includes('localhost') ? CheqdSigningStargateClient.maxGasLimit : (await CheqdQuerier.getConsensusParameters(this.endpoint)).block.maxGas; const { gasInfo } = await (await this.constructSimulateExtension(querier)).tx.simulate(anyMsgs, memo, pubkey, signerAddress, sequence, gasLimit); assertDefined(gasInfo); return Uint53.fromString(gasInfo.gasUsed.toString()).toNumber(); } async constructSimulateExtension(querier) { // setup rpc client const rpc = createProtobufRpcClient(querier); // setup query tx query service const queryService = new ServiceClientImpl(rpc); // setup + return tx extension return { tx: { getTx: async (txId) => { // construct request const request = { hash: txId }; // query + return tx return await queryService.GetTx(request); }, simulate: async (messages, memo, signer, signerAddress, sequence, gasLimit) => { // encode public key const publicKey = encodePubkey(signer); // construct max gas limit const maxGasLimit = Int53.fromString(gasLimit.toString()).toNumber(); // construct unsigned tx const tx = Tx.fromPartial({ body: TxBody.fromPartial({ messages: Array.from(messages), memo, }), authInfo: AuthInfo.fromPartial({ fee: Fee.fromPartial({ amount: [], gasLimit: BigInt(maxGasLimit), payer: signerAddress, }), signerInfos: [ { publicKey, modeInfo: { single: { mode: SignMode.SIGN_MODE_DIRECT }, }, sequence: BigInt(sequence), }, ], }), signatures: [new Uint8Array()], }); // construct request const request = SimulateRequest.fromPartial({ txBytes: Tx.encode(tx).finish(), }); // query + return simulation response return await queryService.Simulate(request); }, }, }; } /** * Batches multiple messages into optimal groups based on gas limits. * Simulates each message individually and groups them to maximize throughput * while staying within gas constraints. * * @param messages - Array of messages to batch * @param signerAddress - Address of the account that will sign the transactions * @param memo - Optional transaction memo * @param maxGasLimit - Maximum gas limit per batch, defaults to 30,000,000 * @returns Promise resolving to MessageBatch with grouped messages and gas estimates */ async batchMessages(messages, signerAddress, memo, maxGasLimit = 30000000 // default gas limit, use consensus params if available ) { // simulate const gasEstimates = await Promise.all(messages.map(async (message) => this.simulate(signerAddress, [message], memo))); // batch messages const { batches, gasPerBatch, currentBatch, currentBatchGas } = gasEstimates.reduce((acc, gasUsed, index) => { // finalise current batch, if limit is surpassed if (acc.currentBatchGas + gasUsed > maxGasLimit) { return { batches: [...acc.batches, acc.currentBatch], gasPerBatch: [...acc.gasPerBatch, acc.currentBatchGas], currentBatch: [messages[index]], currentBatchGas: gasUsed, }; } // otherwise, add to current batch return { batches: acc.batches, gasPerBatch: acc.gasPerBatch, currentBatch: [...acc.currentBatch, messages[index]], currentBatchGas: acc.currentBatchGas + gasUsed, }; }, { batches: [], gasPerBatch: [], currentBatch: [], currentBatchGas: 0, }); // push final batch to batches, if not empty + return return currentBatch.length > 0 ? { batches: [...batches, currentBatch], gas: [...gasPerBatch, currentBatchGas], } : { batches, gas: gasPerBatch, }; } /** * Validates and initializes DID signers for the provided verification methods. * Ensures that all verification method types are supported and assigns appropriate signers. * * @param verificationMethods - Array of verification methods to validate * @returns Promise resolving to the configured signer algorithm map * @throws Error if no verification methods are provided or unsupported types are found */ async checkDidSigners(verificationMethods = []) { if (verificationMethods.length === 0) { throw new Error('No verification methods provided'); } verificationMethods.forEach((verificationMethod) => { if (!Object.values(VerificationMethods).includes(verificationMethod.verificationMethodType ?? '')) { throw new Error(`Unsupported verification method type: ${verificationMethod.verificationMethodType}`); } if (!this.didSigners[verificationMethod.verificationMethodType ?? '']) { this.didSigners[verificationMethod.verificationMethodType ?? ''] = EdDSASigner; } }); return this.didSigners; } /** * Retrieves the appropriate DID signer for a specific verification method. * Looks up the verification method by ID and returns the corresponding signer function. * * @param verificationMethodId - ID of the verification method to get signer for * @param verificationMethods - Array of available verification methods * @returns Promise resolving to a signer function that takes a secret key * @throws Error if the verification method is not found */ async getDidSigner(verificationMethodId, verificationMethods) { await this.checkDidSigners(verificationMethods); const verificationMethod = verificationMethods.find((method) => method.id === verificationMethodId)?.verificationMethodType; if (!verificationMethod) { throw new Error(`Verification method for ${verificationMethodId} not found`); } return this.didSigners[verificationMethod]; } /** * Signs a CreateDidDoc transaction payload using the provided signing inputs. * Validates verification methods and creates signatures for each signing input. * * @param signInputs - Array of signing inputs containing verification method IDs and private keys * @param payload - CreateDidDoc payload to sign * @returns Promise resolving to array of SignInfo objects with signatures */ async signCreateDidDocTx(signInputs, payload) { await this.checkDidSigners(payload?.verificationMethod); const signBytes = MsgCreateDidDocPayload.encode(payload).finish(); const signInfos = await Promise.all(signInputs.map(async (signInput) => { return { verificationMethodId: signInput.verificationMethodId, signature: base64ToBytes((await (await this.getDidSigner(signInput.verificationMethodId, payload.verificationMethod))(hexToBytes(signInput.privateKeyHex))(signBytes))), }; })); return signInfos; } async signUpdateDidDocTx(signInputs, payload, externalControllers, previousDidDocument) { await this.checkDidSigners(payload?.verificationMethod); const signBytes = MsgUpdateDidDocPayload.encode(payload).finish(); const signInfos = await Promise.all(signInputs.map(async (signInput) => { return { verificationMethodId: signInput.verificationMethodId, signature: base64ToBytes((await (await this.getDidSigner(signInput.verificationMethodId, payload.verificationMethod .concat(externalControllers ?.flatMap((controller) => controller.verificationMethod) .map((vm) => { return { id: vm.id, verificationMethodType: vm.type, controller: vm.controller, verificationMaterial: '<ignored>', }; }) ?? []) .concat(previousDidDocument?.verificationMethod?.map((vm) => { return { id: vm.id, verificationMethodType: vm.type, controller: vm.controller, verificationMaterial: '<ignored>', }; }) ?? [])))(hexToBytes(signInput.privateKeyHex))(signBytes))), }; })); return signInfos; } async signDeactivateDidDocTx(signInputs, payload, verificationMethod) { await this.checkDidSigners(verificationMethod); const signBytes = MsgDeactivateDidDocPayload.encode(payload).finish(); const signInfos = await Promise.all(signInputs.map(async (signInput) => { return { verificationMethodId: signInput.verificationMethodId, signature: base64ToBytes((await (await this.getDidSigner(signInput.verificationMethodId, verificationMethod))(hexToBytes(signInput.privateKeyHex))(signBytes))), }; })); return signInfos; } static async signIdentityTx(signBytes, signInputs) { let signInfos = []; for (let signInput of signInputs) { if (typeof signInput.keyType === undefined) { throw new Error('Key type is not defined'); } let signature; switch (signInput.keyType) { case 'Ed25519': signature = (await EdDSASigner(hexToBytes(signInput.privateKeyHex))(signBytes)); break; case 'Secp256k1': signature = (await ES256KSigner(hexToBytes(signInput.privateKeyHex))(signBytes)); break; case 'P256': signature = (await ES256Signer(hexToBytes(signInput.privateKeyHex))(signBytes)); break; default: throw new Error(`Unsupported signature type: ${signInput.keyType}`); } signInfos.push({ verificationMethodId: signInput.verificationMethodId, signature: base64ToBytes(signature), }); } return signInfos; } } //# sourceMappingURL=signer.js.map