Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions packages/xrpl/src/utils/currencyConversion.ts
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +29 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate short codes before taking the fast path.

Right now any 1–3 code-unit string is accepted as a “standard” code. That lets currencyNameToCode('xrp') return reserved XRP, and helpers like isStandardCurrencyCode('€') report true even though the API/docs describe these as ASCII codes. Normalize first, then gate the short-code path with a shared ASCII check.

Also applies to: 79-81, 120-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/xrpl/src/utils/currencyConversion.ts` around lines 29 - 35, The
short-path for treating 1–3 character inputs as standard ASCII codes must only
be taken after normalizing and verifying ASCII letters; in currencyNameToCode
normalize (trim and toUpperCase) the input first, check for reserved 'XRP' after
normalization, then only return the uppercase code for length <= 3 if it matches
/^[A-Z]{1,3}$/ (or reuse isStandardCurrencyCode after making it enforce ASCII
A–Z), and update the corresponding short-code checks in the other occurrences
(the other branches using the same fast-path) to use the same normalized +
ASCII-gated logic.

}

// 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) {
Comment on lines +91 to +99
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Trim 00 padding bytes, not individual 0 nibbles.

replace(/0+$/u, '') corrupts valid payloads whose UTF-8 hex legitimately ends in 0. For example, a name ending with ASCII 0 produces a final byte of 30; this code strips the trailing 0, leaves odd-length hex, and makes the round-trip fail for a valid currency name.

🐛 Proposed fix
-      const trimmedHex = currencyCode.replace(/0+$/u, '')
+      const trimmedHex = currencyCode.replace(/(?:00)+$/u, '')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) {
try {
// Remove trailing zeros and convert from hex
const trimmedHex = currencyCode.replace(/(?:00)+$/u, '')
if (trimmedHex.length === 0) {
throw new Error('Invalid currency code: empty after removing padding')
}
return convertHexToString(trimmedHex)
} catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/xrpl/src/utils/currencyConversion.ts` around lines 91 - 99, The code
is stripping hex nibbles with currencyCode.replace(/0+$/u, '') which corrupts
valid payloads; update the trimming to remove only padding bytes ('00') from the
end of currencyCode (e.g., strip repeating "00" byte sequences) before calling
convertHexToString, keep the existing check that throws if the result is empty,
and ensure trimmedHex has an even length (or otherwise error) so
convertHexToString receives valid byte-aligned hex; refer to the
variables/function names currencyCode, trimmedHex and convertHexToString in
currencyConversion.ts when making the change.

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)
}
10 changes: 10 additions & 0 deletions packages/xrpl/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -247,4 +253,8 @@ export {
getNFTokenID,
parseNFTokenID,
getXChainClaimID,
currencyNameToCode,
currencyCodeToName,
isStandardCurrencyCode,
isHexCurrencyCode,
}
92 changes: 92 additions & 0 deletions test-currency-conversion.js
Original file line number Diff line number Diff line change
@@ -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')
}
Comment on lines +4 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This still isn’t validating the shipped utility.

The file redefines the conversion logic locally, only prints outputs, and already diverges from packages/xrpl/src/utils/currencyConversion.ts by omitting the 20-byte length check. That means regressions in the exported helpers can slip through even when this script reports success.

Also applies to: 70-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test-currency-conversion.js` around lines 4 - 68, The test reimplements
conversion logic (currencyNameToCode, currencyCodeToName) instead of using the
canonical exported helpers and misses the 20-byte length validation; update the
test to import and use the utility from
packages/xrpl/src/utils/currencyConversion.ts (or the package export) rather
than redefining convertStringToHex/convertHexToString, and ensure the test
asserts the same validation rules (including the exact 40-hex / 20-byte check
and error messages for invalid hex/padding cases) so the script exercises the
same codepaths and validations as the real helpers.


// 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);
}
123 changes: 123 additions & 0 deletions test-currency-simple.js
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +1 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This is a demo script, not an automated test.

The file reimplements the conversion helpers locally and only logs outputs, so regressions in packages/xrpl/src/utils/currencyConversion.ts can still finish with “All tests passed”. Please import the exported helpers and assert exact results in the repo test runner instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test-currency-simple.js` around lines 1 - 123, This demo script reimplements
currency helpers and only logs results; replace the local implementations and
ad-hoc console checks with imports of the canonical exported functions
(currencyNameToCode and currencyCodeToName) from the repository's currency
conversion module and convert the script into proper automated tests using the
repo test runner (assert exact return values and error cases rather than
printing), removing the duplicated functions
(stringToHex/hexToString/currencyNameToCode/currencyCodeToName) and any manual
console-based "All tests passed" logic so the real helpers are exercised and
failures surface in CI.