diff --git a/modules/sdk-coin-cosmos/package.json b/modules/sdk-coin-cosmos/package.json index 37d6266c0d..15b4411557 100644 --- a/modules/sdk-coin-cosmos/package.json +++ b/modules/sdk-coin-cosmos/package.json @@ -41,11 +41,15 @@ }, "dependencies": { "@bitgo/abstract-cosmos": "^11.10.0", + "@bitgo/sdk-api": "^1.65.2", "@bitgo/sdk-core": "^35.9.0", "@bitgo/statics": "^55.3.0", "@cosmjs/amino": "^0.29.5", + "@cosmjs/encoding": "^0.29.5", "@cosmjs/stargate": "^0.29.5", "bignumber.js": "^9.1.1" }, - "devDependencies": {} + "devDependencies": { + "@bitgo/sdk-test": "^8.0.98" + } } diff --git a/modules/sdk-coin-cosmos/test/unit/index.ts b/modules/sdk-coin-cosmos/test/resources/index.ts similarity index 100% rename from modules/sdk-coin-cosmos/test/unit/index.ts rename to modules/sdk-coin-cosmos/test/resources/index.ts diff --git a/modules/sdk-coin-cosmos/test/testUtils/generators.ts b/modules/sdk-coin-cosmos/test/testUtils/generators.ts new file mode 100644 index 0000000000..389f5fb978 --- /dev/null +++ b/modules/sdk-coin-cosmos/test/testUtils/generators.ts @@ -0,0 +1,244 @@ +/** + * Data generation functions for Cosmos SDK test data + * This file contains functions to generate test data for Cosmos SDK-based coins + */ + +import { + ChainConfig, + DefaultValues, + TestTransaction, + TestAddresses, + TestBlockHashes, + TestTxIds, + TestCoinAmounts, + GasBudget, + TransactionMessage, + CoinTestData, +} from './types'; + +/** + * Generate addresses for testing + * @param {DefaultValues} defaults - Default values containing base addresses + * @returns {TestAddresses} Object containing various test addresses + */ +export const generateAddresses = (defaults: DefaultValues): TestAddresses => { + return { + address1: defaults.senderAddress, + address2: defaults.recipientAddress1, + address3: defaults.recipientAddress2 || '', + address4: defaults.senderAddress.slice(0, -1), // remove last character to make invalid + address5: defaults.senderAddress + 'x', // add random character to make invalid + address6: defaults.senderAddress.replace(/[a-z]/, '.'), // add dot to make invalid + validatorAddress1: defaults.validatorAddress1, + validatorAddress2: defaults.validatorAddress2, + validatorAddress3: defaults.validatorAddress1 + 'sd', // extra characters to make invalid + validatorAddress4: defaults.validatorAddress1.replace('1', 'x'), // change character to make invalid + noMemoIdAddress: defaults.recipientAddress1, + validMemoIdAddress: defaults.recipientAddress1 + '?memoId=2', + invalidMemoIdAddress: defaults.recipientAddress1 + '?memoId=1.23', + multipleMemoIdAddress: defaults.recipientAddress1 + '?memoId=3&memoId=12', + }; +}; + +/** + * Generate transaction IDs from test transactions + * @param {Object} testTxs - Object containing test transactions + * @returns {TestTxIds} Object containing transaction IDs + */ +export const generateTxIds = (testTxs: { [key: string]: TestTransaction }): TestTxIds => { + return { + hash1: testTxs.TEST_SEND_TX.hash, + hash2: testTxs.TEST_SEND_TX2.hash, + hash3: testTxs.TEST_SEND_MANY_TX.hash, + }; +}; + +/** + * Generate coin amounts for testing + * @param {string} baseDenom - Base denomination of the coin + * @returns {TestCoinAmounts} Object containing various test amounts + */ +export const generateCoinAmounts = (baseDenom: string): TestCoinAmounts => { + return { + amount1: { amount: '1', denom: baseDenom }, + amount2: { amount: '10', denom: baseDenom }, + amount3: { amount: '100', denom: baseDenom }, + amount4: { amount: '-1', denom: baseDenom }, + amount5: { amount: '100', denom: 'm' + baseDenom }, + }; +}; + +/** + * Generate a standard transaction message for sending tokens + * @param {string} fromAddress - The sender address + * @param {string} toAddress - The recipient address + * @param {string} denom - The token denomination + * @param {string} amount - The amount to send + * @returns {TransactionMessage} A transaction message + */ +export const generateSendMessage = ( + fromAddress: string, + toAddress: string, + denom: string, + amount: string +): TransactionMessage => { + return { + typeUrl: '/cosmos.bank.v1beta1.MsgSend', + value: { + amount: [ + { + denom, + amount, + }, + ], + fromAddress, + toAddress, + }, + }; +}; + +/** + * Generate a standard gas budget + * @param {string} denom - The token denomination + * @param {string} amount - The gas amount + * @param {number} gasLimit - The gas limit + * @returns {GasBudget} A gas budget + */ +export const generateGasBudget = (denom: string, amount: string, gasLimit = 500000): GasBudget => { + return { + amount: [{ denom, amount }], + gasLimit, + }; +}; + +/** + * Generate complete coin test data + * @param {ChainConfig} chainConfig - Chain configuration + * @param {DefaultValues} defaults - Default values + * @param {TestBlockHashes} blockHashes - Block hashes for testing + * @param {Object} testTxs - Test transactions + * @returns {CoinTestData} Complete coin test data + */ +export const generateCoinData = ( + chainConfig: ChainConfig, + defaults: DefaultValues, + blockHashes: TestBlockHashes, + testTxs: { [key: string]: TestTransaction } +): CoinTestData => { + const addresses = generateAddresses(defaults); + const txIds = generateTxIds(testTxs); + const coinAmounts = generateCoinAmounts(chainConfig.baseDenom); + + return { + mainnetName: chainConfig.mainnetName, + mainnetCoin: chainConfig.mainnetCoin, + testnetCoin: chainConfig.testnetCoin, + testnetName: chainConfig.testnetName, + family: chainConfig.family, + decimalPlaces: chainConfig.decimalPlaces, + baseDenom: chainConfig.baseDenom, + chainId: chainConfig.chainId, + senderAddress: defaults.senderAddress, + pubKey: defaults.pubKey, + privateKey: defaults.privateKey, + validatorPrefix: chainConfig.validatorPrefix, + addressPrefix: chainConfig.addressPrefix, + addresses, + blockHashes, + txIds, + coinAmounts, + testSendTx: { + ...testTxs.TEST_SEND_TX, + sender: testTxs.TEST_SEND_TX.sender || defaults.senderAddress, + recipient: testTxs.TEST_SEND_TX.recipient || defaults.recipientAddress1, + chainId: chainConfig.chainId, + sendAmount: testTxs.TEST_SEND_TX.sendAmount || defaults.sendAmount, + feeAmount: testTxs.TEST_SEND_TX.feeAmount || defaults.feeAmount, + privateKey: testTxs.TEST_SEND_TX.privateKey || defaults.privateKey, + pubKey: testTxs.TEST_SEND_TX.pubKey || defaults.pubKey, + gasBudget: generateGasBudget( + chainConfig.baseDenom, + testTxs.TEST_SEND_TX.feeAmount || defaults.feeAmount, + defaults.gasLimit + ), + sendMessage: generateSendMessage( + testTxs.TEST_SEND_TX.sender || defaults.senderAddress, + testTxs.TEST_SEND_TX.recipient || defaults.recipientAddress1, + chainConfig.baseDenom, + testTxs.TEST_SEND_TX.sendAmount || defaults.sendAmount + ), + }, + testSendTx2: { + ...testTxs.TEST_SEND_TX2, + sender: testTxs.TEST_SEND_TX2.sender || defaults.senderAddress, + recipient: testTxs.TEST_SEND_TX2.recipient || defaults.recipientAddress1, + chainId: chainConfig.chainId, + sendAmount: testTxs.TEST_SEND_TX2.sendAmount || defaults.sendAmount, + feeAmount: testTxs.TEST_SEND_TX2.feeAmount || defaults.feeAmount, + privateKey: testTxs.TEST_SEND_TX2.privateKey || defaults.privateKey, + pubKey: testTxs.TEST_SEND_TX2.pubKey || defaults.pubKey, + gasBudget: generateGasBudget( + chainConfig.baseDenom, + testTxs.TEST_SEND_TX2.feeAmount || defaults.feeAmount, + defaults.gasLimit + ), + sendMessage: generateSendMessage( + testTxs.TEST_SEND_TX2.sender || defaults.senderAddress, + testTxs.TEST_SEND_TX2.recipient || defaults.recipientAddress1, + chainConfig.baseDenom, + testTxs.TEST_SEND_TX2.sendAmount || defaults.sendAmount + ), + }, + testSendManyTx: { + ...testTxs.TEST_SEND_MANY_TX, + sender: testTxs.TEST_SEND_MANY_TX.sender || defaults.senderAddress, + chainId: chainConfig.chainId, + sendAmount: testTxs.TEST_SEND_MANY_TX.sendAmount || defaults.sendAmount, + sendAmount2: testTxs.TEST_SEND_MANY_TX.sendAmount2 || defaults.sendAmount2, + feeAmount: testTxs.TEST_SEND_MANY_TX.feeAmount || defaults.feeAmount, + pubKey: testTxs.TEST_SEND_MANY_TX.pubKey || defaults.pubKey, + privateKey: testTxs.TEST_SEND_MANY_TX.privateKey || defaults.privateKey, + memo: '', + gasBudget: generateGasBudget( + chainConfig.baseDenom, + testTxs.TEST_SEND_MANY_TX.feeAmount || defaults.feeAmount, + defaults.gasLimit + ), + sendMessages: [ + generateSendMessage( + testTxs.TEST_SEND_MANY_TX.sender || defaults.senderAddress, + testTxs.TEST_SEND_MANY_TX.recipient || defaults.recipientAddress1, + chainConfig.baseDenom, + testTxs.TEST_SEND_MANY_TX.sendAmount || defaults.sendAmount + ), + generateSendMessage( + testTxs.TEST_SEND_MANY_TX.sender || defaults.senderAddress, + testTxs.TEST_SEND_MANY_TX.recipient2 || defaults.recipientAddress1, + chainConfig.baseDenom, + testTxs.TEST_SEND_MANY_TX.sendAmount2 || defaults.sendAmount + ), + ], + }, + testTxWithMemo: { + ...testTxs.TEST_TX_WITH_MEMO, + sender: testTxs.TEST_TX_WITH_MEMO.sender || defaults.senderAddress, + recipient: testTxs.TEST_TX_WITH_MEMO.recipient || defaults.recipientAddress1, + chainId: chainConfig.chainId, + sendAmount: testTxs.TEST_TX_WITH_MEMO.sendAmount || defaults.sendAmount, + feeAmount: testTxs.TEST_TX_WITH_MEMO.feeAmount || defaults.feeAmount, + pubKey: testTxs.TEST_TX_WITH_MEMO.pubKey || defaults.pubKey, + privateKey: testTxs.TEST_TX_WITH_MEMO.privateKey || defaults.privateKey, + gasBudget: generateGasBudget( + chainConfig.baseDenom, + testTxs.TEST_TX_WITH_MEMO.feeAmount || defaults.feeAmount, + defaults.gasLimit + ), + sendMessage: generateSendMessage( + testTxs.TEST_TX_WITH_MEMO.sender || defaults.senderAddress, + testTxs.TEST_TX_WITH_MEMO.recipient || defaults.recipientAddress1, + chainConfig.baseDenom, + testTxs.TEST_TX_WITH_MEMO.sendAmount || defaults.sendAmount + ), + }, + }; +}; diff --git a/modules/sdk-coin-cosmos/test/testUtils/index.ts b/modules/sdk-coin-cosmos/test/testUtils/index.ts new file mode 100644 index 0000000000..5ce7c9b2aa --- /dev/null +++ b/modules/sdk-coin-cosmos/test/testUtils/index.ts @@ -0,0 +1,9 @@ +/** + * Main exports for Cosmos SDK test utilities + * This file re-exports all utilities from the testUtils directory + */ + +// Re-export everything from the other files +export * from './types'; +export * from './generators'; +export * from './utils'; diff --git a/modules/sdk-coin-cosmos/test/testUtils/types.ts b/modules/sdk-coin-cosmos/test/testUtils/types.ts new file mode 100644 index 0000000000..75c0445c20 --- /dev/null +++ b/modules/sdk-coin-cosmos/test/testUtils/types.ts @@ -0,0 +1,196 @@ +/** + * Type definitions for Cosmos SDK test data + * This file contains all interfaces used in the test framework + */ + +/** + * Interface for chain configuration + */ +export interface ChainConfig { + mainnetName: string; + mainnetCoin: string; + testnetName: string; + testnetCoin: string; + family: string; + decimalPlaces: number; + baseDenom: string; + chainId: string; + addressPrefix: string; + validatorPrefix: string; +} + +/** + * Interface for default values + */ +export interface DefaultValues { + senderAddress: string; + pubKey: string; + privateKey: string; + recipientAddress1: string; + recipientAddress2?: string; // Optional for multi-send transactions + sendMessageTypeUrl: string; + sendAmount: string; + sendAmount2?: string; // Optional for multi-send transactions + feeAmount: string; + gasLimit: number; + validatorAddress1: string; + validatorAddress2: string; +} + +/** + * Interface for test transaction + */ +export interface TestTransaction { + hash: string; + signature: string; + signedTxBase64: string; + accountNumber: number; + sequence: number; + sender?: string; + recipient?: string; + recipient2?: string; // Optional for multi-send transactions + memo?: string; + pubKey?: string; + privateKey?: string; + sendAmount?: string; + sendAmount2?: string; // Optional for multi-send transactions + feeAmount?: string; + gasBudget?: GasBudget; +} + +/** + * Interface for address data + */ +export interface TestAddresses { + address1: string; + address2: string; + address3: string; + address4?: string; + address5?: string; + address6?: string; + validatorAddress1: string; + validatorAddress2: string; + validatorAddress3?: string; + validatorAddress4?: string; + noMemoIdAddress: string; + validMemoIdAddress: string; + invalidMemoIdAddress: string; + multipleMemoIdAddress: string; +} + +/** + * Interface for block hash data + */ +export interface TestBlockHashes { + hash1: string; + hash2: string; +} + +/** + * Interface for transaction IDs + */ +export interface TestTxIds { + hash1: string; + hash2: string; + hash3: string; +} + +/** + * Interface for coin amounts + */ +export interface TestCoinAmounts { + amount1: { amount: string; denom: string }; + amount2: { amount: string; denom: string }; + amount3: { amount: string; denom: string }; + amount4: { amount: string; denom: string }; + amount5: { amount: string; denom: string }; +} + +/** + * Interface for a transaction message + */ +export interface TransactionMessage { + typeUrl: string; + value: any; +} + +/** + * Interface for gas budget + */ +export interface GasBudget { + amount: { denom: string; amount: string }[]; + gasLimit: number; +} + +/** + * Interface for a basic transaction + */ +export interface TestSendTransaction { + hash: string; + signature: string; + signedTxBase64: string; + sender: string; + recipient?: string; + chainId: string; + accountNumber: number; + sequence: number; + sendAmount?: string; + feeAmount: string; + sendMessage?: TransactionMessage; + gasBudget?: GasBudget; + memo?: string; + from?: string; + to?: string; + pubKey?: string; + privateKey?: string; +} + +/** + * Interface for a multi-send transaction + */ +export interface TestMultiSendTransaction { + hash: string; + signature: string; + signedTxBase64: string; + sender: string; + chainId: string; + accountNumber: number; + sequence: number; + memo?: string; + sendMessages?: TransactionMessage[]; + gasBudget?: GasBudget; + sendAmount?: string; + sendAmount2?: string; + recipient?: string; + recipient2?: string; + pubKey?: string; + privateKey?: string; + feeAmount?: string; +} + +/** + * Main interface for coin test data + */ +export interface CoinTestData { + mainnetName: string; + mainnetCoin: string; + testnetCoin: string; + testnetName: string; + family: string; + decimalPlaces: number; + baseDenom: string; + chainId: string; + senderAddress: string; + pubKey: string; + privateKey: string; + validatorPrefix: string; + addressPrefix: string; + addresses: TestAddresses; + blockHashes: TestBlockHashes; + txIds: TestTxIds; + coinAmounts: TestCoinAmounts; + testSendTx: TestSendTransaction; + testSendTx2: TestSendTransaction; + testSendManyTx: TestMultiSendTransaction; + testTxWithMemo: TestSendTransaction; +} diff --git a/modules/sdk-coin-cosmos/test/testUtils/utils.ts b/modules/sdk-coin-cosmos/test/testUtils/utils.ts new file mode 100644 index 0000000000..4f3809872d --- /dev/null +++ b/modules/sdk-coin-cosmos/test/testUtils/utils.ts @@ -0,0 +1,87 @@ +/** + * Utility functions for Cosmos SDK test data + * This file contains utility functions for test data + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { CoinTestData } from './types'; + +/** + * Get all available test coins + * @returns {string[]} Array of coin names that have test data + */ +export const getAvailableTestCoins = (): string[] => { + try { + // Get the resources directory path + const resourcesDir = path.join(__dirname, '../resources'); + // Read all files in the resources directory + const files = fs.readdirSync(resourcesDir); + // Filter for .ts files that are not index.ts + const coinFiles = files.filter((file) => file.endsWith('.ts') && file !== 'index.ts'); + // Extract coin names by removing the .ts extension + return coinFiles.map((file) => file.replace('.ts', '')); + } catch (error) { + // Fallback to hardcoded list if there's an error + console.warn('Failed to dynamically discover test coins:', error); + return ['cosmos', 'cronos']; + } +}; + +/** + * Load test data for a specific coin + * + * This utility abstracts away the path resolution logic, + * making it easier to load test data from anywhere in the test suite. + * + * @param {string} coinName - The coin name (e.g., 'cosmos', 'cronos') + * @returns {CoinTestData} The test data for the coin + * @throws {Error} If the test data for the coin is not found + */ +export const getTestData = (coinName: string): CoinTestData => { + try { + // Dynamic import of the coin-specific test data + return require(`../resources/${coinName}`).default; + } catch (e) { + throw new Error(`Test data for coin ${coinName} not found: ${e}`); + } +}; + +/** + * Get test data for all available coins + * + * @returns {Record} An object mapping coin names to their test data + */ +export const getAllTestData = (): Record => { + const availableCoins = getAvailableTestCoins(); + const result: Record = {}; + for (const coin of availableCoins) { + result[coin] = getTestData(coin); + } + return result; +}; + +/** + * Get the builder factory for a specific coin + * @param {string} coin - The coin name + * @returns {TransactionBuilderFactory} The transaction builder factory + */ +export const getBuilderFactory = (coin: string) => { + const coinConfig = coins.get(coin); + return new TransactionBuilderFactory(coinConfig); +}; + +/** + * Ensures that all required properties are present in the test transaction data + * This is useful for TypeScript type checking in test files + * + * @param {T} tx - The transaction data that might have optional properties + * @returns {Required} The same transaction data but with TypeScript treating all properties as non-optional + */ +export function ensureTransaction(tx: T): Required { + // This function doesn't actually modify the data + // It just tells TypeScript that all properties are present + return tx as Required; +} diff --git a/modules/sdk-coin-cosmos/test/unit/cosmosSharedCoin.ts b/modules/sdk-coin-cosmos/test/unit/cosmosSharedCoin.ts new file mode 100644 index 0000000000..2483e80063 --- /dev/null +++ b/modules/sdk-coin-cosmos/test/unit/cosmosSharedCoin.ts @@ -0,0 +1,330 @@ +import should from 'should'; +import BigNumber from 'bignumber.js'; +import sinon from 'sinon'; +import { fromBase64, toHex } from '@cosmjs/encoding'; +import { VerifyAddressOptions, VerifyTransactionOptions } from '@bitgo/sdk-core'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { coins, CosmosNetwork } from '@bitgo/statics'; +import { CosmosSharedCoin } from '../../src/cosmosSharedCoin'; +import { Utils } from '../../src/lib/utils'; +import { getAvailableTestCoins, getTestData } from '../testUtils'; + +describe('Cosmos Shared Coin', function () { + const availableCoins = getAvailableTestCoins(); + // TODO: COIN-5039 - Running tests for each coin in parallel to improve test performance + // Loop through each available coin and run tests + availableCoins.forEach((coinName) => { + describe(`${coinName.toUpperCase()} Cosmos Shared Coin`, function () { + let bitgo: TestBitGoAPI; + let cosmosCoin: CosmosSharedCoin; + let utils: Utils; + const testData = getTestData(coinName); + const testTx = testData.testSendTx as Required; + const testTxWithMemo = testData.testTxWithMemo as Required; + const coin = coins.get(testData.testnetCoin); + const network = coin.network as CosmosNetwork; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister(testData.testnetCoin, CosmosSharedCoin.createInstance); + bitgo.safeRegister(testData.mainnetCoin, CosmosSharedCoin.createInstance); + bitgo.initializeTestVars(); + cosmosCoin = bitgo.coin(testData.testnetCoin) as CosmosSharedCoin; + utils = new Utils(network); + }); + + it('should instantiate the coin', function () { + should.exist(cosmosCoin); + cosmosCoin.should.be.an.instanceof(CosmosSharedCoin); + }); + + it('should return the right info', function () { + const testCoin = bitgo.coin(testData.testnetCoin); + const mainnetCoin = bitgo.coin(testData.mainnetCoin); + testCoin.getChain().should.equal(testData.testnetCoin); + testCoin.getFamily().should.equal(testData.family); + testCoin.getFullName().should.equal(testData.testnetName); + testCoin.getBaseFactor().should.equal(Math.pow(10, testData.decimalPlaces)); + + mainnetCoin.getChain().should.equal(testData.mainnetCoin); + mainnetCoin.getFamily().should.equal(testData.family); + mainnetCoin.getFullName().should.equal(testData.mainnetName); + mainnetCoin.getBaseFactor().should.equal(Math.pow(10, testData.decimalPlaces)); + }); + + it('should throw if instantiated without a staticsCoin', function () { + const tempBitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + should(() => CosmosSharedCoin.createInstance(tempBitgo)).throwError( + 'missing required constructor parameter staticsCoin' + ); + }); + + it('should throw if instantiated with invalid network configuration', function () { + const tempBitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + const invalidCoin = Object.assign({}, coin, { network: {} }); + should(() => CosmosSharedCoin.createInstance(tempBitgo, invalidCoin)).throwError( + 'Invalid network configuration: missing required Cosmos network parameters' + ); + }); + + describe('getBaseFactor', function () { + it('should return the correct base factor', function () { + const baseFactor = cosmosCoin.getBaseFactor(); + should.equal(baseFactor, Math.pow(10, testData.decimalPlaces)); + }); + }); + + describe('getBuilder', function () { + it('should return a transaction builder', function () { + const builder = cosmosCoin.getBuilder(); + should.exist(builder); + }); + }); + + describe('getDenomination', function () { + it('should return the correct denomination', function () { + should.equal(cosmosCoin.getDenomination(), network.denom); + }); + }); + + describe('getGasAmountDetails', function () { + it('should return the correct gas amount details', function () { + const gasAmountDetails = cosmosCoin.getGasAmountDetails(); + should.deepEqual(gasAmountDetails, { + gasAmount: network.gasAmount, + gasLimit: network.gasLimit, + }); + }); + }); + + describe('getNetwork', function () { + it('should return the correct network', function () { + const returnedNetwork = cosmosCoin.getNetwork(); + should.deepEqual(returnedNetwork, network); + }); + }); + + describe('getKeyPair', function () { + it('should return a key pair', function () { + const keyPair = cosmosCoin.getKeyPair(toHex(fromBase64(testData.pubKey))); + should.exist(keyPair); + }); + }); + + describe('getAddressFromPublicKey', function () { + it('should return the correct address', function () { + const address = cosmosCoin.getAddressFromPublicKey(toHex(fromBase64(testData.pubKey))); + should.equal(address, testData.senderAddress); + }); + }); + + describe('Address Validation', () => { + it('should get address details without memoId', function () { + const addressDetails = cosmosCoin.getAddressDetails(testData.addresses.noMemoIdAddress); + addressDetails.address.should.equal(testData.addresses.noMemoIdAddress); + should.not.exist(addressDetails.memoId); + }); + + it('should get address details with memoId', function () { + const addressDetails = cosmosCoin.getAddressDetails(testData.addresses.validMemoIdAddress); + addressDetails.address.should.equal(testData.addresses.validMemoIdAddress.split('?')[0]); + if (addressDetails.memoId) { + addressDetails.memoId.should.equal('2'); + } else { + should.fail('Expected memoId to be defined', null); + } + }); + + it('should throw on invalid memo id address', () => { + (() => { + cosmosCoin.getAddressDetails(testData.addresses.invalidMemoIdAddress); + }).should.throw(); + }); + + it('should throw on multiple memo id address', () => { + (() => { + cosmosCoin.getAddressDetails(testData.addresses.multipleMemoIdAddress); + }).should.throw(); + }); + + it('should validate wallet receive address', async function () { + const receiveAddress = { + address: `${testData.addresses.address1}?memoId=7`, + coinSpecific: { + rootAddress: testData.addresses.address1, + memoID: '7', + }, + }; + // as VerifyTransactionOptions + const isValid = await cosmosCoin.isWalletAddress(receiveAddress as VerifyAddressOptions); + isValid.should.equal(true); + }); + + it('should validate account addresses correctly', () => { + should.equal(utils.isValidAddress(testData.addresses.address1), true); + should.equal(utils.isValidAddress(testData.addresses.address2), true); + should.equal(utils.isValidAddress('dfjk35y'), false); + should.equal(utils.isValidAddress(undefined as unknown as string), false); + should.equal(utils.isValidAddress(''), false); + should.equal(utils.isValidAddress(testData.addresses.validMemoIdAddress), true); + should.equal(utils.isValidAddress(testData.addresses.invalidMemoIdAddress), false); + should.equal(utils.isValidAddress(testData.addresses.multipleMemoIdAddress), false); + }); + + it('should validate validator addresses correctly', () => { + should.equal(utils.isValidValidatorAddress(testData.addresses.validatorAddress1), true); + should.equal(utils.isValidValidatorAddress(testData.addresses.validatorAddress2), true); + should.equal(utils.isValidValidatorAddress('dfjk35y'), false); + should.equal(utils.isValidValidatorAddress(undefined as unknown as string), false); + should.equal(utils.isValidValidatorAddress(''), false); + }); + }); + + describe('Verify transaction: ', () => { + it('should succeed to verify transaction', async function () { + const txPrebuild = { + txHex: testTx.signedTxBase64, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: testTx.recipient, + amount: testTx.sendAmount, + }, + ], + }; + const verification = {}; + const isTransactionVerified = await cosmosCoin.verifyTransaction({ + txParams, + txPrebuild, + verification, + } as VerifyTransactionOptions); + isTransactionVerified.should.equal(true); + }); + + it('should succeed to verify sendMany transaction', async function () { + const txPrebuild = { + txHex: testTx.signedTxBase64, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: testTx.recipient, + amount: testTx.sendAmount, + }, + ], + }; + const verification = {}; + const isTransactionVerified = await cosmosCoin.verifyTransaction({ + txParams, + txPrebuild, + verification, + } as VerifyTransactionOptions); + isTransactionVerified.should.equal(true); + }); + + it('should fail to verify transaction with invalid param', async function () { + const txPrebuild = {}; + const txParams = { recipients: undefined }; + await cosmosCoin + .verifyTransaction({ txParams, txPrebuild } as VerifyTransactionOptions) + .should.be.rejectedWith('missing required tx prebuild property txHex'); + }); + }); + + describe('Explain Transaction: ', () => { + it('should explain a transfer transaction', async function () { + const explainedTransaction = await cosmosCoin.explainTransaction({ + txHex: testTx.signedTxBase64, + }); + explainedTransaction.should.deepEqual({ + displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'type'], + id: testTx.hash, + outputs: [ + { + address: testTx.recipient, + amount: testTx.sendAmount, + }, + ], + outputAmount: testTx.sendAmount, + changeOutputs: [], + changeAmount: '0', + fee: { fee: testTx.feeAmount }, + type: 0, + }); + }); + + it('should explain a transfer transaction with memo', async function () { + const explainedTransaction = await cosmosCoin.explainTransaction({ + txHex: testTxWithMemo.signedTxBase64, + }); + explainedTransaction.should.deepEqual({ + displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'type'], + id: testTxWithMemo.hash, + outputs: [ + { + address: testTxWithMemo.recipient, + amount: testTxWithMemo.sendAmount, + memo: testTxWithMemo.memo, + }, + ], + outputAmount: testTxWithMemo.sendAmount, + changeOutputs: [], + changeAmount: '0', + fee: { fee: testTxWithMemo.feeAmount }, + type: 0, + }); + }); + + it('should fail to explain transaction with missing params', async function () { + try { + await cosmosCoin.explainTransaction({ txHex: '' }); + } catch (error) { + should.equal(error.message, 'missing required txHex parameter'); + } + }); + + it('should fail to explain transaction with invalid params', async function () { + try { + await cosmosCoin.explainTransaction({ txHex: 'randomString' }); + } catch (error) { + should.equal(error.message.startsWith('Invalid transaction:'), true); + } + }); + }); + + describe('Parse Transactions: ', () => { + const transferInputsResponse = { + address: testTx.recipient, + amount: new BigNumber(testTx.sendAmount).plus(testTx.feeAmount).toFixed(), + }; + + const transferOutputsResponse = { + address: testTx.recipient, + amount: testTx.sendAmount, + }; + + it('should parse a transfer transaction', async function () { + const parsedTransaction = await cosmosCoin.parseTransaction({ txHex: testTx.signedTxBase64 }); + + parsedTransaction.should.deepEqual({ + inputs: [transferInputsResponse], + outputs: [transferOutputsResponse], + }); + }); + + it('should fail to parse a transfer transaction when explainTransaction response is undefined', async function () { + const stub = sinon.stub(CosmosSharedCoin.prototype, 'explainTransaction'); + stub.resolves(undefined); + await cosmosCoin + .parseTransaction({ txHex: testTx.signedTxBase64 }) + .should.be.rejectedWith('Invalid transaction'); + stub.restore(); + }); + }); + }); + }); +}); diff --git a/modules/sdk-coin-cosmos/test/unit/keyPair.ts b/modules/sdk-coin-cosmos/test/unit/keyPair.ts new file mode 100644 index 0000000000..7cfede7fa5 --- /dev/null +++ b/modules/sdk-coin-cosmos/test/unit/keyPair.ts @@ -0,0 +1,93 @@ +import assert from 'assert'; +import should from 'should'; +import { fromBase64, toHex } from '@cosmjs/encoding'; +import { coins } from '@bitgo/statics'; +import { KeyPair } from '../../src'; +import { getAvailableTestCoins, getTestData } from '../testUtils'; + +describe('Cosmos KeyPair', function () { + const availableCoins = getAvailableTestCoins(); + // TODO: COIN-5039 - Running tests for each coin in parallel to improve test performance + // Loop through each available coin and run tests + availableCoins.forEach((coinName) => { + describe(`${coinName} KeyPair`, function () { + const testData = getTestData(coinName); + const coin = coins.get(testData.testnetCoin); + + describe('should create a valid KeyPair', () => { + it('from an empty value', () => { + const keyPairObj = new KeyPair(); + const keys = keyPairObj.getKeys(); + should.exists(keys.prv); + should.exists(keys.pub); + should.equal(keys.prv?.length, 64); + should.equal(keys.pub.length, 66); + + const extendedKeys = keyPairObj.getExtendedKeys(); + should.exists(extendedKeys.xprv); + should.exists(extendedKeys.xpub); + }); + + it('from a private key', () => { + const privateKey = testData.privateKey; + const keyPairObj = new KeyPair({ prv: toHex(fromBase64(privateKey)) }, coin); + const keys = keyPairObj.getKeys(); + should.exists(keys.prv); + should.exists(keys.pub); + should.equal(keys.prv, toHex(fromBase64(testData.privateKey))); + should.equal(keys.pub, toHex(fromBase64(testData.pubKey))); + should.equal(keyPairObj.getAddress(), testData.senderAddress); + + assert.throws(() => keyPairObj.getExtendedKeys()); + }); + }); + + describe('should fail to create a KeyPair', () => { + it('from an invalid privateKey', () => { + assert.throws( + () => new KeyPair({ prv: '' }, coin), + (e: any) => e.message === 'Unsupported private key' + ); + }); + + it('from an invalid publicKey', () => { + assert.throws( + () => new KeyPair({ pub: '' }, coin), + (e: any) => e.message.startsWith('Unsupported public key') + ); + }); + + it('from an undefined seed', () => { + const undefinedBuffer = undefined as unknown as Buffer; + assert.throws( + () => new KeyPair({ seed: undefinedBuffer }, coin), + (e: any) => e.message.startsWith('Invalid key pair options') + ); + }); + + it('from an undefined private key', () => { + const undefinedStr: string = undefined as unknown as string; + assert.throws( + () => new KeyPair({ prv: undefinedStr }, coin), + (e: any) => e.message.startsWith('Invalid key pair options') + ); + }); + + it('from an undefined public key', () => { + const undefinedStr: string = undefined as unknown as string; + assert.throws( + () => new KeyPair({ pub: undefinedStr }, coin), + (e: any) => e.message.startsWith('Invalid key pair options') + ); + }); + }); + + describe('get unique address ', () => { + it('from a private key', () => { + const keyPair = new KeyPair({ prv: toHex(fromBase64(testData.privateKey)) }, coin); + should.equal(keyPair.getAddress(), testData.senderAddress); + }); + }); + }); + }); +}); diff --git a/modules/sdk-coin-cosmos/test/unit/register.ts b/modules/sdk-coin-cosmos/test/unit/register.ts new file mode 100644 index 0000000000..c19693fa76 --- /dev/null +++ b/modules/sdk-coin-cosmos/test/unit/register.ts @@ -0,0 +1,56 @@ +import sinon from 'sinon'; +import should from 'should'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { CoinFeature, coins } from '@bitgo/statics'; +import { register } from '../../src/register'; +import { CosmosSharedCoin } from '../../src/cosmosSharedCoin'; + +describe('Cosmos Register', function () { + let bitgo: BitGoAPI; + let registerSpy: sinon.SinonSpy; + let cosmosCoins: Set; + + before(function () { + // Get all coins with the SHARED_COSMOS_SDK feature + cosmosCoins = new Set( + coins.filter((coin) => coin.features.includes(CoinFeature.SHARED_COSMOS_SDK)).map((coin) => coin.name) + ); + }); + + beforeEach(function () { + bitgo = new BitGoAPI({ env: 'test' }); + registerSpy = sinon.spy(bitgo, 'register'); + }); + + afterEach(function () { + registerSpy.restore(); + }); + + it('should register all cosmos coins', function () { + register(bitgo); + + // Verify that register was called for each cosmos coin + should.equal(registerSpy.callCount, cosmosCoins.size); + + // Verify that each call was for a cosmos coin with the correct factory + for (let i = 0; i < registerSpy.callCount; i++) { + const call = registerSpy.getCall(i); + const coinName = call.args[0]; + const factory = call.args[1]; + + should.ok(cosmosCoins.has(coinName), `${coinName} should be a cosmos coin`); + should.equal(factory, CosmosSharedCoin.createInstance); + } + }); + + it('should register each coin only once', function () { + register(bitgo); + + // Get the list of registered coins + const registeredCoins = registerSpy.getCalls().map((call) => call.args[0]); + + // Check for duplicates + const uniqueCoins = new Set(registeredCoins); + should.equal(uniqueCoins.size, registeredCoins.length, 'There should be no duplicate coin registrations'); + }); +}); diff --git a/modules/sdk-coin-cosmos/test/unit/transactionBuilder/transactionBuilder.ts b/modules/sdk-coin-cosmos/test/unit/transactionBuilder/transactionBuilder.ts new file mode 100644 index 0000000000..6c40ecf1a3 --- /dev/null +++ b/modules/sdk-coin-cosmos/test/unit/transactionBuilder/transactionBuilder.ts @@ -0,0 +1,48 @@ +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; + +import { getAvailableTestCoins, getBuilderFactory, getTestData } from '../../testUtils'; + +describe('Cosmos Transaction Builder', function () { + const availableCoins = getAvailableTestCoins(); + // TODO: COIN-5039 - Running tests for each coin in parallel to improve test performance + // Loop through each available coin and run tests + availableCoins.forEach((coinName) => { + describe(`${coinName.toUpperCase()} Transaction Builder`, function () { + const testData = getTestData(coinName); + const factory = getBuilderFactory(testData.testnetCoin); + const testTx = testData.testSendTx as Required; + + it('should build a signed tx from signed tx data', async function () { + const txBuilder = factory.from(testTx.signedTxBase64); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + // Should recreate the same raw tx data when re-build and turned to broadcast format + const rawTx = tx.toBroadcastFormat(); + should.equal(rawTx, testTx.signedTxBase64); + }); + + describe('gasBudget tests', async () => { + it('should succeed for valid gasBudget', function () { + const builder = factory.getTransferBuilder(); + should.doesNotThrow(() => builder.gasBudget(testTx.gasBudget!)); + }); + + it('should throw for invalid gasBudget', function () { + const builder = factory.getTransferBuilder(); + const invalidGasBudget = { amount: testTx.gasBudget!.amount, gasLimit: 0 }; + should(() => builder.gasBudget(invalidGasBudget)).throw('Invalid gas limit 0'); + }); + }); + + it('validateAddress', function () { + const builder = factory.getTransferBuilder(); + const invalidAddress = { address: 'randomString' }; + should.doesNotThrow(() => builder.validateAddress({ address: testTx.sender })); + should(() => builder.validateAddress(invalidAddress)).throwError( + 'transactionBuilder: address isValidAddress check failed: ' + invalidAddress.address + ); + }); + }); + }); +}); diff --git a/modules/sdk-coin-cosmos/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-cosmos/test/unit/transactionBuilder/transferBuilder.ts new file mode 100644 index 0000000000..652c6ab7da --- /dev/null +++ b/modules/sdk-coin-cosmos/test/unit/transactionBuilder/transferBuilder.ts @@ -0,0 +1,201 @@ +import { TransactionType } from '@bitgo/sdk-core'; +import { fromBase64, toHex } from '@cosmjs/encoding'; +import should from 'should'; +import { getAvailableTestCoins, getBuilderFactory, getTestData } from '../../testUtils'; + +describe('Cosmos Transfer Builder', function () { + const availableCoins = getAvailableTestCoins(); + // TODO: COIN-5039 - Running tests for each coin in parallel to improve test performance + // Loop through each available coin and run tests + availableCoins.forEach((coinName) => { + describe(`${coinName.toUpperCase()} Transfer Builder`, function () { + const testData = getTestData(coinName); + const factory = getBuilderFactory(testData.testnetCoin); + const testTx = testData.testSendTx as Required; + const testTx2 = testData.testSendTx2 as Required; + const testTxWithMemo = testData.testTxWithMemo as Required; + const testSendManyTx = testData.testSendManyTx as Required; + it('should build a Transfer tx with signature', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sequence(testTx.sequence); + txBuilder.gasBudget(testTx.gasBudget!); + txBuilder.messages([testTx.sendMessage!.value]); + txBuilder.publicKey(toHex(fromBase64(testTx.pubKey!))); + txBuilder.addSignature({ pub: toHex(fromBase64(testTx.pubKey)) }, Buffer.from(testTx.signature, 'base64')); + + const tx = await txBuilder.build(); + const json = await (await txBuilder.build()).toJson(); + should.equal(tx.type, TransactionType.Send); + should.deepEqual(json.gasBudget, testTx.gasBudget); + should.deepEqual(json.sendMessages, [testTx.sendMessage]); + should.deepEqual(json.publicKey, toHex(fromBase64(testTx.pubKey))); + should.deepEqual(json.sequence, testTx.sequence); + const rawTx = tx.toBroadcastFormat(); + should.equal(rawTx, testTx.signedTxBase64); + should.deepEqual(tx.inputs, [ + { + address: testTx.sender, + value: testTx.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + should.deepEqual(tx.outputs, [ + { + address: testTx.sendMessage.value.toAddress, + value: testTx.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + }); + + it('should build a Transfer tx with signature and memo', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sequence(testTxWithMemo.sequence); + txBuilder.gasBudget(testTxWithMemo.gasBudget); + txBuilder.messages([testTxWithMemo.sendMessage.value]); + txBuilder.publicKey(toHex(fromBase64(testTxWithMemo.pubKey))); + txBuilder.memo(testTxWithMemo.memo); + txBuilder.addSignature( + { pub: toHex(fromBase64(testTxWithMemo.pubKey)) }, + Buffer.from(testTxWithMemo.signature, 'base64') + ); + + const tx = await txBuilder.build(); + const json = await (await txBuilder.build()).toJson(); + should.equal(tx.type, TransactionType.Send); + should.deepEqual(json.gasBudget, testTxWithMemo.gasBudget); + should.deepEqual(json.sendMessages, [testTxWithMemo.sendMessage]); + should.deepEqual(json.publicKey, toHex(fromBase64(testTxWithMemo.pubKey))); + should.deepEqual(json.sequence, testTxWithMemo.sequence); + should.equal(json.memo, testTxWithMemo.memo); + const rawTx = tx.toBroadcastFormat(); + should.equal(rawTx, testTxWithMemo.signedTxBase64); + should.deepEqual(tx.inputs, [ + { + address: testTxWithMemo.sender, + value: testTxWithMemo.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + should.deepEqual(tx.outputs, [ + { + address: testTxWithMemo.sendMessage.value.toAddress, + value: testTxWithMemo.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + }); + + it('should build a Transfer tx without signature', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sequence(testTx.sequence); + txBuilder.gasBudget(testTx.gasBudget); + txBuilder.messages([testTx.sendMessage.value]); + txBuilder.publicKey(toHex(fromBase64(testTx.pubKey))); + const tx = await txBuilder.build(); + const json = await (await txBuilder.build()).toJson(); + should.equal(tx.type, TransactionType.Send); + should.deepEqual(json.gasBudget, testTx.gasBudget); + should.deepEqual(json.sendMessages, [testTx.sendMessage]); + should.deepEqual(json.publicKey, toHex(fromBase64(testTx.pubKey))); + should.deepEqual(json.sequence, testTx.sequence); + tx.toBroadcastFormat(); + should.deepEqual(tx.inputs, [ + { + address: testTx.sender, + value: testTx.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + should.deepEqual(tx.outputs, [ + { + address: testTx.sendMessage.value.toAddress, + value: testTx.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + }); + + it('should sign a Transfer tx', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sequence(testTx2.sequence); + txBuilder.gasBudget(testTx2.gasBudget); + txBuilder.messages([testTx2.sendMessage.value]); + txBuilder.accountNumber(testTx2.accountNumber); + txBuilder.chainId(testTx2.chainId); + txBuilder.sign({ key: toHex(fromBase64(testTx2.privateKey)) }); + const tx = await txBuilder.build(); + const json = await (await txBuilder.build()).toJson(); + should.equal(tx.type, TransactionType.Send); + should.deepEqual(json.gasBudget, testTx2.gasBudget); + should.deepEqual(json.sendMessages, [testTx2.sendMessage]); + should.deepEqual(json.publicKey, toHex(fromBase64(testTx2.pubKey))); + should.deepEqual(json.sequence, testTx2.sequence); + const rawTx = tx.toBroadcastFormat(); + should.equal(tx.signature[0], toHex(fromBase64(testTx2.signature))); + + should.equal(rawTx, testTx2.signedTxBase64); + should.deepEqual(tx.inputs, [ + { + address: testTx2.sender, + value: testTx2.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + should.deepEqual(tx.outputs, [ + { + address: testTx2.sendMessage.value.toAddress, + value: testTx2.sendMessage.value.amount[0].amount, + coin: testData.testnetCoin, + }, + ]); + }); + + it('should build a sendMany Transfer tx', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sequence(testSendManyTx.sequence); + txBuilder.gasBudget(testSendManyTx.gasBudget); + txBuilder.messages(testSendManyTx.sendMessages.map((msg) => msg.value)); + txBuilder.publicKey(toHex(fromBase64(testSendManyTx.pubKey))); + txBuilder.chainId(testSendManyTx.chainId); + txBuilder.accountNumber(testSendManyTx.accountNumber); + txBuilder.memo(testSendManyTx.memo); + txBuilder.addSignature( + { pub: toHex(fromBase64(testSendManyTx.pubKey)) }, + Buffer.from(testSendManyTx.signature, 'base64') + ); + + const tx = await txBuilder.build(); + const json = await (await txBuilder.build()).toJson(); + should.equal(tx.type, TransactionType.Send); + should.deepEqual(json.gasBudget, testSendManyTx.gasBudget); + should.deepEqual(json.sendMessages, testSendManyTx.sendMessages); + should.deepEqual(json.publicKey, toHex(fromBase64(testSendManyTx.pubKey))); + should.deepEqual(json.sequence, testSendManyTx.sequence); + should.deepEqual( + tx.inputs, + testSendManyTx.sendMessages.map((msg) => { + return { + address: msg.value.fromAddress, + value: msg.value.amount[0].amount, + coin: testData.testnetCoin, + }; + }) + ); + should.deepEqual( + tx.outputs, + testSendManyTx.sendMessages.map((msg) => { + return { + address: msg.value.toAddress, + value: msg.value.amount[0].amount, + coin: testData.testnetCoin, + }; + }) + ); + should.equal(tx.id, testSendManyTx.hash); + const rawTx = tx.toBroadcastFormat(); + should.equal(rawTx, testSendManyTx.signedTxBase64); + }); + }); + }); +}); diff --git a/modules/sdk-coin-cosmos/test/unit/utils.ts b/modules/sdk-coin-cosmos/test/unit/utils.ts new file mode 100644 index 0000000000..fe0f02e8fc --- /dev/null +++ b/modules/sdk-coin-cosmos/test/unit/utils.ts @@ -0,0 +1,96 @@ +import { Utils } from '../../src/lib/utils'; +import { coins, CosmosNetwork } from '@bitgo/statics'; +import { getAvailableTestCoins, getTestData } from '../testUtils'; +import should from 'should'; + +describe('Cosmos Utils', function () { + const availableCoins = getAvailableTestCoins(); + // TODO: COIN-5039 - Running tests for each coin in parallel to improve test performance + // Loop through each available coin and run tests + availableCoins.forEach((coinName) => { + describe(`${coinName} Utils`, function () { + const testData = getTestData(coinName); + const addresses = testData.addresses as Required; + const coin = coins.get(testData.testnetCoin); + const network = coin.network as CosmosNetwork; + const utils = new Utils(network); + + describe('isValidAddress', function () { + it('should return true for valid address', function () { + should.equal(utils.isValidAddress(addresses.address1), true); + should.equal(utils.isValidAddress(addresses.address2), true); + }); + + it('should return false for invalid address', function () { + should.equal(utils.isValidAddress(addresses.address6), false); + should.equal(utils.isValidAddress('invalid'), false); + }); + + it('should validate memo id addresses correctly', function () { + should.equal(utils.isValidAddress(addresses.noMemoIdAddress), true); + should.equal(utils.isValidAddress(addresses.validMemoIdAddress), true); + should.equal(utils.isValidAddress(addresses.invalidMemoIdAddress), false); + should.equal(utils.isValidAddress(addresses.multipleMemoIdAddress), false); + }); + }); + + describe('isValidValidatorAddress', function () { + it('should return true for valid validator address', function () { + should.equal(utils.isValidValidatorAddress(addresses.validatorAddress1), true); + should.equal(utils.isValidValidatorAddress(addresses.validatorAddress2), true); + }); + + it('should return false for invalid validator address', function () { + should.equal(utils.isValidValidatorAddress(addresses.address1), false); + should.equal(utils.isValidValidatorAddress('invalid'), false); + }); + }); + + describe('isValidContractAddress', function () { + it('should return true for valid contract address', function () { + // Contract addresses follow the same format as regular addresses + should.equal(utils.isValidContractAddress(addresses.address1), true); + should.equal(utils.isValidContractAddress(addresses.address2), true); + }); + + it('should return false for invalid contract address', function () { + should.equal(utils.isValidContractAddress(addresses.address6), false); + should.equal(utils.isValidContractAddress('invalid'), false); + }); + }); + + describe('validateAmount', function () { + it('should not throw for valid amount', function () { + should.doesNotThrow(() => { + utils.validateAmount(testData.coinAmounts.amount1); + utils.validateAmount(testData.coinAmounts.amount2); + utils.validateAmount(testData.coinAmounts.amount3); + }); + }); + + it('should throw for invalid amount', function () { + should(() => utils.validateAmount(testData.coinAmounts.amount4)).throwError( + `Invalid amount: '${testData.coinAmounts.amount4.amount}' is not a valid positive integer` + ); + }); + + it('should throw for invalid denom', function () { + should(() => utils.validateAmount({ denom: 'invalid', amount: '100' })).throwError( + `Invalid amount: denom 'invalid' is not a valid denomination` + ); + }); + + it('should throw for missing denom', function () { + should(() => utils.validateAmount({ amount: '100' } as any)).throwError(`Invalid amount: missing denom`); + }); + + it('should throw for missing amount', function () { + should.throws( + () => utils.validateAmount({ denom: testData.baseDenom } as any), + 'Invalid amount: missing amount' + ); + }); + }); + }); + }); +});