diff --git a/packages/crypto-wallet-core/src/derivation/index.ts b/packages/crypto-wallet-core/src/derivation/index.ts index 2e61381f761..532ffc48aa5 100644 --- a/packages/crypto-wallet-core/src/derivation/index.ts +++ b/packages/crypto-wallet-core/src/derivation/index.ts @@ -27,36 +27,72 @@ const derivers: { [chain: string]: IDeriver } = { }; export class DeriverProxy { - private get(chain) { + /** + * Returns the list of supported chain identifiers. + * + * @returns {string[]} Array of supported chain names (uppercase) + */ + getSupportedChains(): string[] { + return Object.keys(derivers); + } + + /** + * Returns whether a given chain is supported by the deriver proxy. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @returns {boolean} True if the chain is supported + */ + isSupported(chain: string): boolean { + if (!chain || typeof chain !== 'string') { + return false; + } + return chain.toUpperCase() in derivers; + } + + /** + * Retrieves the deriver implementation for a given chain. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @returns {IDeriver} The deriver instance for the chain + * @throws {Error} If the chain is not provided or not supported + */ + private get(chain: string): IDeriver { + if (!chain || typeof chain !== 'string') { + throw new Error('Chain must be a non-empty string'); + } const normalizedChain = chain.toUpperCase(); - return derivers[normalizedChain]; + const deriver = derivers[normalizedChain]; + if (!deriver) { + throw new Error(`Unsupported chain: ${chain}. Supported chains: ${this.getSupportedChains().join(', ')}`); + } + return deriver; } /** - * This is derives addresses using the conventional paths. - * @param chain - * @param network - * @param xpubKey - * @param addressIndex - * @param isChange - * @param addressType - * @returns + * This derives addresses using the conventional paths. + * @param {string} chain - The chain identifier + * @param {string} network - The network name + * @param {string} xpubKey - The extended public key + * @param {number} addressIndex - The address index + * @param {boolean} isChange - Whether this is a change address + * @param {string} [addressType] - Optional address type + * @returns The derived address */ - deriveAddress(chain, network, xpubKey, addressIndex, isChange, addressType?) { + deriveAddress(chain: string, network: string, xpubKey: string, addressIndex: number, isChange: boolean, addressType?: string) { return this.get(chain).deriveAddress(network, xpubKey, addressIndex, isChange, addressType); } /** * This derives keys/addresses using the conventional paths. - * @param chain - * @param network - * @param privKey - * @param addressIndex - * @param isChange - * @param addressType - * @returns + * @param {string} chain - The chain identifier + * @param {string} network - The network name + * @param {string} privKey - The private key + * @param {number} addressIndex - The address index + * @param {boolean} isChange - Whether this is a change address + * @param {string} [addressType] - Optional address type + * @returns The derived private key info */ - derivePrivateKey(chain, network, privKey, addressIndex, isChange, addressType?) { + derivePrivateKey(chain: string, network: string, privKey: string, addressIndex: number, isChange: boolean, addressType?: string) { return this.get(chain).derivePrivateKey(network, privKey, addressIndex, isChange, addressType); } @@ -65,14 +101,14 @@ export class DeriverProxy { * This should probably only be used when importing from another wallet * where known paths are provided with their keys. Most of the BitPay * codebase uses `deriveAddress()` - * @param chain - * @param network - * @param xpubKey - * @param path - * @param addressType - * @returns + * @param {string} chain - The chain identifier + * @param {string} network - The network name + * @param {string} xpubKey - The extended public key + * @param {string} path - The derivation path + * @param {string} addressType - The address type + * @returns The derived address */ - deriveAddressWithPath(chain, network, xpubKey, path, addressType) { + deriveAddressWithPath(chain: string, network: string, xpubKey: string, path: string, addressType: string) { return this.get(chain).deriveAddressWithPath(network, xpubKey, path, addressType); } @@ -81,31 +117,42 @@ export class DeriverProxy { * This should probably only be used when importing from another wallet * where known paths are provided with their keys. Most of the BitPay * codebase uses `derivePrivateKey()` - * @param chain - * @param network - * @param xprivKey - * @param path - * @param addressType - * @returns + * @param {string} chain - The chain identifier + * @param {string} network - The network name + * @param {string} xprivKey - The extended private key + * @param {string} path - The derivation path + * @param {string} addressType - The address type + * @returns The derived private key info */ - derivePrivateKeyWithPath(chain, network, xprivKey, path, addressType) { + derivePrivateKeyWithPath(chain: string, network: string, xprivKey: string, path: string, addressType: string) { return this.get(chain).derivePrivateKeyWithPath(network, xprivKey, path, addressType); } /** * This is a simple function for getting an address from a * given pub key and chain. There is no derivation happening. - * @param chain - * @param network - * @param pubKey - * @param addressType - * @returns + * @param {string} chain - The chain identifier + * @param {string} network - The network name + * @param {string} pubKey - The public key + * @param {string} [addressType] - Optional address type + * @returns The address */ - getAddress(chain, network, pubKey, addressType?) { + getAddress(chain: string, network: string, pubKey: string, addressType?: string) { return this.get(chain).getAddress(network, pubKey, addressType); } - pathFor(chain, network, account = 0) { + /** + * Returns the BIP44 derivation path for a given chain and network. + * + * @param {string} chain - The chain identifier + * @param {string} network - The network name + * @param {number} [account=0] - The account index + * @returns {string} The derivation path + */ + pathFor(chain: string, network: string, account: number = 0): string { + if (!chain || typeof chain !== 'string') { + throw new Error('Chain must be a non-empty string'); + } const normalizedChain = chain.toUpperCase(); const accountStr = `${account}'`; const chainConfig = Paths[normalizedChain]; diff --git a/packages/crypto-wallet-core/src/transactions/index.ts b/packages/crypto-wallet-core/src/transactions/index.ts index e1364c908cb..f8f68b0417f 100644 --- a/packages/crypto-wallet-core/src/transactions/index.ts +++ b/packages/crypto-wallet-core/src/transactions/index.ts @@ -46,36 +46,76 @@ const providers = { }; export class TransactionsProxy { - get({ chain }) { + /** + * Returns the list of supported chain/token identifiers. + * + * @returns {string[]} Array of supported chain names (uppercase) + */ + getSupportedChains(): string[] { + return Object.keys(providers); + } + + /** + * Returns whether a given chain is supported by the transactions proxy. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @returns {boolean} True if the chain is supported + */ + isSupported(chain: string): boolean { + if (!chain || typeof chain !== 'string') { + return false; + } + return chain.toUpperCase() in providers; + } + + /** + * Retrieves the transaction provider for a given chain. + * + * @param {{ chain: string }} params - Object containing the chain identifier + * @returns The transaction provider for the chain + * @throws {Error} If the chain is not provided or not supported + */ + get(params: { chain: string }) { + if (!params || typeof params !== 'object') { + throw new Error('Params must be an object with a "chain" property'); + } + const chain = params.chain; + if (!chain || typeof chain !== 'string') { + throw new Error('Chain must be a non-empty string'); + } const normalizedChain = chain.toUpperCase(); - return providers[normalizedChain]; + const provider = providers[normalizedChain]; + if (!provider) { + throw new Error(`Unsupported chain: ${chain}. Supported chains: ${this.getSupportedChains().join(', ')}`); + } + return provider; } - create(params) { + create(params: { chain: string; [key: string]: any }) { return this.get(params).create(params); } - sign(params): string { + sign(params: { chain: string; [key: string]: any }): string { return this.get(params).sign(params); } - getSignature(params): string { + getSignature(params: { chain: string; [key: string]: any }): string { return this.get(params).getSignature(params); } - applySignature(params) { + applySignature(params: { chain: string; [key: string]: any }) { return this.get(params).applySignature(params); } - getHash(params) { + getHash(params: { chain: string; [key: string]: any }) { return this.get(params).getHash(params); } - transformSignatureObject(params) { + transformSignatureObject(params: { chain: string; [key: string]: any }) { return this.get(params).transformSignatureObject(params); } - getSighash(params): string { + getSighash(params: { chain: string; [key: string]: any }): string { return this.get(params).getSighash(params); } } diff --git a/packages/crypto-wallet-core/src/validation/eth/index.ts b/packages/crypto-wallet-core/src/validation/eth/index.ts index a2d38bf6ae1..e5980272207 100644 --- a/packages/crypto-wallet-core/src/validation/eth/index.ts +++ b/packages/crypto-wallet-core/src/validation/eth/index.ts @@ -18,10 +18,47 @@ export class EthValidation implements IValidation { } const address = this.extractAddress(addressUri); const prefix = this.regex.exec(addressUri); - return !!prefix && utils.isAddress(address); + if (!prefix) { + return false; + } + if (!utils.isAddress(address)) { + return false; + } + // Validate that numeric parameters contain only valid numbers + if (!this.validateUriParams(addressUri)) { + return false; + } + return true; + } + + /** + * Validates that URI parameters contain properly formatted numeric values. + * Returns false if any recognized numeric parameter has an invalid (non-numeric) value. + * + * @param {string} uri - The full URI string + * @returns {boolean} True if all numeric params are valid, or no params exist + */ + protected validateUriParams(uri: string): boolean { + const queryIndex = uri.indexOf('?'); + if (queryIndex === -1) { + return true; + } + const queryString = uri.substring(queryIndex + 1); + const params = queryString.split('&'); + const numericParams = ['value', 'gas', 'gasPrice', 'gasLimit', 'amount']; + + for (const param of params) { + const [key, value] = param.split('='); + if (numericParams.includes(key)) { + if (!value || isNaN(Number(value.replace(',', '.')))) { + return false; + } + } + } + return true; } - protected extractAddress(data) { + protected extractAddress(data: string): string { const prefix = /^[a-z]+:/i; const params = /([?&](value|gas|gasPrice|gasLimit)=(\d+([,.]\d+)?))+/i; return data.replace(prefix, '').replace(params, ''); diff --git a/packages/crypto-wallet-core/src/validation/index.ts b/packages/crypto-wallet-core/src/validation/index.ts index cd7a84a7b50..b4668d1f747 100644 --- a/packages/crypto-wallet-core/src/validation/index.ts +++ b/packages/crypto-wallet-core/src/validation/index.ts @@ -26,16 +26,75 @@ const validation: { [chain: string]: IValidation } = { }; export class ValidationProxy { - get(chain) { + /** + * Returns the list of supported chain identifiers. + * + * @returns {string[]} Array of supported chain names (uppercase) + */ + getSupportedChains(): string[] { + return Object.keys(validation); + } + + /** + * Returns whether a given chain is supported by the validation proxy. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @returns {boolean} True if the chain is supported + */ + isSupported(chain: string): boolean { + if (!chain || typeof chain !== 'string') { + return false; + } + return chain.toUpperCase() in validation; + } + + /** + * Retrieves the validation implementation for a given chain. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @returns {IValidation} The validation instance for the chain + * @throws {Error} If the chain is not provided or not supported + */ + get(chain: string): IValidation { + if (!chain || typeof chain !== 'string') { + throw new Error('Chain must be a non-empty string'); + } const normalizedChain = chain.toUpperCase(); - return validation[normalizedChain]; + const validator = validation[normalizedChain]; + if (!validator) { + throw new Error(`Unsupported chain: ${chain}. Supported chains: ${this.getSupportedChains().join(', ')}`); + } + return validator; } - validateAddress(chain, network, address) { + /** + * Validates an address for the specified chain and network. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @param {string} network - The network (e.g. 'mainnet', 'testnet') + * @param {string} address - The address to validate + * @returns {boolean} True if the address is valid + * @throws {Error} If chain is unsupported or address is not a string + */ + validateAddress(chain: string, network: string, address: string): boolean { + if (!address || typeof address !== 'string') { + return false; + } return this.get(chain).validateAddress(network, address); } - validateUri(chain, addressUri) { + /** + * Validates a URI for the specified chain. + * + * @param {string} chain - The chain identifier (case-insensitive) + * @param {string} addressUri - The URI to validate + * @returns {boolean} True if the URI is valid + * @throws {Error} If chain is unsupported + */ + validateUri(chain: string, addressUri: string): boolean { + if (!addressUri || typeof addressUri !== 'string') { + return false; + } return this.get(chain).validateUri(addressUri); } } diff --git a/packages/crypto-wallet-core/src/validation/xrp/index.ts b/packages/crypto-wallet-core/src/validation/xrp/index.ts index 02a7d7542a8..2fdd1a601c4 100644 --- a/packages/crypto-wallet-core/src/validation/xrp/index.ts +++ b/packages/crypto-wallet-core/src/validation/xrp/index.ts @@ -3,34 +3,47 @@ import baseX from 'base-x'; import type { IValidation } from '../../types/validation'; const RIPPLE_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz'; +const RippleAddressRegex = /^r[rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz]{27,35}$/; export class XrpValidation implements IValidation { validateAddress(_network: string, address: string): boolean { + if (!address || typeof address !== 'string') { + return false; + } + // First ensure it matches regex - const RippleAddressRegex = new RegExp(/^r[rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz]{27,35}$/); if (!address.match(RippleAddressRegex)) { return false; } // Then ensure it is a valid base58check encoding - const base58 = baseX(RIPPLE_ALPHABET); - const buffer = Buffer.from(base58.decode(address)); - const prefix = buffer.subarray(0, 1); - const data = buffer.subarray(1, -4); - let hash = Buffer.concat([prefix, data]); - hash = Bitcore.crypto.Hash.sha256(hash); - hash = Bitcore.crypto.Hash.sha256(hash); - const checksum = buffer.subarray(-4).reduce((acc, check, index) => { - if (check !== hash[index]) { - // Invalid checksum - return 0; - } else return acc || 1; - }); - if (checksum === 0) { + try { + const base58 = baseX(RIPPLE_ALPHABET); + const buffer = Buffer.from(base58.decode(address)); + + if (buffer.length < 5) { + return false; + } + + const prefix = buffer.subarray(0, 1); + const data = buffer.subarray(1, -4); + let hash = Buffer.concat([prefix, data]); + hash = Bitcore.crypto.Hash.sha256(hash); + hash = Bitcore.crypto.Hash.sha256(hash); + const checksum = buffer.subarray(-4).reduce((acc, check, index) => { + if (check !== hash[index]) { + // Invalid checksum + return 0; + } else return acc || 1; + }); + if (checksum === 0) { + return false; + } + + return true; + } catch { return false; } - - return true; } validateUri(addressUri: string): boolean { @@ -39,10 +52,47 @@ export class XrpValidation implements IValidation { } const address = this.extractAddress(addressUri); const ripplePrefix = /ripple/i.exec(addressUri); - return !!ripplePrefix && this.validateAddress('livenet', address); + if (!ripplePrefix) { + return false; + } + if (!this.validateAddress('livenet', address)) { + return false; + } + // Validate that numeric parameters are well-formed + if (!this.validateUriParams(addressUri)) { + return false; + } + return true; + } + + /** + * Validates that URI parameters contain properly formatted numeric values. + * Returns false if any recognized numeric parameter has an invalid (non-numeric) value. + * + * @param {string} uri - The full URI string + * @returns {boolean} True if all numeric params are valid, or no params exist + */ + private validateUriParams(uri: string): boolean { + const queryIndex = uri.indexOf('?'); + if (queryIndex === -1) { + return true; + } + const queryString = uri.substring(queryIndex + 1); + const params = queryString.split('&'); + const numericParams = ['amount', 'dt']; + + for (const param of params) { + const [key, value] = param.split('='); + if (numericParams.includes(key)) { + if (!value || isNaN(Number(value.replace(',', '.')))) { + return false; + } + } + } + return true; } - private extractAddress(data) { + private extractAddress(data: string): string { const prefix = /^[a-z]+:/i; const params = /([?&](amount|dt)=(\d+([,.]\d+)?))+/i; return data.replace(prefix, '').replace(params, ''); diff --git a/packages/crypto-wallet-core/test/proxy-error-handling.test.ts b/packages/crypto-wallet-core/test/proxy-error-handling.test.ts new file mode 100644 index 00000000000..c5aed0251d7 --- /dev/null +++ b/packages/crypto-wallet-core/test/proxy-error-handling.test.ts @@ -0,0 +1,230 @@ +import { expect } from 'chai'; +import { Validation, Transactions, Deriver } from '../src'; + +describe('Proxy Error Handling', () => { + + describe('ValidationProxy', () => { + describe('getSupportedChains', () => { + it('should return an array of supported chain identifiers', () => { + const chains = Validation.getSupportedChains(); + expect(chains).to.be.an('array'); + expect(chains).to.include('BTC'); + expect(chains).to.include('ETH'); + expect(chains).to.include('XRP'); + expect(chains).to.include('SOL'); + expect(chains).to.include('DOGE'); + expect(chains).to.include('LTC'); + expect(chains).to.include('BCH'); + expect(chains).to.include('MATIC'); + expect(chains).to.include('ARB'); + expect(chains).to.include('BASE'); + expect(chains).to.include('OP'); + }); + }); + + describe('isSupported', () => { + it('should return true for supported chains', () => { + expect(Validation.isSupported('BTC')).to.be.true; + expect(Validation.isSupported('eth')).to.be.true; + expect(Validation.isSupported('Xrp')).to.be.true; + }); + + it('should return false for unsupported chains', () => { + expect(Validation.isSupported('INVALID')).to.be.false; + expect(Validation.isSupported('AVAX')).to.be.false; + }); + + it('should return false for invalid inputs', () => { + expect(Validation.isSupported('')).to.be.false; + expect(Validation.isSupported(null as any)).to.be.false; + expect(Validation.isSupported(undefined as any)).to.be.false; + expect(Validation.isSupported(123 as any)).to.be.false; + }); + }); + + describe('get', () => { + it('should return a validator for a supported chain', () => { + const validator = Validation.get('BTC'); + expect(validator).to.have.property('validateAddress'); + expect(validator).to.have.property('validateUri'); + }); + + it('should be case-insensitive', () => { + const validator = Validation.get('btc'); + expect(validator).to.have.property('validateAddress'); + }); + + it('should throw for unsupported chains', () => { + expect(() => Validation.get('INVALID')).to.throw('Unsupported chain: INVALID'); + }); + + it('should throw for empty string', () => { + expect(() => Validation.get('')).to.throw('Chain must be a non-empty string'); + }); + + it('should throw for non-string input', () => { + expect(() => Validation.get(null as any)).to.throw('Chain must be a non-empty string'); + expect(() => Validation.get(undefined as any)).to.throw('Chain must be a non-empty string'); + expect(() => Validation.get(42 as any)).to.throw('Chain must be a non-empty string'); + }); + }); + + describe('validateAddress edge cases', () => { + it('should return false for empty address', () => { + expect(Validation.validateAddress('BTC', 'mainnet', '')).to.be.false; + }); + + it('should return false for null address', () => { + expect(Validation.validateAddress('BTC', 'mainnet', null as any)).to.be.false; + }); + + it('should return false for non-string address', () => { + expect(Validation.validateAddress('BTC', 'mainnet', 123 as any)).to.be.false; + }); + + it('should throw for unsupported chain', () => { + expect(() => Validation.validateAddress('INVALID', 'mainnet', 'someaddress')).to.throw('Unsupported chain'); + }); + }); + + describe('validateUri edge cases', () => { + it('should return false for empty URI', () => { + expect(Validation.validateUri('BTC', '')).to.be.false; + }); + + it('should return false for null URI', () => { + expect(Validation.validateUri('BTC', null as any)).to.be.false; + }); + + it('should return false for non-string URI', () => { + expect(Validation.validateUri('ETH', 123 as any)).to.be.false; + }); + + it('should throw for unsupported chain', () => { + expect(() => Validation.validateUri('INVALID', 'bitcoin:someaddress')).to.throw('Unsupported chain'); + }); + }); + }); + + describe('TransactionsProxy', () => { + describe('getSupportedChains', () => { + it('should return an array of supported chain identifiers', () => { + const chains = Transactions.getSupportedChains(); + expect(chains).to.be.an('array'); + expect(chains).to.include('BTC'); + expect(chains).to.include('ETH'); + expect(chains).to.include('ETHERC20'); + expect(chains).to.include('SOL'); + expect(chains).to.include('SOLSPL'); + }); + }); + + describe('isSupported', () => { + it('should return true for supported chains', () => { + expect(Transactions.isSupported('BTC')).to.be.true; + expect(Transactions.isSupported('eth')).to.be.true; + expect(Transactions.isSupported('ETHERC20')).to.be.true; + }); + + it('should return false for unsupported chains', () => { + expect(Transactions.isSupported('INVALID')).to.be.false; + }); + + it('should return false for invalid inputs', () => { + expect(Transactions.isSupported('')).to.be.false; + expect(Transactions.isSupported(null as any)).to.be.false; + }); + }); + + describe('get', () => { + it('should return a provider for a supported chain', () => { + const provider = Transactions.get({ chain: 'BTC' }); + expect(provider).to.have.property('create'); + }); + + it('should throw for unsupported chains', () => { + expect(() => Transactions.get({ chain: 'INVALID' })).to.throw('Unsupported chain: INVALID'); + }); + + it('should throw for missing chain property', () => { + expect(() => Transactions.get({} as any)).to.throw('Chain must be a non-empty string'); + }); + + it('should throw for null params', () => { + expect(() => Transactions.get(null as any)).to.throw('Params must be an object'); + }); + + it('should throw for non-object params', () => { + expect(() => Transactions.get('BTC' as any)).to.throw('Params must be an object'); + }); + }); + }); + + describe('DeriverProxy', () => { + describe('getSupportedChains', () => { + it('should return an array of supported chain identifiers', () => { + const chains = Deriver.getSupportedChains(); + expect(chains).to.be.an('array'); + expect(chains).to.include('BTC'); + expect(chains).to.include('ETH'); + expect(chains).to.include('SOL'); + }); + }); + + describe('isSupported', () => { + it('should return true for supported chains', () => { + expect(Deriver.isSupported('BTC')).to.be.true; + expect(Deriver.isSupported('eth')).to.be.true; + }); + + it('should return false for unsupported chains', () => { + expect(Deriver.isSupported('INVALID')).to.be.false; + }); + + it('should return false for invalid inputs', () => { + expect(Deriver.isSupported('')).to.be.false; + expect(Deriver.isSupported(null as any)).to.be.false; + }); + }); + + describe('pathFor', () => { + it('should return correct path for BTC mainnet', () => { + const path = Deriver.pathFor('BTC', 'mainnet'); + expect(path).to.equal("m/44'/0'/0'"); + }); + + it('should return correct path for ETH', () => { + const path = Deriver.pathFor('ETH', 'mainnet'); + expect(path).to.equal("m/44'/60'/0'"); + }); + + it('should return correct path for SOL', () => { + const path = Deriver.pathFor('SOL', 'mainnet'); + expect(path).to.equal("m/44'/501'/0'"); + }); + + it('should handle custom account index', () => { + const path = Deriver.pathFor('BTC', 'mainnet', 5); + expect(path).to.equal("m/44'/0'/5'"); + }); + + it('should throw for empty chain', () => { + expect(() => Deriver.pathFor('', 'mainnet')).to.throw('Chain must be a non-empty string'); + }); + + it('should throw for non-string chain', () => { + expect(() => Deriver.pathFor(null as any, 'mainnet')).to.throw('Chain must be a non-empty string'); + }); + + it('should fallback to BTC default for unknown chains', () => { + const path = Deriver.pathFor('UNKNOWN_CHAIN_XYZ', 'testnet'); + expect(path).to.equal("m/44'/1'/0'"); + }); + + it('should be case-insensitive', () => { + const path = Deriver.pathFor('btc', 'mainnet'); + expect(path).to.equal("m/44'/0'/0'"); + }); + }); + }); +});