From ea4cc9fa6698f74ec856241840ab71a1c9da6f9e Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Mon, 17 Feb 2025 11:53:38 +0530 Subject: [PATCH] feat(sdk-coin-icp): implemented transaction builder and validations for ICP TICKET: WIN-4635 --- modules/sdk-coin-icp/package.json | 8 +- modules/sdk-coin-icp/src/lib/iface.ts | 186 +++++++ modules/sdk-coin-icp/src/lib/index.ts | 3 + modules/sdk-coin-icp/src/lib/message.proto | 39 ++ .../src/lib/signedTransactionBuilder.ts | 93 ++++ modules/sdk-coin-icp/src/lib/transaction.ts | 204 +++++++ .../src/lib/transactionBuilder.ts | 230 ++++++-- .../src/lib/transactionBuilderFactory.ts | 65 ++- .../sdk-coin-icp/src/lib/transferBuilder.ts | 66 +-- .../src/lib/transferTransaction.ts | 9 + .../src/lib/unsignedTransactionBuilder.ts | 135 +++++ modules/sdk-coin-icp/src/lib/utils.ts | 511 ++++++++++++++++-- modules/sdk-coin-icp/test/resources/icp.ts | 100 ++++ modules/sdk-coin-icp/test/unit/icp.ts | 4 +- modules/sdk-coin-icp/test/unit/keyPair.ts | 140 +++++ modules/sdk-coin-icp/test/unit/utils.ts | 365 +++++++++++++ 16 files changed, 1964 insertions(+), 194 deletions(-) create mode 100644 modules/sdk-coin-icp/src/lib/iface.ts create mode 100644 modules/sdk-coin-icp/src/lib/message.proto create mode 100644 modules/sdk-coin-icp/src/lib/signedTransactionBuilder.ts create mode 100644 modules/sdk-coin-icp/src/lib/transaction.ts create mode 100644 modules/sdk-coin-icp/src/lib/transferTransaction.ts create mode 100644 modules/sdk-coin-icp/src/lib/unsignedTransactionBuilder.ts create mode 100644 modules/sdk-coin-icp/test/resources/icp.ts create mode 100644 modules/sdk-coin-icp/test/unit/keyPair.ts create mode 100644 modules/sdk-coin-icp/test/unit/utils.ts diff --git a/modules/sdk-coin-icp/package.json b/modules/sdk-coin-icp/package.json index 04f2c2b716..0a787025d9 100644 --- a/modules/sdk-coin-icp/package.json +++ b/modules/sdk-coin-icp/package.json @@ -46,8 +46,12 @@ "@dfinity/agent": "^2.2.0", "@dfinity/candid": "^2.2.0", "@dfinity/principal": "^2.2.0", - "@noble/curves": "1.8.1", - "crc-32": "^1.2.2" + "bignumber.js": "^9.1.1", + "cbor-x": "^1.6.0", + "crc-32": "^1.2.2", + "js-sha256": "^0.11.0", + "protobufjs": "^7.4.0", + "@noble/curves": "1.8.1" }, "devDependencies": { "@bitgo/sdk-api": "^1.58.9", diff --git a/modules/sdk-coin-icp/src/lib/iface.ts b/modules/sdk-coin-icp/src/lib/iface.ts new file mode 100644 index 0000000000..f8379fb017 --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/iface.ts @@ -0,0 +1,186 @@ +import { + TransactionExplanation as BaseTransactionExplanation, + TransactionType as BitGoTransactionType, +} from '@bitgo/sdk-core'; + +export enum RequestType { + CALL = 'call', + READ_STATE = 'read_state', +} + +export enum SignatureType { + ECDSA = 'ecdsa', +} + +export enum CurveType { + SECP256K1 = 'secp256k1', +} + +export enum OperationType { + TRANSACTION = 'TRANSACTION', + FEE = 'FEE', +} + +export enum MethodName { + SEND_PB = 'send_pb', // send_pb is the method name for ICP transfer transaction +} + +export enum NetworkID { + MAINNET = '00000000000000020101', // ICP does not have different network IDs for mainnet and testnet +} + +export interface IcpTransactionData { + senderAddress: string; + receiverAddress: string; + amount: string; + fee: string; + senderPublicKeyHex: string; + memo: number | BigInt; // memo in string is not accepted by ICP chain. + transactionType: OperationType; + expiryTime: number | BigInt; +} + +export interface IcpPublicKey { + hex_bytes: string; + curve_type: string; +} + +export interface IcpAccount { + address: string; +} + +export interface IcpCurrency { + symbol: string; + decimals: number; +} + +export interface IcpAmount { + value: string; + currency: IcpCurrency; +} + +export interface IcpOperation { + type: string; + account: IcpAccount; + amount: IcpAmount; +} + +export interface IcpMetadata { + created_at_time: number; + memo: number | BigInt; // memo in string is not accepted by ICP chain. + ingress_start: number | BigInt; // it should be nano seconds + ingress_end: number | BigInt; // it should be nano seconds +} + +export interface IcpTransaction { + public_keys: IcpPublicKey[]; + operations: IcpOperation[]; + metadata: IcpMetadata; +} + +export interface IcpAccountIdentifier { + address: string; +} + +export interface SendArgs { + memo: { memo: number | BigInt }; + payment: { receiverGets: { e8s: number } }; + maxFee: { e8s: number }; + to: { hash: Buffer }; + createdAtTime: { timestampNanos: number }; +} + +export interface HttpCanisterUpdate { + canister_id: Uint8Array; + method_name: MethodName; + arg: Uint8Array; + sender: Uint8Array; + ingress_expiry: bigint; +} + +export interface SigningPayload { + account_identifier: IcpAccountIdentifier; + hex_bytes: string; + signature_type: SignatureType; +} + +export interface PayloadsData { + payloads: SigningPayload[]; + unsigned_transaction: string; +} + +export interface Signatures { + signing_payload: SigningPayload; + signature_type: SignatureType; + public_key: IcpPublicKey; + hex_bytes: string; +} + +export interface cborUnsignedTransaction { + updates: [string, HttpCanisterUpdate][]; + ingress_expiries: bigint[]; +} + +export interface ReadState { + sender: Uint8Array; + paths: Array<[Buffer, Buffer]>; + ingress_expiry: bigint; +} + +export interface UpdateEnvelope { + content: { + request_type: RequestType; + canister_id: Uint8Array; + method_name: MethodName; + arg: Uint8Array; + sender: Uint8Array; + ingress_expiry: bigint; + }; + sender_pubkey: Uint8Array; + sender_sig: Uint8Array; +} + +export interface ReadStateEnvelope { + content: { + request_type: RequestType; + sender: Uint8Array; + paths: Array<[Uint8Array, Uint8Array]>; + ingress_expiry: bigint; + }; + sender_pubkey: Uint8Array; + sender_sig: Uint8Array; +} + +export interface RequestEnvelope { + update: UpdateEnvelope; + read_state: ReadStateEnvelope; +} + +/** + * The transaction data returned from the toJson() function of a transaction + */ +export interface TxData { + id?: string; + sender: string; + senderPublicKey: string; + recipient: string; + memo: number | BigInt; + feeAmount: string; + expirationTime: number | BigInt; + type?: BitGoTransactionType; +} + +export interface IcpTransactionExplanation extends BaseTransactionExplanation { + sender?: string; + type?: BitGoTransactionType; +} + +export interface NetworkIdentifier { + blockchain: string; + network: string; +} + +export interface SignedTransactionRequest { + network_identifier: NetworkIdentifier; + signed_transaction: string; +} diff --git a/modules/sdk-coin-icp/src/lib/index.ts b/modules/sdk-coin-icp/src/lib/index.ts index 838a26ae4e..d7a5114ee1 100644 --- a/modules/sdk-coin-icp/src/lib/index.ts +++ b/modules/sdk-coin-icp/src/lib/index.ts @@ -1,5 +1,8 @@ import * as Utils from './utils'; +export { KeyPair } from './keyPair'; export { TransactionBuilder } from './transactionBuilder'; export { TransferBuilder } from './transferBuilder'; +export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export { Transaction } from './transaction'; export { Utils }; diff --git a/modules/sdk-coin-icp/src/lib/message.proto b/modules/sdk-coin-icp/src/lib/message.proto new file mode 100644 index 0000000000..4e0912a35e --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/message.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +message Memo { + uint64 memo = 1; +} + +message Tokens { + uint64 e8s = 1; +} + +message Payment { + Tokens receiver_gets = 1; +} + +message Subaccount { + bytes sub_account = 1; +} + +message AccountIdentifier { + bytes hash = 1; +} + +message BlockIndex { + uint64 height = 1; +} + +message TimeStamp { + uint64 timestamp_nanos = 1; +} + +message SendRequest { + Memo memo = 1; + Payment payment = 2; + Tokens max_fee = 3; + Subaccount from_subaccount = 4; + AccountIdentifier to = 5; + BlockIndex created_at = 6; + TimeStamp created_at_time = 7; +} \ No newline at end of file diff --git a/modules/sdk-coin-icp/src/lib/signedTransactionBuilder.ts b/modules/sdk-coin-icp/src/lib/signedTransactionBuilder.ts new file mode 100644 index 0000000000..675e60a3d2 --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/signedTransactionBuilder.ts @@ -0,0 +1,93 @@ +import { + cborUnsignedTransaction, + RequestType, + Signatures, + UpdateEnvelope, + ReadStateEnvelope, + RequestEnvelope, +} from './iface'; +import utils from './utils'; +import assert from 'assert'; + +export class SignedTransactionBuilder { + protected _unsigned_transaction: string; + protected _signaturePayload: Signatures[]; + + constructor(unsigned_transaction: string, signatures: Signatures[]) { + this._unsigned_transaction = unsigned_transaction; + this._signaturePayload = signatures; + } + + getSignTransaction(): string { + const combineRequest = { + signatures: this._signaturePayload, + unsigned_transaction: this._unsigned_transaction, + }; + const signatureMap = new Map(); + for (const sig of combineRequest.signatures) { + signatureMap.set(sig.signing_payload.hex_bytes, sig); + } + /*{ + string: SIGNATURE + } + */ + const unsignedTransaction = utils.cborDecode( + utils.blobFromHex(combineRequest.unsigned_transaction) + ) as cborUnsignedTransaction; + assert(combineRequest.signatures.length === unsignedTransaction.ingress_expiries.length * 2); + assert(unsignedTransaction.updates.length === 1); + const envelopes = this.getEnvelopes(unsignedTransaction, signatureMap); + const envelopRequests = { requests: envelopes }; + const signedTransaction = utils.blobToHex(Buffer.from(utils.cborEncode(envelopRequests))); + return signedTransaction; + } + + getEnvelopes( + unsignedTransaction: cborUnsignedTransaction, + signatureMap: Map + ): [string, RequestEnvelope[]][] { + const envelopes: [string, RequestEnvelope[]][] = []; + for (const [reqType, update] of unsignedTransaction.updates) { + const requestEnvelopes: RequestEnvelope[] = []; + for (const ingressExpiry of unsignedTransaction.ingress_expiries) { + update.ingress_expiry = ingressExpiry; + + const readState = utils.makeReadStateFromUpdate(update); + + const transaction_signature = signatureMap.get( + utils.blobToHex(utils.makeSignatureData(utils.generateHttpCanisterUpdateId(update))) + ); + if (!transaction_signature) { + throw new Error('Transaction signature is undefined'); + } + + const readStateSignature = signatureMap.get( + utils.blobToHex(utils.makeSignatureData(utils.HttpReadStateRepresentationIndependentHash(readState))) + ); + if (!readStateSignature) { + throw new Error('read state signature is undefined'); + } + + const pk_der = utils.getPublicKeyInDERFormat(transaction_signature.public_key.hex_bytes); + const updateEnvelope: UpdateEnvelope = { + content: { request_type: RequestType.CALL, ...update }, + sender_pubkey: pk_der, + sender_sig: utils.blobFromHex(transaction_signature.hex_bytes), + }; + + const readStateEnvelope: ReadStateEnvelope = { + content: { request_type: RequestType.READ_STATE, ...readState }, + sender_pubkey: pk_der, + sender_sig: utils.blobFromHex(readStateSignature.hex_bytes), + }; + + requestEnvelopes.push({ + update: updateEnvelope, + read_state: readStateEnvelope, + }); + } + envelopes.push([reqType, requestEnvelopes]); + } + return envelopes; + } +} diff --git a/modules/sdk-coin-icp/src/lib/transaction.ts b/modules/sdk-coin-icp/src/lib/transaction.ts new file mode 100644 index 0000000000..62084cc070 --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/transaction.ts @@ -0,0 +1,204 @@ +import { + BaseKey, + BaseTransaction, + TransactionRecipient, + TransactionType, + InvalidTransactionError, + TransactionType as BitGoTransactionType, +} from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { + IcpTransaction, + IcpTransactionData, + PayloadsData, + OperationType, + Signatures, + TxData, + IcpTransactionExplanation, + SignedTransactionRequest, + NetworkID, +} from './iface'; +import { Utils } from './utils'; +import { KeyPair } from './keyPair'; +import BigNumber from 'bignumber.js'; + +export class Transaction extends BaseTransaction { + protected _icpTransactionData: IcpTransactionData; + protected _icpTransaction: IcpTransaction; + protected _payloadsData: PayloadsData; + protected _signedTransaction: string; + protected _signaturePayload: Signatures[]; + protected _utils: Utils; + + constructor(_coinConfig: Readonly, utils: Utils) { + super(_coinConfig); + this._utils = utils; + } + + get icpTransactionData(): IcpTransactionData { + return this._icpTransactionData; + } + + get icpTransaction(): IcpTransaction { + return this._icpTransaction; + } + + set icpTransaction(icpTransaction: IcpTransaction) { + this._icpTransaction = icpTransaction; + } + + get unsignedTransaction(): string { + return this._payloadsData.unsigned_transaction; + } + + get signaturePayload(): Signatures[] { + return this._signaturePayload; + } + + set signedTransaction(signature: string) { + this._signedTransaction = signature; + } + + set payloadsData(payloadsData: PayloadsData) { + this._payloadsData = payloadsData; + } + + fromRawTransaction(rawTransaction: string): void { + try { + const parsedTx = JSON.parse(rawTransaction); + switch (parsedTx.type) { + case OperationType.TRANSACTION: + const keyPair = new KeyPair({ prv: parsedTx.address }); + const senderPublicKeyHex = keyPair.getPublicKey({ compressed: true }).toString('hex'); + this._icpTransactionData = { + senderAddress: parsedTx.address, + receiverAddress: parsedTx.externalOutputs[0].address, + amount: parsedTx.spendAmountString, + fee: parsedTx.fee, + senderPublicKeyHex: senderPublicKeyHex, + memo: parsedTx.seqno, + transactionType: parsedTx.type, + expiryTime: parsedTx.expiryTime, + }; + this._utils.validateRawTransaction(this._icpTransactionData); + break; + default: + throw new Error('Invalid transaction type'); + } + } catch (error) { + throw new InvalidTransactionError('Invalid raw transaction'); + } + } + + addSignature(signaturePayloads: Signatures[]): void { + if (!signaturePayloads) { + throw new Error('signatures not provided'); + } + if (signaturePayloads.length !== this._payloadsData.payloads.length) { + throw new Error('signatures length is not matching'); + } + this._signaturePayload = signaturePayloads; + } + + /** @inheritdoc */ + toJson(): TxData { + if (!this._icpTransactionData) { + throw new Error('Empty transaction'); + } + let type: BitGoTransactionType | undefined; + switch (this._icpTransactionData.transactionType) { + case OperationType.TRANSACTION: + type = BitGoTransactionType.Send; + break; + default: + throw new Error('Unsupported transaction type'); + } + return { + id: this._id, + sender: this._icpTransactionData.senderAddress, + senderPublicKey: this._icpTransactionData.senderPublicKeyHex, + recipient: this._icpTransactionData.receiverAddress, + memo: this._icpTransactionData.memo, + feeAmount: this._icpTransactionData.fee, + expirationTime: this._icpTransactionData.expiryTime, + type: type, + }; + } + + /** @inheritDoc */ + explainTransaction(): IcpTransactionExplanation { + const result = this.toJson(); + const displayOrder = ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'type']; + const outputs: TransactionRecipient[] = []; + + const explanationResult: IcpTransactionExplanation = { + displayOrder, + id: this.id, + outputs, + outputAmount: '0', + changeOutputs: [], + changeAmount: '0', + fee: { fee: this._icpTransactionData.fee }, + type: result.type, + }; + + switch (this.type) { + case TransactionType.Send: + return this.explainTransferTransaction(explanationResult); + default: + throw new InvalidTransactionError('Transaction type not supported'); + } + } + + /** + * Explains a transfer transaction by providing details about the recipients and the total output amount. + * + * @param {IcpTransactionExplanation} explanationResult - The initial explanation result to be extended. + * @returns {IcpTransactionExplanation} The extended explanation result including the output amount and recipients. + */ + explainTransferTransaction(explanationResult: IcpTransactionExplanation): IcpTransactionExplanation { + const recipients = this._utils.getRecipients(this.icpTransactionData); + const outputs: TransactionRecipient[] = recipients.map((recipient) => recipient); + const outputAmountBN = recipients.reduce( + (accumulator, current) => accumulator.plus(current.amount), + new BigNumber(0) + ); + const outputAmount = outputAmountBN.toString(); + + return { + ...explanationResult, + outputAmount, + outputs, + }; + } + + /** @inheritdoc */ + toBroadcastFormat(): string { + if (!this._signedTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + return this.serialize(); + } + + serialize(): string { + const transaction: SignedTransactionRequest = { + signed_transaction: this._signedTransaction, + network_identifier: { + blockchain: this._coinConfig.fullName, + network: NetworkID.MAINNET, + }, + }; + return Buffer.from(JSON.stringify(transaction)).toString('base64'); + } + + /** @inheritdoc */ + canSign(key: BaseKey): boolean { + try { + const keyPair = new KeyPair({ prv: key.key }); + const publicKeyHex = keyPair.getPublicKey({ compressed: true }).toString('hex'); + return this._icpTransactionData.senderPublicKeyHex === publicKeyHex; + } catch (error) { + return false; + } + } +} diff --git a/modules/sdk-coin-icp/src/lib/transactionBuilder.ts b/modules/sdk-coin-icp/src/lib/transactionBuilder.ts index 3a167ca39d..e4d652fa13 100644 --- a/modules/sdk-coin-icp/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-icp/src/lib/transactionBuilder.ts @@ -1,97 +1,219 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { - BaseKey, - BaseTransaction, - PublicKey as BasePublicKey, - BaseTransactionBuilder, - BaseAddress, - Recipient, -} from '@bitgo/sdk-core'; -import { TransferBuilder } from './transferBuilder'; +import BigNumber from 'bignumber.js'; +import { BaseKey, BaseTransaction, BaseTransactionBuilder, BuildTransactionError, SigningError } from '@bitgo/sdk-core'; +import { Transaction } from './transaction'; +import { CurveType, IcpMetadata, IcpOperation, IcpPublicKey, IcpTransaction, OperationType } from './iface'; +import utils from './utils'; +import { UnsignedTransactionBuilder } from './unsignedTransactionBuilder'; +import { SignedTransactionBuilder } from './signedTransactionBuilder'; +import { KeyPair } from './keyPair'; export abstract class TransactionBuilder extends BaseTransactionBuilder { - protected _transfer: TransferBuilder; + protected _transaction: Transaction; + private _sender: string; + private _publicKey: string; + private _memo: number | BigInt; + private _receiverId: string; + private _amount: string; protected constructor(_coinConfig: Readonly) { super(_coinConfig); + this._transaction = new Transaction(_coinConfig, utils); } - /** @inheritdoc */ - protected signImplementation(key: BaseKey): BaseTransaction { - throw new Error('method not implemented'); + /** + * Sets the public key and the address of the sender of this transaction. + * + * @param {string} address the account that is sending this transaction + * @param {string} pubKey the public key that is sending this transaction + * @returns {TransactionBuilder} This transaction builder + */ + public sender(address: string, pubKey: string): this { + if (!address || !utils.isValidAddress(address.toString())) { + throw new BuildTransactionError('Invalid or missing address, got: ' + address); + } + if (!pubKey || !utils.isValidPublicKey(pubKey)) { + throw new BuildTransactionError('Invalid or missing pubKey, got: ' + pubKey); + } + this._sender = address; + this._publicKey = pubKey; + return this; } /** - * add a signature to the transaction + * Set the memo + * + * @param {number} memo - number that to be used as memo + * @returns {TransactionBuilder} This transaction builder */ - addSignature(publicKey: BasePublicKey, signature: Buffer): void { - throw new Error('method not implemented'); + public memo(memo: number): this { + if (memo < 0) { + throw new BuildTransactionError(`Invalid memo: ${memo}`); + } + this._memo = memo; + return this; } /** - * Sets the sender of this transaction. + * Sets the account Id of the receiver of this transaction. + * + * @param {string} accountId the account id of the account that is receiving this transaction + * @returns {TransactionBuilder} This transaction builder */ - sender(senderAddress: string): this { - throw new Error('method not implemented'); + public receiverId(accountId: string): this { + if (!accountId || !utils.isValidAddress(accountId)) { + throw new BuildTransactionError('Invalid or missing accountId for receiver, got: ' + accountId); + } + this._receiverId = accountId; + return this; } /** - * gets the gas data of this transaction. + * Sets the amount of this transaction. + * + * @param {string} value the amount to be sent in e8s (1 ICP = 1e8 e8s) + * @returns {TransactionBuilder} This transaction builder */ - gasData(): this { - throw new Error('method not implemented'); + public amount(value: string): this { + this.validateValue(new BigNumber(value)); + this._amount = value; + return this; } /** @inheritdoc */ - validateAddress(address: BaseAddress, addressFormat?: string): void { - throw new Error('method not implemented'); + validateTransaction(transaction: Transaction): void { + if (!utils.isValidAddress(transaction.icpTransactionData.senderAddress)) { + throw new BuildTransactionError('Invalid sender address'); + } + if (!utils.isValidAddress(transaction.icpTransactionData.receiverAddress)) { + throw new BuildTransactionError('Invalid receiver address'); + } + if (!utils.isValidPublicKey(transaction.icpTransactionData.senderPublicKeyHex)) { + throw new BuildTransactionError('Invalid sender public key'); + } + utils.validateValue(new BigNumber(transaction.icpTransactionData.amount)); + utils.validateFee(transaction.icpTransactionData.fee); + utils.validateMemo(transaction.icpTransactionData.memo); + utils.validateExpireTime(transaction.icpTransactionData.expiryTime); } - /** - * validates the recipients of the transaction - */ - validateRecipients(recipients: Recipient[]): void { - throw new Error('method not implemented'); + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.validateTransaction(this._transaction); + this.buildIcpTransactionData(); + const unsignedTransactionBuilder = new UnsignedTransactionBuilder(this._transaction.icpTransaction); + const payloadsData = await unsignedTransactionBuilder.getUnsignedTransaction(); + this._transaction.payloadsData = payloadsData; + return this._transaction; } - /** - * validates the gas data of the transaction - */ - validateGasData(): void { - throw new Error('method not implemented'); + protected buildIcpTransactionData(): void { + const publicKey: IcpPublicKey = { + hex_bytes: this._publicKey, + curve_type: CurveType.SECP256K1, + }; + + const senderOperation: IcpOperation = { + type: OperationType.TRANSACTION, + account: { address: this._sender }, + amount: { + value: `-this._amount`, + currency: { + symbol: this._coinConfig.family, + decimals: this._coinConfig.decimalPlaces, + }, + }, + }; + + const receiverOperation: IcpOperation = { + type: OperationType.TRANSACTION, + account: { address: this._receiverId }, + amount: { + value: this._amount, + currency: { + symbol: this._coinConfig.family, + decimals: this._coinConfig.decimalPlaces, + }, + }, + }; + + const feeOperation: IcpOperation = { + type: OperationType.FEE, + account: { address: this._sender }, + amount: { + value: utils.gasData(), + currency: { + symbol: this._coinConfig.family, + decimals: this._coinConfig.decimalPlaces, + }, + }, + }; + + const currentTime = Date.now() * 1000_000; + const ingressStartTime = currentTime; + const ingressEndTime = ingressStartTime + 5 * 60 * 1000_000_000; // 5 mins in nanoseconds + const metaData: IcpMetadata = { + created_at_time: currentTime, + memo: this._memo, + ingress_start: ingressStartTime, + ingress_end: ingressEndTime, + }; + + const icpTransactionData: IcpTransaction = { + public_keys: [publicKey], + operations: [senderOperation, receiverOperation, feeOperation], + metadata: metaData, + }; + this._transaction.icpTransaction = icpTransactionData; } /** - * validates the gas price of the transaction + * Initialize the transaction builder fields using the decoded transaction data + * + * @param {Transaction} tx the transaction data */ - validateGasPrice(gasPrice: number): void { - throw new Error('method not implemented'); + initBuilder(tx: Transaction): void { + this._transaction = tx; + const icpTransactionData = tx.icpTransactionData; + this._sender = icpTransactionData.senderAddress; + this._memo = icpTransactionData.memo; + this._receiverId = icpTransactionData.receiverAddress; + this._publicKey = icpTransactionData.senderPublicKeyHex; + this._amount = icpTransactionData.amount; } /** @inheritdoc */ - validateKey(key: BaseKey): void { - throw new Error('method not implemented'); - } + sign(key: BaseKey): void { + this.validateKey(key); + if (!this.transaction.canSign(key)) { + throw new SigningError('Private key cannot sign the transaction'); + } - /** @inheritdoc */ - validateRawTransaction(rawTransaction: string): void { - throw new Error('method not implemented'); + this.transaction = this.signImplementation(key); } /** @inheritdoc */ - validateValue(): void { - throw new Error('method not implemented'); - } - - /** - * Validates the specific transaction builder internally - */ - validateDecodedTransaction(): void { - throw new Error('method not implemented'); + protected signImplementation(key: BaseKey): BaseTransaction { + const keyPair = new KeyPair({ prv: key.key }); + const keys = keyPair.getKeys(); + if (!keys.prv || this._publicKey !== keys.pub) { + throw new SigningError('invalid private key'); + } + const signedTransactionBuilder = new SignedTransactionBuilder( + this._transaction.unsignedTransaction, + this._transaction.signaturePayload + ); + this._transaction.signedTransaction = signedTransactionBuilder.getSignTransaction(); + return this._transaction; } /** @inheritdoc */ - validateTransaction(): void { - throw new Error('method not implemented'); + validateKey(key: BaseKey): void { + if (!key || !key.key) { + throw new SigningError('Key is required'); + } + if (!utils.isValidPrivateKey(key.key)) { + throw new SigningError('Invalid private key'); + } } } diff --git a/modules/sdk-coin-icp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-icp/src/lib/transactionBuilderFactory.ts index c971054b91..e6fe235605 100644 --- a/modules/sdk-coin-icp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-icp/src/lib/transactionBuilderFactory.ts @@ -1,5 +1,10 @@ -import { BaseTransactionBuilderFactory } from '@bitgo/sdk-core'; +import { BaseTransactionBuilderFactory, InvalidTransactionError } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import { TransferBuilder } from './transferBuilder'; +import { Utils } from './utils'; +import { OperationType } from './iface'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -7,32 +12,45 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { } /** @inheritdoc */ - from(): void { - throw new Error('method not implemented'); + from(rawTransaction: string): TransactionBuilder { + const transaction = new Transaction(this._coinConfig, new Utils()); + transaction.fromRawTransaction(rawTransaction); + try { + switch (transaction.icpTransactionData.transactionType) { + case OperationType.TRANSACTION: + return this.getTransferBuilder(transaction); + default: + throw new InvalidTransactionError('Invalid transaction'); + } + } catch (e) { + throw new InvalidTransactionError('Invalid transaction: ' + e.message); + } } - /** @inheritdoc */ - getTransferBuilder(): void { - throw new Error('method not implemented'); - } - - /** @inheritdoc */ - getStakingBuilder(): void { - throw new Error('method not implemented'); - } - - /** @inheritdoc */ - getUnstakingBuilder(): void { - throw new Error('method not implemented'); + /** + * Initialize the builder with the given transaction + * + * @param {Transaction | undefined} tx - the transaction used to initialize the builder + * @param {TransactionBuilder} builder - the builder to be initialized + * @returns {TransactionBuilder} the builder initialized + */ + private static initializeBuilder(tx: Transaction | undefined, builder: T): T { + if (tx) { + builder.initBuilder(tx); + } + return builder; } /** @inheritdoc */ - getCustomTransactionBuilder(): void { - throw new Error('method not implemented'); + getTransferBuilder(tx?: Transaction): TransferBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new TransferBuilder(this._coinConfig, new Utils())); } - /** @inheritdoc */ - getTokenTransferBuilder(): void { + //TODO WIN-4723 need to implement the following method + /** + * Parse the transaction from a raw transaction + */ + private parseTransaction(rawTransaction: string): void { throw new Error('method not implemented'); } @@ -40,11 +58,4 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { getWalletInitializationBuilder(): void { throw new Error('method not implemented'); } - - /** - * Parse the transaction from a raw transaction - */ - private parseTransaction(rawTransaction: string): void { - throw new Error('method not implemented'); - } } diff --git a/modules/sdk-coin-icp/src/lib/transferBuilder.ts b/modules/sdk-coin-icp/src/lib/transferBuilder.ts index 87dc3c05cc..a4b34561e3 100644 --- a/modules/sdk-coin-icp/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-icp/src/lib/transferBuilder.ts @@ -1,74 +1,40 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { TransactionBuilder } from './transactionBuilder'; -import { BaseKey, Recipient, TransactionType, BaseTransaction } from '@bitgo/sdk-core'; +import { TransactionType, BaseTransaction, BaseAddress } from '@bitgo/sdk-core'; +import { Utils } from './utils'; +import BigNumber from 'bignumber.js'; export class TransferBuilder extends TransactionBuilder { - protected _recipients: Recipient[]; + protected _utils: Utils; - constructor(_coinConfig: Readonly) { + validateValue(value: BigNumber): void { + throw new Error('Method not implemented.'); + } + + constructor(_coinConfig: Readonly, utils: Utils) { super(_coinConfig); + this._utils = utils; } protected get transactionType(): TransactionType { return TransactionType.Send; } - send(recipients: Recipient[]): this { - this.validateRecipients(recipients); - this._recipients = recipients; - return this; - } - - /** @inheritdoc */ - validateTransaction(): void { - throw new Error('method not implemented'); - } - /** @inheritdoc */ - sign(key: BaseKey): void { - throw new Error('method not implemented'); - } - - /** @inheritdoc */ - protected async buildImplementation(): Promise { - throw new Error('method not implemented'); - } - - /** - * Initialize the transaction builder fields using the decoded transaction data - */ - initBuilder(): void { - throw new Error('method not implemented'); - } - - /** - * Validates all fields are defined - */ - private validateTransactionFields(): void { - throw new Error('method not implemented'); - } - - /** - * Build transfer programmable transaction - * - * @protected - */ - protected buildIcpTransaction(): void { + fromImplementation(): BaseTransaction { throw new Error('method not implemented'); } /** @inheritdoc */ - TransactionBuilder(): void { + get transaction(): BaseTransaction { throw new Error('method not implemented'); } - /** @inheritdoc */ - fromImplementation(): BaseTransaction { - throw new Error('method not implemented'); + validateAddress(address: BaseAddress, addressFormat?: string): void { + throw new Error('Method not implemented.'); } - /** @inheritdoc */ - get transaction(): BaseTransaction { - throw new Error('method not implemented'); + validateRawTransaction(rawTransaction: any): void { + throw new Error('Invalid raw transaction'); } } diff --git a/modules/sdk-coin-icp/src/lib/transferTransaction.ts b/modules/sdk-coin-icp/src/lib/transferTransaction.ts new file mode 100644 index 0000000000..c2db0d6355 --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/transferTransaction.ts @@ -0,0 +1,9 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction } from './transaction'; +import { Utils } from './utils'; + +export class TransferTransaction extends Transaction { + constructor(_coinConfig: Readonly, utils: Utils) { + super(_coinConfig, utils); + } +} diff --git a/modules/sdk-coin-icp/src/lib/unsignedTransactionBuilder.ts b/modules/sdk-coin-icp/src/lib/unsignedTransactionBuilder.ts new file mode 100644 index 0000000000..9752f63b3c --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/unsignedTransactionBuilder.ts @@ -0,0 +1,135 @@ +import { + IcpTransaction, + SendArgs, + HttpCanisterUpdate, + SigningPayload, + PayloadsData, + SignatureType, + OperationType, + MethodName, +} from './iface'; +import protobuf from 'protobufjs'; +import utils from './utils'; + +const PROTOPATH = './message.proto'; +const MAX_INGRESS_TTL = 5 * 60 * 1000_000_000; // 5 minutes in nanoseconds +const PERMITTED_DRIFT = 60 * 1000_000_000; // 60 seconds in nanoseconds +const LEDGER_CANISTER_ID = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); // Uint8Array value for "00000000000000020101" and the string value is "ryjl3-tyaaa-aaaaa-aaaba-cai" + +export class UnsignedTransactionBuilder { + private _icpTransactionPayload: IcpTransaction; + constructor(icpTransactionPayload: IcpTransaction) { + this._icpTransactionPayload = icpTransactionPayload; + } + + async getUnsignedTransaction(): Promise { + const interval = MAX_INGRESS_TTL - PERMITTED_DRIFT - 120 * 1000_000_000; // 120 seconds in milliseconds + const ingressExpiries = this.getIngressExpiries( + this._icpTransactionPayload.metadata.ingress_start, + this._icpTransactionPayload.metadata.ingress_end, + interval + ); + const sendArgs = this.getSendArgs( + this._icpTransactionPayload.metadata.memo, + this._icpTransactionPayload.metadata.created_at_time, + this._icpTransactionPayload.operations[1].amount.value, + this._icpTransactionPayload.operations[2].amount.value, + this._icpTransactionPayload.operations[1].account.address + ); + const update = await this.getUpdate(sendArgs, this._icpTransactionPayload.public_keys[0].hex_bytes); + const updates: [string, HttpCanisterUpdate][] = []; + updates.push([OperationType.TRANSACTION, update]); + const txn = { updates, ingressExpiries }; + const unsignedTransaction = utils.cborEncode(txn); + const payloads: SigningPayload[] = []; + this.getPayloads(payloads, ingressExpiries, this._icpTransactionPayload.operations[0].account.address, update); + const payloadsData = { + payloads: payloads, + unsigned_transaction: unsignedTransaction, + }; + return payloadsData; + } + + getPayloads( + payloads: SigningPayload[], + ingressExpiries: bigint[], + accountAddress: string, + update: HttpCanisterUpdate + ): SigningPayload[] { + for (const ingressExpiry of ingressExpiries) { + const clonedUpdate: HttpCanisterUpdate = { + canister_id: Buffer.from(update.canister_id), + method_name: update.method_name, + arg: new Uint8Array(update.arg), + sender: new Uint8Array(update.sender), + ingress_expiry: ingressExpiry, + }; + + const representationIndependentHash = utils.HttpCanisterUpdateRepresentationIndependentHash(clonedUpdate); + const transactionPayload: SigningPayload = { + hex_bytes: utils.blobToHex(utils.makeSignatureData(representationIndependentHash)), + account_identifier: { address: accountAddress }, + signature_type: SignatureType.ECDSA, + }; + payloads.push(transactionPayload); + + const readState = utils.makeReadStateFromUpdate(clonedUpdate); + const readStateMessageId = utils.HttpReadStateRepresentationIndependentHash(readState); + const readStatePayload: SigningPayload = { + hex_bytes: utils.blobToHex(utils.makeSignatureData(readStateMessageId)), + account_identifier: { address: accountAddress }, + signature_type: SignatureType.ECDSA, + }; + payloads.push(readStatePayload); + } + + return payloads; + } + + getIngressExpiries(ingressStartTime: number | BigInt, ingressEndTime: number | BigInt, interval: number): bigint[] { + const ingressExpiries: bigint[] = []; + + for (let now = Number(ingressStartTime); now < Number(ingressEndTime); now += interval) { + const ingressExpiry = BigInt(now + (MAX_INGRESS_TTL - PERMITTED_DRIFT)); + ingressExpiries.push(ingressExpiry); + } + + return ingressExpiries; + } + + getSendArgs(memo: number | BigInt, created_at_time: number, amount: string, fee: string, receiver: string): SendArgs { + const sendArgs: SendArgs = { + memo: { memo: memo }, + payment: { receiverGets: { e8s: Number(amount) } }, + maxFee: { e8s: Number(fee) }, + to: { hash: Buffer.from(receiver, 'hex') }, + createdAtTime: { timestampNanos: Number(created_at_time) }, + }; + return sendArgs; + } + + async toArg(args: SendArgs): Promise { + const root = await protobuf.load(PROTOPATH); + const SendRequestMessage = root.lookupType('SendRequest'); + const errMsg = SendRequestMessage.verify(args); + if (errMsg) throw new Error(errMsg); + + const message = SendRequestMessage.create(args); + return SendRequestMessage.encode(message).finish(); + } + + async getUpdate(sendArgs: SendArgs, publicKeyHex: string): Promise { + const principalId = utils.getPrincipalIdFromPublicKey(publicKeyHex).toUint8Array(); + const senderBlob = principalId; + const canisterIdBuffer = Buffer.from(LEDGER_CANISTER_ID); + const args = await this.toArg(sendArgs); + const update: HttpCanisterUpdate = { + canister_id: canisterIdBuffer, + method_name: MethodName.SEND_PB, + arg: args, + sender: senderBlob, + ingress_expiry: BigInt(0), + }; + return update; + } +} diff --git a/modules/sdk-coin-icp/src/lib/utils.ts b/modules/sdk-coin-icp/src/lib/utils.ts index 1c94b77096..8c0ab15957 100644 --- a/modules/sdk-coin-icp/src/lib/utils.ts +++ b/modules/sdk-coin-icp/src/lib/utils.ts @@ -1,47 +1,117 @@ -import { BaseUtils, KeyPair } from '@bitgo/sdk-core'; -import { secp256k1 } from '@noble/curves/secp256k1'; +import { BaseUtils, KeyPair, ParseTransactionError, Recipient, BuildTransactionError } from '@bitgo/sdk-core'; import { Principal as DfinityPrincipal } from '@dfinity/principal'; import * as agent from '@dfinity/agent'; import crypto from 'crypto'; import crc32 from 'crc-32'; +import { HttpCanisterUpdate, IcpTransactionData, ReadState, RequestType } from './iface'; import { KeyPair as IcpKeyPair } from './keyPair'; +import { decode, encode } from 'cbor-x'; // The "cbor-x" library is used here because it supports modern features like BigInt. do not replace it with "cbor as "cbor" is not compatible with Rust's serde_cbor when handling big numbers. +import js_sha256 from 'js-sha256'; +import BigNumber from 'bignumber.js'; +import { secp256k1 } from '@noble/curves/secp256k1'; export class Utils implements BaseUtils { - isValidAddress(address: string): boolean { + isValidTransactionId(txId: string): boolean { throw new Error('Method not implemented.'); } - isValidTransactionId(txId: string): boolean { + isValidBlockId(hash: string): boolean { throw new Error('Method not implemented.'); } - isValidPublicKey(hexStr: string): boolean { - if (!this.isValidHex(hexStr)) { - return false; - } + isValidSignature(signature: string): boolean { + throw new Error('Method not implemented.'); + } + + /** + * gets the gas data of this transaction. + */ + gasData(): string { + return '-10000'; + } + + /** + * Checks if the provided address is a valid hexadecimal string. + * + * @param {string} address - The address to validate. + * @returns {boolean} - Returns `true` if the address is a valid 64-character hexadecimal string, otherwise `false`. + */ + isValidAddress(address: string): boolean { + return typeof address === 'string' && /^[0-9a-fA-F]{64}$/.test(address); + } - if (!this.isValidLength(hexStr)) { + /** + * Checks if the provided hex string is a valid public key. + * + * A valid public key can be either compressed or uncompressed: + * - Compressed public keys are 33 bytes long and start with either 0x02 or 0x03. + * - Uncompressed public keys are 65 bytes long and start with 0x04. + * + * @param {string} hexStr - The hex string representation of the public key to validate. + * @returns {boolean} - Returns `true` if the hex string is a valid public key, otherwise `false`. + */ + isValidPublicKey(hexStr: string): boolean { + if (!this.isValidHex(hexStr) || !this.isValidLength(hexStr)) { return false; } const pubKeyBytes = this.hexToBytes(hexStr); const firstByte = pubKeyBytes[0]; - return ( - (pubKeyBytes.length === 33 && (firstByte === 2 || firstByte === 3)) || - (pubKeyBytes.length === 65 && firstByte === 4) - ); + const validCompressed = pubKeyBytes.length === 33 && (firstByte === 2 || firstByte === 3); + const validUncompressed = pubKeyBytes.length === 65 && firstByte === 4; + + return validCompressed || validUncompressed; } + /** + * Encodes a value into CBOR format and returns it as a hex string. + * + * @param {unknown} value - The value to encode. + * @returns {string} - The CBOR encoded value as a hex string. + */ + cborEncode(value: unknown): string { + if (value === undefined) { + throw new Error('Value to encode cannot be undefined.'); + } + const cborData = encode(value); + return Buffer.from(cborData).toString('hex'); + } + + /** + * Checks if the length of the given hexadecimal string is valid. + * A valid length is either 66 characters (33 bytes) or 130 characters (65 bytes). + * + * @param {string} hexStr - The hexadecimal string to check. + * @returns {boolean} - Returns `true` if the length is valid, otherwise `false`. + */ isValidLength(hexStr: string): boolean { return hexStr.length / 2 === 33 || hexStr.length / 2 === 65; } + /** + * Checks if the provided string is a valid hexadecimal string. + * + * A valid hexadecimal string consists of pairs of hexadecimal digits (0-9, a-f, A-F). + * + * @param hexStr - The string to be validated as a hexadecimal string. + * @returns True if the string is a valid hexadecimal string, false otherwise. + */ isValidHex(hexStr: string): boolean { return /^([0-9a-fA-F]{2})+$/.test(hexStr); } + /** + * Converts a hexadecimal string to a Uint8Array. + * + * @param {string} hex - The hexadecimal string to convert. + * @returns {Uint8Array} The resulting byte array. + */ hexToBytes(hex: string): Uint8Array { - return new Uint8Array(Buffer.from(hex, 'hex')); + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + } + return bytes; } /** @inheritdoc */ @@ -49,6 +119,16 @@ export class Utils implements BaseUtils { return this.isValidKey(key); } + /** + * Validates whether the provided key is a valid ICP private key. + * + * This function attempts to create a new instance of `IcpKeyPair` using the provided key. + * If the key is valid, the function returns `true`. If the key is invalid, an error is thrown, + * and the function returns `false`. + * + * @param {string} key - The private key to validate. + * @returns {boolean} - `true` if the key is valid, `false` otherwise. + */ isValidKey(key: string): boolean { try { new IcpKeyPair({ prv: key }); @@ -58,27 +138,13 @@ export class Utils implements BaseUtils { } } - isValidSignature(signature: string): boolean { - throw new Error('Method not implemented.'); - } - - isValidBlockId(hash: string): boolean { - throw new Error('Method not implemented.'); - } - - getHeaders(): Record { - return { - 'Content-Type': 'application/json', - }; - } - - getNetworkIdentifier(): Record { - return { - blockchain: 'Internet Computer', - network: '00000000000000020101', - }; - } - + /** + * Compresses an uncompressed public key. + * + * @param {string} uncompressedKey - The uncompressed public key in hexadecimal format. + * @returns {string} - The compressed public key in hexadecimal format. + * @throws {Error} - If the input key is not a valid uncompressed public key. + */ compressPublicKey(uncompressedKey: string): string { if (uncompressedKey.startsWith('02') || uncompressedKey.startsWith('03')) { return uncompressedKey; @@ -92,34 +158,64 @@ export class Utils implements BaseUtils { const y = BigInt(`0x${yHex}`); const prefix = y % 2n === 0n ? '02' : '03'; - return prefix + xHex; + return `${prefix}${xHex}`; } - getCurveType(): string { - return 'secp256k1'; + /** + * Converts a public key from its hexadecimal string representation to DER format. + * + * @param {string} publicKeyHex - The public key in hexadecimal string format. + * @returns The public key in DER format as a Uint8Array. + */ + getPublicKeyInDERFormat(publicKeyHex: string): Uint8Array { + const publicKeyBuffer = Buffer.from(publicKeyHex, 'hex'); + const ellipticKey = secp256k1.ProjectivePoint.fromHex(publicKeyBuffer.toString('hex')); + const uncompressedPublicKeyHex = ellipticKey.toHex(false); + const derEncodedKey = agent.wrapDER(Buffer.from(uncompressedPublicKeyHex, 'hex'), agent.SECP256K1_OID); + return derEncodedKey; } + /** + * Converts a public key in hexadecimal format to a Dfinity Principal ID. + * + * @param {string} publicKeyHex - The public key in hexadecimal format. + * @returns The corresponding Dfinity Principal ID. + */ + getPrincipalIdFromPublicKey(publicKeyHex: string): DfinityPrincipal { + const derEncodedKey = this.getPublicKeyInDERFormat(publicKeyHex); + const principalId = DfinityPrincipal.selfAuthenticating(Buffer.from(derEncodedKey)); + return principalId; + } + + /** + * Derives a DfinityPrincipal from a given public key in hexadecimal format. + * + * @param {string} publicKeyHex - The public key in hexadecimal format. + * @returns The derived DfinityPrincipal. + * @throws Will throw an error if the principal cannot be derived from the public key. + */ derivePrincipalFromPublicKey(publicKeyHex: string): DfinityPrincipal { try { - const point = secp256k1.ProjectivePoint.fromHex(publicKeyHex); - const uncompressedPublicKeyHex = point.toHex(false); - const derEncodedKey = agent.wrapDER(Buffer.from(uncompressedPublicKeyHex, 'hex'), agent.SECP256K1_OID); + const derEncodedKey = this.getPublicKeyInDERFormat(publicKeyHex); const principalId = DfinityPrincipal.selfAuthenticating(Buffer.from(derEncodedKey)); const principal = DfinityPrincipal.fromUint8Array(principalId.toUint8Array()); return principal; } catch (error) { - throw new Error(`Failed to process the public key: ${error.message}`); + throw new Error(`Failed to derive principal from public key: ${error.message}`); } } + /** + * Converts a DfinityPrincipal and an optional subAccount to a string representation of an account ID. + * + * @param {DfinityPrincipal} principal - The principal to convert. + * @param {Uint8Array} [subAccount=new Uint8Array(32)] - An optional sub-account, defaults to a 32-byte array of zeros. + * @returns {string} The hexadecimal string representation of the account ID. + */ fromPrincipal(principal: DfinityPrincipal, subAccount: Uint8Array = new Uint8Array(32)): string { - const ACCOUNT_ID_PREFIX = new Uint8Array([0x0a, ...Buffer.from('account-id')]); - const principalBytes = principal.toUint8Array(); - const combinedBytes = new Uint8Array(ACCOUNT_ID_PREFIX.length + principalBytes.length + subAccount.length); - - combinedBytes.set(ACCOUNT_ID_PREFIX, 0); - combinedBytes.set(principalBytes, ACCOUNT_ID_PREFIX.length); - combinedBytes.set(subAccount, ACCOUNT_ID_PREFIX.length + principalBytes.length); + const ACCOUNT_ID_PREFIX = Buffer.from([0x0a, ...Buffer.from('account-id')]); + const principalBytes = Buffer.from(principal.toUint8Array()); + const combinedBytes = Buffer.concat([ACCOUNT_ID_PREFIX, principalBytes, subAccount]); const sha224Hash = crypto.createHash('sha224').update(combinedBytes).digest(); const checksum = Buffer.alloc(4); @@ -129,27 +225,324 @@ export class Utils implements BaseUtils { return accountIdBytes.toString('hex'); } + /** + * Retrieves the address associated with a given hex-encoded public key. + * + * @param {string} hexEncodedPublicKey - The public key in hex-encoded format. + * @returns {Promise} A promise that resolves to the address derived from the provided public key. + * @throws {Error} Throws an error if the provided public key is not in a valid hex-encoded format. + */ async getAddressFromPublicKey(hexEncodedPublicKey: string): Promise { - const isKeyValid = this.isValidPublicKey(hexEncodedPublicKey); - if (!isKeyValid) { - throw new Error('Public Key is not in a valid Hex Encoded Format'); + if (!this.isValidPublicKey(hexEncodedPublicKey)) { + throw new Error('Invalid hex-encoded public key format.'); } const compressedKey = this.compressPublicKey(hexEncodedPublicKey); - const KeyPair = new IcpKeyPair({ pub: compressedKey }); - return KeyPair.getAddress(); + const keyPair = new IcpKeyPair({ pub: compressedKey }); + return keyPair.getAddress(); } + /** + * Generates a new key pair. If a seed is provided, it will be used to generate the key pair. + * + * @param {Buffer} [seed] - Optional seed for key generation. + * @returns {KeyPair} - The generated key pair containing both public and private keys. + * @throws {Error} - If the private key is missing in the generated key pair. + */ public generateKeyPair(seed?: Buffer): KeyPair { const keyPair = seed ? new IcpKeyPair({ seed }) : new IcpKeyPair(); - const keys = keyPair.getKeys(); - if (!keys.prv) { - throw new Error('Missing prv in key generation.'); + const { pub, prv } = keyPair.getKeys(); + if (!prv) { + throw new Error('Private key is missing in the generated key pair.'); + } + return { pub, prv }; + } + + validateFee(fee: string): void { + if (new BigNumber(fee).isEqualTo(0)) { + throw new BuildTransactionError('Fee equal to zero'); } + if (fee !== this.gasData()) { + throw new BuildTransactionError('Invalid fee value'); + } + } + + /** @inheritdoc */ + validateValue(value: BigNumber): void { + if (value.isLessThanOrEqualTo(0)) { + throw new BuildTransactionError('amount cannot be less than or equal to zero'); + } + } + + validateMemo(memo: number | BigInt): void { + if (Number(memo) < 0) { + throw new BuildTransactionError('Invalid memo'); + } + } + + validateExpireTime(expireTime: number | BigInt): void { + if (Number(expireTime) < Date.now() * 1000_000) { + throw new BuildTransactionError('Invalid expiry time'); + } + } + + /** + * Validates the raw transaction data to ensure it has a valid format in the blockchain context. + * + * @param {IcpTransactionData} transactionData - The transaction data to validate. + * @throws {ParseTransactionError} If the transaction data is invalid. + */ + validateRawTransaction(transactionData: IcpTransactionData): void { + if (!transactionData) { + throw new ParseTransactionError('Transaction data is missing.'); + } + const { senderPublicKeyHex, senderAddress, receiverAddress } = transactionData; + if (!this.isValidPublicKey(senderPublicKeyHex)) { + throw new ParseTransactionError('Sender public key is invalid.'); + } + if (!this.isValidAddress(senderAddress)) { + throw new ParseTransactionError('Sender address is invalid.'); + } + if (!this.isValidAddress(receiverAddress)) { + throw new ParseTransactionError('Receiver address is invalid.'); + } + this.validateFee(transactionData.fee); + this.validateValue(new BigNumber(transactionData.amount)); + this.validateMemo(transactionData.memo); + this.validateExpireTime(transactionData.expiryTime); + } + + /** + * + * @param {object} update + * @returns {Buffer} + */ + generateHttpCanisterUpdateId(update: HttpCanisterUpdate): Buffer { + return this.HttpCanisterUpdateRepresentationIndependentHash(update); + } + + /** + * Generates a representation-independent hash for an HTTP canister update. + * + * @param {HttpCanisterUpdate} update - The HTTP canister update object. + * @returns {Buffer} - The hash of the update object. + */ + HttpCanisterUpdateRepresentationIndependentHash(update: HttpCanisterUpdate): Buffer { + const updateMap = { + request_type: RequestType.CALL, + canister_id: update.canister_id, + method_name: update.method_name, + arg: update.arg, + ingress_expiry: update.ingress_expiry, + sender: update.sender, + }; + return this.hashOfMap(updateMap); + } + + /** + * Generates a SHA-256 hash for a given map object. + * + * @param {Record} map - The map object to hash. + * @returns {Buffer} - The resulting hash as a Buffer. + */ + hashOfMap(map: Record): Buffer { + const hashes = Object.entries(map) + .map(([key, value]) => this.hashKeyVal(key, value as string | BigInt | Buffer)) + .sort(Buffer.compare); + return this.sha256(hashes); + } + + /** + * Generates a hash for a key-value pair. + * + * @param {string} key - The key to hash. + * @param {string | Buffer | BigInt} val - The value to hash. + * @returns {Buffer} - The resulting hash as a Buffer. + */ + hashKeyVal(key: string, val: string | Buffer | BigInt): Buffer { + const keyHash = this.hashString(key); + const valHash = this.hashVal(val); + return Buffer.concat([keyHash, valHash]); + } + + /** + * Generates a SHA-256 hash for a given string. + * + * @param {string} value - The string to hash. + * @returns {Buffer} - The resulting hash as a Buffer. + */ + hashString(value: string): Buffer { + return this.sha256([Buffer.from(value)]); + } + + /** + * Generates a hash for a 64-bit unsigned integer. + * + * @param {bigint} n - The 64-bit unsigned integer to hash. + * @returns {Buffer} - The resulting hash as a Buffer. + */ + hashU64(n: bigint): Buffer { + const buf = Buffer.allocUnsafe(10); + let i = 0; + while (true) { + const byte = Number(n & BigInt(0x7f)); + n >>= BigInt(7); + if (n === BigInt(0)) { + buf[i] = byte; + break; + } else { + buf[i] = byte | 0x80; + ++i; + } + } + return this.hashBytes(buf.subarray(0, i + 1)); + } + + /** + * Generates a SHA-256 hash for an array of elements. + * + * @param {Array} elements - The array of elements to hash. + * @returns {Buffer} - The resulting hash as a Buffer. + */ + hashArray(elements: Array): Buffer { + return this.sha256(elements.map(this.hashVal)); + } + + /** + * Generates a hash for a given value. + * + * @param {string | Buffer | BigInt | number | Array} val - The value to hash. + * @returns {Buffer} - The resulting hash as a Buffer. + * @throws {Error} - If the value type is unsupported. + */ + hashVal(val: string | Buffer | BigInt | number | Array): Buffer { + if (typeof val === 'string') { + return this.hashString(val); + } else if (Buffer.isBuffer(val)) { + return this.hashBytes(val); + } else if (typeof val === 'bigint' || typeof val === 'number') { + return this.hashU64(BigInt(val)); + } else if (Array.isArray(val)) { + return this.hashArray(val); + } else { + throw new Error(`Unsupported value type for hashing: ${typeof val}`); + } + } + + /** + * Computes the SHA-256 hash of the given buffer. + * + * @param value - The buffer to hash. + * @returns The SHA-256 hash of the input buffer. + */ + hashBytes(value: Buffer): Buffer { + return this.sha256([value]); + } + + /** + * Computes the SHA-256 hash of the provided array of Buffer chunks. + * + * @param {Array} chunks - An array of Buffer objects to be hashed. + * @returns {Buffer} - The resulting SHA-256 hash as a Buffer. + */ + sha256(chunks: Array): Buffer { + const hasher = js_sha256.sha256.create(); + chunks.forEach((chunk) => hasher.update(chunk)); + return Buffer.from(hasher.arrayBuffer()); + } + + /** + * Converts a hexadecimal string to a Buffer. + * + * @param hex - The hexadecimal string to convert. + * @returns A Buffer containing the binary data represented by the hexadecimal string. + */ + blobFromHex(hex: string): Buffer { + return Buffer.from(hex, 'hex'); + } + + /** + * Converts a binary blob (Buffer) to a hexadecimal string. + * + * @param {Buffer} blob - The binary data to be converted. + * @returns {string} The hexadecimal representation of the binary data. + */ + blobToHex(blob: Buffer): string { + return blob.toString('hex'); + } + + /** + * Decodes a given CBOR-encoded buffer. + * + * @param buffer - The CBOR-encoded buffer to decode. + * @returns The decoded data. + */ + cborDecode(buffer: Buffer): unknown { + const res = decode(buffer); + return res; + } + + /** + * Generates a Buffer containing the domain IC request string. + * + * @returns {Buffer} A Buffer object initialized with the string '\x0Aic-request'. + */ + getDomainICRequest(): Buffer { + return Buffer.from('\x0Aic-request'); + } + + /** + * Combines the domain IC request buffer with the provided message ID buffer to create signature data. + * + * @param {Buffer} messageId - The buffer containing the message ID. + * @returns {Buffer} - The concatenated buffer containing the domain IC request and the message ID. + */ + makeSignatureData(messageId: Buffer): Buffer { + return Buffer.concat([this.getDomainICRequest(), messageId]); + } + + /** + * Generates a read state object from an HTTP canister update. + * + * @param {HttpCanisterUpdate} update - The HTTP canister update object. + * @returns {ReadState} The read state object containing the sender, paths, and ingress expiry. + */ + makeReadStateFromUpdate(update: HttpCanisterUpdate): ReadState { return { - pub: keys.pub, - prv: keys.prv, + sender: update.sender, + paths: [[Buffer.from('request_status'), this.generateHttpCanisterUpdateId(update)]], + ingress_expiry: update.ingress_expiry, }; } + + /** + * Generates a representation-independent hash for an HTTP read state object. + * + * @param {ReadState} readState - The HTTP read state object. + * @returns {Buffer} - The hash of the read state object. + */ + HttpReadStateRepresentationIndependentHash(readState: ReadState): Buffer { + return this.hashOfMap({ + request_type: RequestType.READ_STATE, + ingress_expiry: readState.ingress_expiry, + paths: readState.paths, + sender: readState.sender, + }); + } + + /** + * Extracts the recipient information from the provided ICP transaction data. + * + * @param {IcpTransactionData} icpTransactionData - The ICP transaction data containing the receiver's address and amount. + * @returns {Recipient[]} An array containing a single recipient object with the receiver's address and amount. + */ + getRecipients(icpTransactionData: IcpTransactionData): Recipient[] { + return [ + { + address: icpTransactionData.receiverAddress, + amount: icpTransactionData.amount, + }, + ]; + } } const utils = new Utils(); diff --git a/modules/sdk-coin-icp/test/resources/icp.ts b/modules/sdk-coin-icp/test/resources/icp.ts new file mode 100644 index 0000000000..575c9bcb3b --- /dev/null +++ b/modules/sdk-coin-icp/test/resources/icp.ts @@ -0,0 +1,100 @@ +import { OperationType } from '../../src/lib/iface'; + +export const accounts = { + account1: { + secretKey: 'c5bccb8f471c5c9eb6483aa77ee4b700003b1e12df430a24d93238eb378b968b', + publicKey: + '042ab77b959e28c4fa47fa8fb9e57cec3d66df5684d076ac2e4c5f28fd69a23dd31a59f908c8add51eab3530b4ac5d015166eaf2198c52fa9a8df7cfaeb8fdb7d4', + address: '0af815da8259ba8bb3d34fbfb2ac730f07a1adc81438d40d667d91b408b25f2f', + }, + account2: { + secretKey: '73312c28d0d455b6a29a9a66811ffda94f3db6bfd57bf5c2bed917ee5928e15f', + publicKey: + '044e01707f70f6ad8d9f79e5f2c2f0bac5e91520e5e2491354c6c7827b59d44148847f9180ac9679a6ce66f69c330551a99f8f9b7419c437705602a54c258a9dfe', + address: 'c3d30f404955975adaba89f2e1ebc75c1f44a6a204578afce8f3780d64fe252e', + }, + account3: { + publicKey: + '042281378584012843130dce9b19002f88a949f237397e2f6cda2db1392d54f6345faaf51c384fbfe4e8f67eb12fdb53732d2ddfe7470f9310a0bf824dad3f6b1b', + secretKey: '7b4de3d8cc3e312c70f674b52f11818205546ca7036c8071997c46e429160dc3', + address: '50b59c953c9412823ada13c485656f853ec65cb58f164756429af53f06d3ab5f', + }, + account4: { + secretKey: 'c71d2779709061cc991b58bd79b0080cc125acb98b28c71ac5d63bca62e2b742', + publicKey: + '045b340975daf07887df1f32689dad303cb3e2869939d82f9225a79b0a2e56621f0c773070dc6316b36b796e7c92334c6897c9179b75efbd7a78c149f4b7a15cd9', + address: '8812eef6cf88b86ccf8d3e1b5d4aa3011025ec0c014aece4e8e9bdb02151392c', + }, + account5: { + secretKey: '8d08c0393b707cd90c37213025fe7ed13c05b267d946ca1b6e0fd3b0e47ec188', + publicKey: + '04c8e66bf2e02f15ebe8da05b74d105b54cde5114f13d4afdec8afad6aaeb621bacab8c119821ef079545413cb26ef71dd8ab681c0fcce6085648b3fe08d3cd109', + address: '6f1cd9940598e205b0affacff7fcdafa81700cd7b2d0c25f15803b955a12f100', + }, + account6: { + secretKey: '8d08c0393b707cd90c37213025fe7ed13c05b267d946ca1b6e0fd3b0e47ec188', + publicKey: '02ad010ce68b75266c723bf25fbe3a0c48eb29f14b25925b06b7f5026a0f12702e', + address: '2b9b89604362e185544c8bba76cadff1a3af26e1467e8530d13743a08a52dd7b', + }, + errorsAccounts: { + account1: { + secretKey: 'not ok', + publicKey: 'not ok', + address: 'not ok', + }, + account2: { + secretKey: 'test_test', + publicKey: 'test_test', + address: 'bo__wen', + }, + account3: { + publicKey: 'invalid-public-key', + secretKey: 'invalid-private-key', + address: 'me@google.com', + }, + account4: { + secretKey: '#$%', + publicKey: '#$%', + address: '$$$', + }, + account5: { + secretKey: 'qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm', + publicKey: 'qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm', + address: 'abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz', + }, + account6: { + secretKey: '', + publicKey: '', + address: '', + }, + }, +}; + +export const IcpTransactionData = { + senderAddress: accounts.account1.address, + receiverAddress: accounts.account2.address, + amount: '10', + fee: '-10000', + senderPublicKeyHex: accounts.account1.publicKey, + memo: 1740638136656000000, + transactionType: OperationType.TRANSACTION, + expiryTime: Date.now() * 1000_000 + 5 * 60 * 1000_000_000, +}; + +export const rawTransaction = { + outputAmount: '10', + inputAmount: '10', + spendAmount: '10', + externalOutputs: [ + { + amount: '10', + address: accounts.account2.address, + }, + ], + type: 'transaction', + address: accounts.account1.address, + seqno: 1740638136656000000, + spendAmountString: '10', + id: '5jTEPuDcMCeEgp1iyEbNBKsnhYz4F4c1EPDtRmxm3wCw', + expiryTime: Date.now() * 1000_000 + 5 * 60 * 1000_000_000, +}; diff --git a/modules/sdk-coin-icp/test/unit/icp.ts b/modules/sdk-coin-icp/test/unit/icp.ts index ffe5f02345..d41b9982d2 100644 --- a/modules/sdk-coin-icp/test/unit/icp.ts +++ b/modules/sdk-coin-icp/test/unit/icp.ts @@ -76,7 +76,7 @@ describe('Internet computer', function () { it('should throw an error when invalid public key is provided', async function () { await basecoin .getAddressFromPublicKey(invalidPublicKey) - .should.be.rejectedWith(`Public Key is not in a valid Hex Encoded Format`); + .should.be.rejectedWith(`Invalid hex-encoded public key format.`); }); it('should return valid address from a valid hex encoded public key', async function () { @@ -87,7 +87,7 @@ describe('Internet computer', function () { it('should throw an error when invalid public key is provided', async function () { await utils .getAddressFromPublicKey(invalidPublicKey) - .should.be.rejectedWith(`Public Key is not in a valid Hex Encoded Format`); + .should.be.rejectedWith(`Invalid hex-encoded public key format.`); }); }); describe('Generate wallet key pair: ', () => { diff --git a/modules/sdk-coin-icp/test/unit/keyPair.ts b/modules/sdk-coin-icp/test/unit/keyPair.ts new file mode 100644 index 0000000000..bcbfeb5b3f --- /dev/null +++ b/modules/sdk-coin-icp/test/unit/keyPair.ts @@ -0,0 +1,140 @@ +import should from 'should'; +import { KeyPair } from '../../src/lib/keyPair'; +import { randomBytes } from 'crypto'; + +describe('ICP KeyPair', () => { + describe('constructor', () => { + it('should generate a key pair with a random seed when no source is provided', () => { + const keyPair = new KeyPair(); + should.exist(keyPair); + const publicKey = keyPair.getKeys().pub; + const privateKey = keyPair.getKeys().prv; + should.exist(publicKey); + should.exist(privateKey); + publicKey.should.be.a.String(); + if (privateKey) { + privateKey.should.be.a.String(); + } + }); + + it('should generate a key pair from a given seed', () => { + const seed = randomBytes(32); + const keyPair = new KeyPair({ seed }); + should.exist(keyPair); + const publicKey = keyPair.getKeys().pub; + const privateKey = keyPair.getKeys().prv; + should.exist(publicKey); + should.exist(privateKey); + publicKey.should.be.a.String(); + if (privateKey) { + privateKey.should.be.a.String(); + } + }); + + it('should generate a key pair from a public key', () => { + const tempKeyPair = new KeyPair(); + const publicKey = tempKeyPair.getKeys().pub; + const keyPair = new KeyPair({ pub: publicKey }); + + should.exist(keyPair); + should.exist(keyPair.getKeys().pub); + should.equal(keyPair.getKeys().pub, publicKey); + }); + + it('should generate different key pairs for different seeds', () => { + const seed1 = randomBytes(32); + const seed2 = randomBytes(32); + const keyPair1 = new KeyPair({ seed: seed1 }); + const keyPair2 = new KeyPair({ seed: seed2 }); + + should.notEqual(keyPair1.getKeys().pub, keyPair2.getKeys().pub); + should.notEqual(keyPair1.getKeys().prv, keyPair2.getKeys().prv); + }); + + it('should generate the same key pair for the same seed', () => { + const seed = randomBytes(32); + const keyPair1 = new KeyPair({ seed }); + const keyPair2 = new KeyPair({ seed }); + + should.equal(keyPair1.getKeys().pub, keyPair2.getKeys().pub); + should.equal(keyPair1.getKeys().prv, keyPair2.getKeys().prv); + }); + }); + + describe('KeyPair getKeys()', () => { + it('should return valid public and private keys for a randomly generated key pair', () => { + const keyPair = new KeyPair(); + const keys = keyPair.getKeys(); + + should.exist(keys); + should.exist(keys.pub); + should.exist(keys.prv); + keys.pub.should.be.a.String(); + keys.pub.length.should.be.greaterThan(0); + if (keys.prv) { + keys.prv.should.be.a.String(); + keys.prv.length.should.be.greaterThan(0); + } + }); + + it('should return valid public and private keys for a key pair generated with a seed', () => { + const seed = randomBytes(32); + const keyPair = new KeyPair({ seed }); + const keys = keyPair.getKeys(); + + should.exist(keys); + should.exist(keys.pub); + should.exist(keys.prv); + keys.pub.should.be.a.String(); + if (keys.prv) { + keys.prv.should.be.a.String(); + } + }); + + it('should return only a public key when a key pair is generated from a public key', () => { + const tempKeyPair = new KeyPair(); + const publicKey = tempKeyPair.getKeys().pub; + const keyPair = new KeyPair({ pub: publicKey }); + const keys = keyPair.getKeys(); + + should.exist(keys); + should.exist(keys.pub); + should.equal(keys.pub, publicKey); + should.not.exist(keys.prv); + }); + + it('should generate consistent keys for the same seed', () => { + const seed = randomBytes(32); + const keyPair1 = new KeyPair({ seed }); + const keyPair2 = new KeyPair({ seed }); + + const keys1 = keyPair1.getKeys(); + const keys2 = keyPair2.getKeys(); + + should.equal(keys1.pub, keys2.pub); + should.equal(keys1.prv, keys2.prv); + }); + + it('should generate different keys for different seeds', () => { + const seed1 = randomBytes(32); + const seed2 = randomBytes(32); + const keyPair1 = new KeyPair({ seed: seed1 }); + const keyPair2 = new KeyPair({ seed: seed2 }); + + const keys1 = keyPair1.getKeys(); + const keys2 = keyPair2.getKeys(); + + should.notEqual(keys1.pub, keys2.pub); + should.notEqual(keys1.prv, keys2.prv); + }); + + it('should return a compressed public key', () => { + const keyPair = new KeyPair(); + const keys = keyPair.getKeys(); + + should.exist(keys.pub); + keys.pub.length.should.equal(66); // 33 bytes * 2 (hex) + keys.pub.startsWith('02').should.be.true() || keys.pub.startsWith('03').should.be.true(); + }); + }); +}); diff --git a/modules/sdk-coin-icp/test/unit/utils.ts b/modules/sdk-coin-icp/test/unit/utils.ts new file mode 100644 index 0000000000..8a77c99fb2 --- /dev/null +++ b/modules/sdk-coin-icp/test/unit/utils.ts @@ -0,0 +1,365 @@ +import should from 'should'; +import utils from '../../src/lib/utils'; +import { accounts, IcpTransactionData } from '../resources/icp'; +import { encode } from 'cbor-x'; +import { randomBytes } from 'crypto'; + +describe('utils', () => { + describe('isValidAddress()', () => { + it('should validate addresses correctly', () => { + should.equal(utils.isValidAddress(accounts.account1.address), true); + should.equal(utils.isValidAddress(accounts.account2.address), true); + should.equal(utils.isValidAddress(accounts.account3.address), true); + should.equal(utils.isValidAddress(accounts.account4.address), true); + should.equal(utils.isValidAddress(accounts.account5.address), true); + should.equal(utils.isValidAddress(accounts.account6.address), true); + }); + + it('should invalidate wrong addresses correctly', () => { + should.equal(utils.isValidAddress(accounts.errorsAccounts.account1.address), false); + should.equal(utils.isValidAddress(accounts.errorsAccounts.account2.address), false); + should.equal(utils.isValidAddress(accounts.errorsAccounts.account3.address), false); + should.equal(utils.isValidAddress(accounts.errorsAccounts.account4.address), false); + should.equal(utils.isValidAddress(accounts.errorsAccounts.account5.address), false); + should.equal(utils.isValidAddress(accounts.errorsAccounts.account6.address), false); + }); + }); + + describe('gasData()', () => { + it('should return correct gas data', () => { + should.equal(utils.gasData(), '-10000'); + }); + }); + + describe('isValidPublicKey()', () => { + it('should validate public key correctly', () => { + should.equal(utils.isValidPublicKey(accounts.account1.publicKey), true); + should.equal(utils.isValidPublicKey(accounts.account2.publicKey), true); + should.equal(utils.isValidPublicKey(accounts.account3.publicKey), true); + should.equal(utils.isValidPublicKey(accounts.account4.publicKey), true); + should.equal(utils.isValidPublicKey(accounts.account5.publicKey), true); + should.equal(utils.isValidPublicKey(accounts.account6.publicKey), true); + }); + + it('should invalidate public key correctly', () => { + should.equal(utils.isValidPublicKey(accounts.errorsAccounts.account1.publicKey), false); + should.equal(utils.isValidPublicKey(accounts.errorsAccounts.account2.publicKey), false); + should.equal(utils.isValidPublicKey(accounts.errorsAccounts.account3.publicKey), false); + should.equal(utils.isValidPublicKey(accounts.errorsAccounts.account4.publicKey), false); + should.equal(utils.isValidPublicKey(accounts.errorsAccounts.account5.publicKey), false); + should.equal(utils.isValidPublicKey(accounts.errorsAccounts.account6.publicKey), false); + }); + }); + + describe('cborEncode()', () => { + it('should correctly encode a number', () => { + const value = 42; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode a string', () => { + const value = 'hello'; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode a boolean (true)', () => { + const value = true; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode a boolean (false)', () => { + const value = false; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode null', () => { + const value = null; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode an array', () => { + const value = [1, 2, 3]; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode an object', () => { + const value = { key: 'value' }; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode an empty object', () => { + const value = {}; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode an empty array', () => { + const value = []; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should correctly encode a nested object', () => { + const value = { a: 1, b: [2, 3], c: { d: 'hello' } }; + const expectedHex = Buffer.from(encode(value)).toString('hex'); + should.equal(utils.cborEncode(value), expectedHex); + }); + + it('should throw an error when encoding an undefined value', () => { + should.throws(() => utils.cborEncode(undefined), Error); + }); + + it('should throw an error when encoding a function', () => { + should.throws( + () => + utils.cborEncode(() => { + throw new Error('Value to encode cannot be undefined.'); + }), + Error + ); + }); + }); + + describe('cborDecode()', () => { + it('should correctly decode a CBOR-encoded string', () => { + const original = 'Hello, CBOR!'; + const encoded = encode(original); + const decoded = utils.cborDecode(encoded); + should.equal(decoded, original); + }); + + it('should correctly decode a CBOR-encoded number', () => { + const original = 42; + const encoded = encode(original); + const decoded = utils.cborDecode(encoded); + should.equal(decoded, original); + }); + + it('should correctly decode a CBOR-encoded boolean', () => { + const original = true; + const encoded = encode(original); + const decoded = utils.cborDecode(encoded); + should.equal(decoded, original); + }); + + it('should correctly decode a CBOR-encoded object', () => { + const original = { key: 'value', number: 100 }; + const encoded = encode(original); + const decoded = utils.cborDecode(encoded); + should.deepEqual(decoded, original); + }); + + it('should correctly decode a CBOR-encoded array', () => { + const original = [1, 'text', false]; + const encoded = encode(original); + const decoded = utils.cborDecode(encoded); + should.deepEqual(decoded, original); + }); + + it('should return undefined for an empty CBOR buffer', () => { + const encoded = encode(undefined); + const decoded = utils.cborDecode(encoded); + should.equal(decoded, undefined); + }); + + it('should throw an error for an invalid CBOR buffer', () => { + should.throws(() => utils.cborDecode(Buffer.from('invalid data')), Error); + }); + }); + + describe('isValidLength()', () => { + it('should return true for a valid compressed public key length (66 characters)', () => { + should.equal(utils.isValidLength('a'.repeat(66)), true); + }); + + it('should return true for a valid uncompressed public key length (130 characters)', () => { + should.equal(utils.isValidLength('a'.repeat(130)), true); + }); + + it('should return false for a string shorter than 66 characters', () => { + should.equal(utils.isValidLength('a'.repeat(64)), false); + }); + + it('should return false for a string longer than 66 but shorter than 130 characters', () => { + should.equal(utils.isValidLength('a'.repeat(100)), false); + }); + + it('should return false for a string longer than 130 characters', () => { + should.equal(utils.isValidLength('a'.repeat(132)), false); + }); + + it('should return false for an empty string', () => { + should.equal(utils.isValidLength(''), false); + }); + + it('should return false for a non-hexadecimal string', () => { + should.equal(utils.isValidLength('ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'), false); + }); + }); + + describe('isValidHex()', () => { + it('should return true for a valid hexadecimal string', () => { + should.equal(utils.isValidHex('abcdef1234567890ABCDEF'), true); + }); + + it('should return true for a valid hex string with even length', () => { + should.equal(utils.isValidHex('a1b2c3d4e5f6'), true); + }); + + it('should return false for a string with an odd number of characters', () => { + should.equal(utils.isValidHex('abcde'), false); + }); + + it('should return false for a string containing non-hex characters', () => { + should.equal(utils.isValidHex('xyz123'), false); + should.equal(utils.isValidHex('12345G'), false); + should.equal(utils.isValidHex('1234@!'), false); + }); + + it('should return false for an empty string', () => { + should.equal(utils.isValidHex(''), false); + }); + + it('should return false for a string with spaces', () => { + should.equal(utils.isValidHex('abcdef 123456'), false); + }); + + it('should return false for a string with mixed valid and invalid characters', () => { + should.equal(utils.isValidHex('abcd123!@#'), false); + }); + }); + + describe('hexToBytes()', () => { + it('should correctly convert a valid hexadecimal string to a Uint8Array', () => { + const hex = 'abcdef123456'; + const expected = new Uint8Array([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56]); + should.deepEqual(utils.hexToBytes(hex), expected); + }); + + it('should correctly convert an uppercase hexadecimal string to a Uint8Array', () => { + const hex = 'ABCDEF123456'; + const expected = new Uint8Array([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56]); + should.deepEqual(utils.hexToBytes(hex), expected); + }); + + it('should return an empty Uint8Array for an empty string', () => { + should.deepEqual(utils.hexToBytes(''), new Uint8Array([])); + }); + + it('should correctly convert a single byte hexadecimal string', () => { + const hex = '0a'; + const expected = new Uint8Array([0x0a]); + should.deepEqual(utils.hexToBytes(hex), expected); + }); + + it('should correctly convert a multi-byte hexadecimal string', () => { + const hex = 'ff00ff'; + const expected = new Uint8Array([0xff, 0x00, 0xff]); + should.deepEqual(utils.hexToBytes(hex), expected); + }); + }); + + describe('isValidPrivateKey()', () => { + it('should validate private key correctly', () => { + should.equal(utils.isValidPrivateKey(accounts.account1.secretKey), true); + should.equal(utils.isValidPrivateKey(accounts.account2.secretKey), true); + should.equal(utils.isValidPrivateKey(accounts.account3.secretKey), true); + should.equal(utils.isValidPrivateKey(accounts.account4.secretKey), true); + should.equal(utils.isValidPrivateKey(accounts.account5.secretKey), true); + should.equal(utils.isValidPrivateKey(accounts.account6.secretKey), true); + }); + + it('should invalidate private key correctly', () => { + should.equal(utils.isValidPrivateKey(accounts.errorsAccounts.account1.secretKey), false); + should.equal(utils.isValidPrivateKey(accounts.errorsAccounts.account2.secretKey), false); + should.equal(utils.isValidPrivateKey(accounts.errorsAccounts.account3.secretKey), false); + should.equal(utils.isValidPrivateKey(accounts.errorsAccounts.account4.secretKey), false); + should.equal(utils.isValidPrivateKey(accounts.errorsAccounts.account5.secretKey), false); + should.equal(utils.isValidPrivateKey(accounts.errorsAccounts.account6.secretKey), false); + }); + }); + + describe('getAddressFromPublicKey()', () => { + it('should return the correct address for a valid public key', async () => { + const address1 = await utils.getAddressFromPublicKey(accounts.account1.publicKey); + should.equal(address1, accounts.account1.address); + const address2 = await utils.getAddressFromPublicKey(accounts.account1.publicKey); + should.equal(address2, accounts.account1.address); + const address3 = await utils.getAddressFromPublicKey(accounts.account1.publicKey); + should.equal(address3, accounts.account1.address); + const address4 = await utils.getAddressFromPublicKey(accounts.account1.publicKey); + should.equal(address4, accounts.account1.address); + const address5 = await utils.getAddressFromPublicKey(accounts.account1.publicKey); + should.equal(address5, accounts.account1.address); + const address6 = await utils.getAddressFromPublicKey(accounts.account1.publicKey); + should.equal(address6, accounts.account1.address); + }); + + it('should throw an error for an invalid public key', async () => { + await should(utils.getAddressFromPublicKey(accounts.errorsAccounts.account1.publicKey)).be.rejectedWith( + 'Invalid hex-encoded public key format.' + ); + }); + }); + + describe('generateKeyPair()', () => { + it('should generate a valid key pair without a seed', () => { + const keyPair = utils.generateKeyPair(); + should.exist(keyPair); + should.exist(keyPair.pub); + should.exist(keyPair.prv); + }); + + it('should generate a valid key pair with a given seed', () => { + const seed = randomBytes(32); + const keyPair = utils.generateKeyPair(seed); + should.exist(keyPair); + should.exist(keyPair.pub); + should.exist(keyPair.prv); + }); + + it('should generate different key pairs for different seeds', () => { + const seed1 = randomBytes(32); + const seed2 = randomBytes(32); + const keyPair1 = utils.generateKeyPair(seed1); + const keyPair2 = utils.generateKeyPair(seed2); + + should.notEqual(keyPair1.pub, keyPair2.pub); + should.notEqual(keyPair1.prv, keyPair2.prv); + }); + + it('should generate the same key pair for the same seed', () => { + const seed = randomBytes(32); + const keyPair1 = utils.generateKeyPair(seed); + const keyPair2 = utils.generateKeyPair(seed); + + should.equal(keyPair1.pub, keyPair2.pub); + should.equal(keyPair1.prv, keyPair2.prv); + }); + }); + + describe('validateRawTransaction()', () => { + const data = IcpTransactionData; + it('should validate icpTransactionData correctly', () => { + utils.validateRawTransaction(data); + }); + it('should throw an error for invalid expiryTime', () => { + (data.expiryTime = Date.now()), should.throws(() => utils.validateRawTransaction(data), 'Invalid expiry time'); + }); + it('should throw an error for invalid fee', () => { + data.fee = '-100'; + should.throws(() => utils.validateRawTransaction(data), 'Invalid fee value'); + }); + it('should throw an error for invalid amount', () => { + data.amount = '0'; + should.throws(() => utils.validateRawTransaction(data), 'amount cannot be less than or equal to zero'); + }); + }); +});