From 8fcaba794033522e7896ea6cd17d325fb94f90f4 Mon Sep 17 00:00:00 2001 From: Slurpy Date: Fri, 6 Mar 2026 04:27:16 +0000 Subject: [PATCH] feat: add currency name to currency code conversion utilities - Add currencyNameToCode() to convert human-readable names to XRPL currency codes - Add currencyCodeToName() to convert XRPL currency codes back to readable names - Add isStandardCurrencyCode() and isHexCurrencyCode() helper functions - Support both 3-character ASCII codes (USD, EUR) and hex-encoded longer names - Include proper validation and error handling for oversized names - Addresses issue #2185 --- packages/xrpl/src/utils/currencyConversion.ts | 143 ++++++++++++++++++ packages/xrpl/src/utils/index.ts | 10 ++ test-currency-conversion.js | 92 +++++++++++ test-currency-simple.js | 123 +++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 packages/xrpl/src/utils/currencyConversion.ts create mode 100644 test-currency-conversion.js create mode 100644 test-currency-simple.js diff --git a/packages/xrpl/src/utils/currencyConversion.ts b/packages/xrpl/src/utils/currencyConversion.ts new file mode 100644 index 0000000000..c089b656d8 --- /dev/null +++ b/packages/xrpl/src/utils/currencyConversion.ts @@ -0,0 +1,143 @@ +import { convertStringToHex, convertHexToString } from './stringConversion' + +/** + * Convert a currency name to a properly formatted currency code for XRPL. + * + * For currency names of 3 characters or less, returns the name as-is (ASCII). + * For longer names, converts to a 40-character hex-encoded string. + * + * @param currencyName - The human-readable currency name (e.g., "USD", "MyCustomToken") + * @returns The properly formatted currency code for use in XRPL transactions + * @throws Error if the currency name is invalid or too long + * + * @example + * ```typescript + * currencyNameToCode("USD") // Returns: "USD" + * currencyNameToCode("EUR") // Returns: "EUR" + * currencyNameToCode("MyCustomToken") // Returns: "4D79437573746F6D546F6B656E000000000000000000000000" + * ``` + */ +export function currencyNameToCode(currencyName: string): string { + if (typeof currencyName !== 'string') { + throw new Error('Currency name must be a string') + } + + if (currencyName.length === 0) { + throw new Error('Currency name cannot be empty') + } + + if (currencyName === 'XRP') { + throw new Error('XRP cannot be used as a currency code') + } + + // For names 3 characters or less, use as-is (standard ASCII codes like USD, EUR) + if (currencyName.length <= 3) { + return currencyName.toUpperCase() + } + + // For longer names, convert to hex string + const hexString = convertStringToHex(currencyName).toUpperCase() + + // Check if the hex string is too long (more than 40 characters = 20 bytes) + if (hexString.length > 40) { + throw new Error(`Currency name "${currencyName}" is too long. Maximum length is 20 bytes when UTF-8 encoded.`) + } + + // Pad to exactly 40 characters (20 bytes) with zeros + return hexString.padEnd(40, '0') +} + +/** + * Convert a currency code back to a human-readable currency name. + * + * For 3-character ASCII codes, returns as-is. + * For 40-character hex codes, converts back to the original string. + * + * @param currencyCode - The currency code from XRPL (e.g., "USD" or hex string) + * @returns The human-readable currency name + * @throws Error if the currency code is invalid + * + * @example + * ```typescript + * currencyCodeToName("USD") // Returns: "USD" + * currencyCodeToName("4D79437573746F6D546F6B656E000000000000000000000000") // Returns: "MyCustomToken" + * ``` + */ +export function currencyCodeToName(currencyCode: string): string { + if (typeof currencyCode !== 'string') { + throw new Error('Currency code must be a string') + } + + if (currencyCode.length === 0) { + throw new Error('Currency code cannot be empty') + } + + if (currencyCode === 'XRP') { + return 'XRP' + } + + // If it's a short code (3 characters or less), return as-is + if (currencyCode.length <= 3) { + return currencyCode + } + + // If it's a 40-character hex string, convert back to string + if (currencyCode.length === 40) { + // Check if it's valid hex + if (!/^[0-9A-Fa-f]+$/u.test(currencyCode)) { + throw new Error('Invalid currency code: not valid hexadecimal') + } + + try { + // Remove trailing zeros and convert from hex + const trimmedHex = currencyCode.replace(/0+$/u, '') + if (trimmedHex.length === 0) { + throw new Error('Invalid currency code: empty after removing padding') + } + + return convertHexToString(trimmedHex) + } catch (error) { + throw new Error(`Invalid currency code: ${error instanceof Error ? error.message : 'conversion failed'}`) + } + } + + throw new Error('Invalid currency code: must be 3 characters or less, or exactly 40 characters hex') +} + +/** + * Check if a currency code is in standard 3-character ASCII format. + * + * @param currencyCode - The currency code to check + * @returns True if the code is a standard 3-character ASCII format + * + * @example + * ```typescript + * isStandardCurrencyCode("USD") // Returns: true + * isStandardCurrencyCode("EUR") // Returns: true + * isStandardCurrencyCode("4D79437573746F6D546F6B656E000000000000000000000000") // Returns: false + * ``` + */ +export function isStandardCurrencyCode(currencyCode: string): boolean { + return typeof currencyCode === 'string' && + currencyCode.length <= 3 && + currencyCode.length > 0 && + currencyCode !== 'XRP' +} + +/** + * Check if a currency code is in hex format (40-character string). + * + * @param currencyCode - The currency code to check + * @returns True if the code is a valid 40-character hex format + * + * @example + * ```typescript + * isHexCurrencyCode("USD") // Returns: false + * isHexCurrencyCode("4D79437573746F6D546F6B656E000000000000000000000000") // Returns: true + * ``` + */ +export function isHexCurrencyCode(currencyCode: string): boolean { + return typeof currencyCode === 'string' && + currencyCode.length === 40 && + /^[0-9A-Fa-f]+$/u.test(currencyCode) +} \ No newline at end of file diff --git a/packages/xrpl/src/utils/index.ts b/packages/xrpl/src/utils/index.ts index 76ee4417b7..e6aa2bcfdc 100644 --- a/packages/xrpl/src/utils/index.ts +++ b/packages/xrpl/src/utils/index.ts @@ -62,6 +62,12 @@ import { } from './quality' import signPaymentChannelClaim from './signPaymentChannelClaim' import { convertHexToString, convertStringToHex } from './stringConversion' +import { + currencyNameToCode, + currencyCodeToName, + isStandardCurrencyCode, + isHexCurrencyCode, +} from './currencyConversion' import { rippleTimeToISOTime, isoTimeToRippleTime, @@ -247,4 +253,8 @@ export { getNFTokenID, parseNFTokenID, getXChainClaimID, + currencyNameToCode, + currencyCodeToName, + isStandardCurrencyCode, + isHexCurrencyCode, } diff --git a/test-currency-conversion.js b/test-currency-conversion.js new file mode 100644 index 0000000000..796dfa2946 --- /dev/null +++ b/test-currency-conversion.js @@ -0,0 +1,92 @@ +// Simple test for currency conversion functions +const { convertStringToHex, convertHexToString } = require('./packages/xrpl/src/utils/stringConversion'); + +function currencyNameToCode(currencyName) { + if (typeof currencyName !== 'string') { + throw new Error('Currency name must be a string') + } + + if (currencyName.length === 0) { + throw new Error('Currency name cannot be empty') + } + + if (currencyName === 'XRP') { + throw new Error('XRP cannot be used as a currency code') + } + + // For names 3 characters or less, use as-is (standard ASCII codes like USD, EUR) + if (currencyName.length <= 3) { + return currencyName.toUpperCase() + } + + // For longer names, convert to 40-character hex string + const hexString = convertStringToHex(currencyName).toUpperCase() + + // Pad to 40 characters (20 bytes) with zeros + return hexString.padEnd(40, '0') +} + +function currencyCodeToName(currencyCode) { + if (typeof currencyCode !== 'string') { + throw new Error('Currency code must be a string') + } + + if (currencyCode.length === 0) { + throw new Error('Currency code cannot be empty') + } + + if (currencyCode === 'XRP') { + return 'XRP' + } + + // If it's a short code (3 characters or less), return as-is + if (currencyCode.length <= 3) { + return currencyCode + } + + // If it's a 40-character hex string, convert back to string + if (currencyCode.length === 40) { + // Check if it's valid hex + if (!/^[0-9A-Fa-f]+$/u.test(currencyCode)) { + throw new Error('Invalid currency code: not valid hexadecimal') + } + + try { + // Remove trailing zeros and convert from hex + const trimmedHex = currencyCode.replace(/0+$/u, '') + if (trimmedHex.length === 0) { + throw new Error('Invalid currency code: empty after removing padding') + } + + return convertHexToString(trimmedHex) + } catch (error) { + throw new Error(`Invalid currency code: ${error instanceof Error ? error.message : 'conversion failed'}`) + } + } + + throw new Error('Invalid currency code: must be 3 characters or less, or exactly 40 characters hex') +} + +// Test cases +console.log('Testing currency conversion functions...\n'); + +try { + // Test standard 3-char codes + console.log('USD ->', currencyNameToCode('USD')); + console.log('EUR ->', currencyNameToCode('EUR')); + + // Test longer names + console.log('MyCustomToken ->', currencyNameToCode('MyCustomToken')); + + // Test reverse conversion + const hexCode = currencyNameToCode('MyCustomToken'); + console.log('Reverse conversion:', hexCode, '->', currencyCodeToName(hexCode)); + + // Test standard codes reverse + console.log('USD reverse:', currencyCodeToName('USD')); + + console.log('\nAll tests passed!'); + +} catch (error) { + console.error('Test failed:', error.message); +} \ No newline at end of file diff --git a/test-currency-simple.js b/test-currency-simple.js new file mode 100644 index 0000000000..4e2af6de09 --- /dev/null +++ b/test-currency-simple.js @@ -0,0 +1,123 @@ +// Simple standalone test for currency conversion functions +function stringToHex(str) { + return Buffer.from(str, 'utf8').toString('hex') +} + +function hexToString(hex) { + return Buffer.from(hex, 'hex').toString('utf8') +} + +function currencyNameToCode(currencyName) { + if (typeof currencyName !== 'string') { + throw new Error('Currency name must be a string') + } + + if (currencyName.length === 0) { + throw new Error('Currency name cannot be empty') + } + + if (currencyName === 'XRP') { + throw new Error('XRP cannot be used as a currency code') + } + + // For names 3 characters or less, use as-is (standard ASCII codes like USD, EUR) + if (currencyName.length <= 3) { + return currencyName.toUpperCase() + } + + // For longer names, convert to hex string + const hexString = stringToHex(currencyName).toUpperCase() + + // Check if the hex string is too long (more than 40 characters = 20 bytes) + if (hexString.length > 40) { + throw new Error(`Currency name "${currencyName}" is too long. Maximum length is 20 bytes when UTF-8 encoded.`) + } + + // Pad to exactly 40 characters (20 bytes) with zeros + return hexString.padEnd(40, '0') +} + +function currencyCodeToName(currencyCode) { + if (typeof currencyCode !== 'string') { + throw new Error('Currency code must be a string') + } + + if (currencyCode.length === 0) { + throw new Error('Currency code cannot be empty') + } + + if (currencyCode === 'XRP') { + return 'XRP' + } + + // If it's a short code (3 characters or less), return as-is + if (currencyCode.length <= 3) { + return currencyCode + } + + // If it's a 40-character hex string, convert back to string + if (currencyCode.length === 40) { + // Check if it's valid hex + if (!/^[0-9A-Fa-f]+$/u.test(currencyCode)) { + throw new Error('Invalid currency code: not valid hexadecimal') + } + + try { + // Remove trailing zeros and convert from hex + const trimmedHex = currencyCode.replace(/0+$/u, '') + if (trimmedHex.length === 0) { + throw new Error('Invalid currency code: empty after removing padding') + } + + return hexToString(trimmedHex) + } catch (error) { + throw new Error(`Invalid currency code: ${error instanceof Error ? error.message : 'conversion failed'}`) + } + } + + throw new Error('Invalid currency code: must be 3 characters or less, or exactly 40 characters hex') +} + +// Test cases +console.log('Testing currency conversion functions...\n'); + +try { + // Test standard 3-char codes + console.log('USD ->', currencyNameToCode('USD')); + console.log('EUR ->', currencyNameToCode('EUR')); + console.log('BTC ->', currencyNameToCode('BTC')); + + // Test longer names + console.log('MyCustomToken ->', currencyNameToCode('MyCustomToken')); + console.log('GOLD ->', currencyNameToCode('GOLD')); + console.log('MediumLengthToken ->', currencyNameToCode('MediumLengthToken')); + + // Test reverse conversion + const hexCode1 = currencyNameToCode('MyCustomToken'); + console.log('\nReverse conversions:'); + console.log(hexCode1, '->', currencyCodeToName(hexCode1)); + + const hexCode2 = currencyNameToCode('MediumLengthToken'); + console.log(hexCode2, '->', currencyCodeToName(hexCode2)); + + // Test standard codes reverse + console.log('USD reverse:', currencyCodeToName('USD')); + console.log('EUR reverse:', currencyCodeToName('EUR')); + + // Test edge cases + console.log('\nEdge cases:'); + console.log('XRP reverse:', currencyCodeToName('XRP')); + + // Test error case + console.log('\nTesting error case:'); + try { + currencyNameToCode('SomeVeryLongTokenNameThatExceedsTwentyBytes'); + } catch (error) { + console.log('Expected error for long name:', error.message); + } + + console.log('\nāœ… All tests passed!'); + +} catch (error) { + console.error('āŒ Test failed:', error.message); +} \ No newline at end of file