From 5ca557f56dd70c28c55fced971bdd3c7349f8d7d Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 23:25:49 +0530 Subject: [PATCH 1/3] feat: add new hash functions for various ledger entries and update ledgerSpaces --- packages/xrpl/src/utils/hashes/index.ts | 304 ++++++++++++++++++ .../xrpl/src/utils/hashes/ledgerSpaces.ts | 15 + packages/xrpl/test/utils/hashes.test.ts | 294 +++++++++++++++++ 3 files changed, 613 insertions(+) diff --git a/packages/xrpl/src/utils/hashes/index.ts b/packages/xrpl/src/utils/hashes/index.ts index 9d32f6bfa1..a1aeee0568 100644 --- a/packages/xrpl/src/utils/hashes/index.ts +++ b/packages/xrpl/src/utils/hashes/index.ts @@ -185,4 +185,308 @@ export function hashPaymentChannel( ) } +/** + * Compute the hash of a Ticket. + * + * @param address - Account that created the Ticket. + * @param ticketSequence - The Ticket Sequence number. + * @returns Hash of the Ticket. + * @category Utilities + */ +export function hashTicket(address: string, ticketSequence: number): string { + return sha512Half( + ledgerSpaceHex('ticket') + + addressToHex(address) + + ticketSequence.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + ) +} + +/** + * Compute the hash of a Check. + * + * @param address - Account that created the Check. + * @param sequence - Sequence number of the CheckCreate transaction. + * @returns Hash of the Check. + * @category Utilities + */ +export function hashCheck(address: string, sequence: number): string { + return sha512Half( + ledgerSpaceHex('check') + + addressToHex(address) + + sequence.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + ) +} + +/** + * Compute the hash of a DepositPreauth entry. + * + * @param address - Account that granted the authorization. + * @param authorizedAddress - Account that was authorized. + * @returns Hash of the DepositPreauth entry. + * @category Utilities + */ +export function hashDepositPreauth( + address: string, + authorizedAddress: string, +): string { + return sha512Half( + ledgerSpaceHex('depositPreauth') + + addressToHex(address) + + addressToHex(authorizedAddress), + ) +} + +/** + * Compute the hash of an NFTokenPage. + * + * @param address - Account that owns the NFTokenPage. + * @param nfTokenIDLow96 - The low 96 bits of a representative NFTokenID. + * @returns Hash of the NFTokenPage. + * @category Utilities + */ +export function hashNFTokenPage( + address: string, + nfTokenIDLow96: string, +): string { + return sha512Half( + ledgerSpaceHex('nfTokenPage') + addressToHex(address) + nfTokenIDLow96, + ) +} + +/** + * Compute the hash of an NFTokenOffer. + * + * @param address - Account that created the NFTokenOffer. + * @param sequence - Sequence number of the NFTokenCreateOffer transaction. + * @returns Hash of the NFTokenOffer. + * @category Utilities + */ +export function hashNFTokenOffer(address: string, sequence: number): string { + return sha512Half( + ledgerSpaceHex('nfTokenOffer') + + addressToHex(address) + + sequence.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + ) +} + +/** + * Compute the hash of an AMM root. + * + * @param currency1 - First currency in the AMM pool. + * @param currency2 - Second currency in the AMM pool. + * @param issuer1 - Issuer of the first currency (omit for XRP). + * @param issuer2 - Issuer of the second currency (omit for XRP). + * @returns Hash of the AMM root. + * @category Utilities + */ +export function hashAMMRoot( + currency1: string, + currency2: string, + issuer1?: string, + issuer2?: string, +): string { + const cur1Hex = currencyToHex(currency1) + const cur2Hex = currencyToHex(currency2) + const iss1Hex = issuer1 ? addressToHex(issuer1) : '00'.repeat(20) + const iss2Hex = issuer2 ? addressToHex(issuer2) : '00'.repeat(20) + + // Ensure deterministic ordering + const asset1 = cur1Hex + iss1Hex + const asset2 = cur2Hex + iss2Hex + const swap = new BigNumber(asset1, HEX).isGreaterThan( + new BigNumber(asset2, HEX), + ) + + const lowAsset = swap ? asset2 : asset1 + const highAsset = swap ? asset1 : asset2 + + return sha512Half(ledgerSpaceHex('ammRoot') + lowAsset + highAsset) +} + +/** + * Compute the hash of an Oracle entry. + * + * @param address - Account that created the Oracle. + * @param oracleID - The unique identifier for this Oracle. + * @returns Hash of the Oracle. + * @category Utilities + */ +export function hashOracle(address: string, oracleID: string): string { + return sha512Half(ledgerSpaceHex('oracle') + addressToHex(address) + oracleID) +} + +/** + * Compute the hash of a Hook entry. + * + * @param address - Account that installed the Hook. + * @param hookHash - Hash of the Hook code. + * @returns Hash of the Hook entry. + * @category Utilities + */ +export function hashHook(address: string, hookHash: string): string { + return sha512Half(ledgerSpaceHex('hook') + addressToHex(address) + hookHash) +} + +/** + * Compute the hash of a Hook State entry. + * + * @param address - Account that owns the Hook State. + * @param hookHash - Hash of the Hook code. + * @param hookStateKey - Key for the Hook State entry. + * @returns Hash of the Hook State entry. + * @category Utilities + */ +export function hashHookState( + address: string, + hookHash: string, + hookStateKey: string, +): string { + return sha512Half( + ledgerSpaceHex('hookState') + + addressToHex(address) + + hookHash + + hookStateKey, + ) +} + +/** + * Compute the hash of a Hook Definition entry. + * + * @param hookHash - Hash of the Hook code. + * @returns Hash of the Hook Definition entry. + * @category Utilities + */ +export function hashHookDefinition(hookHash: string): string { + return sha512Half(ledgerSpaceHex('hookDefinition') + hookHash) +} + +/** + * Compute the hash of a DID entry. + * + * @param address - Account that owns the DID. + * @returns Hash of the DID entry. + * @category Utilities + */ +export function hashDID(address: string): string { + return sha512Half(ledgerSpaceHex('did') + addressToHex(address)) +} + +/** + * Compute the hash of a Bridge entry. + * + * @param door - The door account of the bridge. + * @param otherChainSource - The source account on the other chain. + * @param issuingChainDoor - The door account on the issuing chain. + * @param issuingChainIssue - The issue specification on the issuing chain. + * @param lockingChainDoor - The door account on the locking chain. + * @param lockingChainIssue - The issue specification on the locking chain. + * @returns Hash of the Bridge entry. + * @category Utilities + */ +export function hashBridge( + door: string, + otherChainSource: string, + issuingChainDoor: string, + issuingChainIssue: string, + lockingChainDoor: string, + lockingChainIssue: string, +): string { + return sha512Half( + ledgerSpaceHex('bridge') + + addressToHex(door) + + addressToHex(otherChainSource) + + addressToHex(issuingChainDoor) + + currencyToHex(issuingChainIssue) + + addressToHex(lockingChainDoor) + + currencyToHex(lockingChainIssue), + ) +} + +/** + * Compute the hash of an XChain Owned Claim ID entry. + * + * @param address - Account that owns the claim ID. + * @param bridgeAccount - The bridge account. + * @param claimID - The claim ID. + * @returns Hash of the XChain Owned Claim ID entry. + * @category Utilities + */ +export function hashXChainOwnedClaimID( + address: string, + bridgeAccount: string, + claimID: number, +): string { + return sha512Half( + ledgerSpaceHex('xchainOwnedClaimID') + + addressToHex(address) + + addressToHex(bridgeAccount) + + claimID.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + ) +} + +/** + * Compute the hash of an XChain Owned Create Account Claim ID entry. + * + * @param address - Account that owns the create account claim ID. + * @param bridgeAccount - The bridge account. + * @param claimID - The claim ID. + * @returns Hash of the XChain Owned Create Account Claim ID entry. + * @category Utilities + */ +export function hashXChainOwnedCreateAccountClaimID( + address: string, + bridgeAccount: string, + claimID: number, +): string { + return sha512Half( + ledgerSpaceHex('xchainOwnedCreateAccountClaimID') + + addressToHex(address) + + addressToHex(bridgeAccount) + + claimID.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + ) +} + +/** + * Compute the hash of an MPToken entry. + * + * @param address - Account that holds the MPToken. + * @param mpTokenIssuanceID - The MPToken issuance ID. + * @returns Hash of the MPToken entry. + * @category Utilities + */ +export function hashMPToken( + address: string, + mpTokenIssuanceID: string, +): string { + return sha512Half( + ledgerSpaceHex('mpToken') + addressToHex(address) + mpTokenIssuanceID, + ) +} + +/** + * Compute the hash of an MPToken Issuance entry. + * + * @param sequence - Sequence number of the transaction that created this issuance. + * @param address - Account that created the MPToken issuance. + * @returns Hash of the MPToken Issuance entry. + * @category Utilities + */ +export function hashMPTokenIssuance(sequence: number, address: string): string { + return sha512Half( + ledgerSpaceHex('mpTokenIssuance') + + sequence.toString(HEX).padStart(BYTE_LENGTH * 2, '0') + + addressToHex(address), + ) +} + +/** + * Compute the hash of a NegativeUNL entry. + * + * @returns Hash of the NegativeUNL entry. + * @category Utilities + */ +export function hashNegativeUNL(): string { + return sha512Half(ledgerSpaceHex('negativeUNL')) +} + export { hashLedgerHeader, hashSignedTx, hashLedger, hashStateTree, hashTxTree } diff --git a/packages/xrpl/src/utils/hashes/ledgerSpaces.ts b/packages/xrpl/src/utils/hashes/ledgerSpaces.ts index e2af0c6aae..b5eac9ce14 100644 --- a/packages/xrpl/src/utils/hashes/ledgerSpaces.ts +++ b/packages/xrpl/src/utils/hashes/ledgerSpaces.ts @@ -29,6 +29,21 @@ const ledgerSpaces = { paychan: 'x', check: 'C', depositPreauth: 'p', + // Additional ledger entry types + negativeUNL: 'N', + nfTokenPage: 'P', + nfTokenOffer: 't', + ammRoot: 'A', + oracle: 'R', + hook: 'H', + hookState: 'h', + hookDefinition: 'D', + did: 'I', + bridge: 'X', + xchainOwnedClaimID: 'K', + xchainOwnedCreateAccountClaimID: 'k', + mpToken: 'M', + mpTokenIssuance: 'm', } export default ledgerSpaces diff --git a/packages/xrpl/test/utils/hashes.test.ts b/packages/xrpl/test/utils/hashes.test.ts index edc19575fa..f7d81796ce 100644 --- a/packages/xrpl/test/utils/hashes.test.ts +++ b/packages/xrpl/test/utils/hashes.test.ts @@ -21,6 +21,23 @@ import { hashAccountRoot, hashOfferId, hashSignerListId, + hashTicket, + hashCheck, + hashDepositPreauth, + hashNFTokenPage, + hashNFTokenOffer, + hashAMMRoot, + hashOracle, + hashHook, + hashHookState, + hashHookDefinition, + hashDID, + hashBridge, + hashXChainOwnedClaimID, + hashXChainOwnedCreateAccountClaimID, + hashMPToken, + hashMPTokenIssuance, + hashNegativeUNL, } from '../../src/utils/hashes' import fixtures from '../fixtures/rippled' import { assertResultMatch } from '../testUtils' @@ -230,4 +247,281 @@ describe('Hashes', function () { '9EDF5DB29F536DD3919037F1E8A72B040D075571A10C9000294C57B5ECEEA791', ) }) + + describe('New Ledger Entry Hash Functions', function () { + /* eslint-disable require-unicode-regexp -- TypeScript target doesn't support u flag in tests */ + it('hashTicket', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const ticketSequence = 123 + const actualHash = hashTicket(account, ticketSequence) + + // Ticket hashes should be deterministic + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.isTrue( + /^[A-F0-9]+$/.test(actualHash), + 'Hash should be uppercase hex', + ) + + // Should be consistent + assert.equal(hashTicket(account, ticketSequence), actualHash) + }) + + it('hashCheck', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const sequence = 456 + const actualHash = hashCheck(account, sequence) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashCheck(account, sequence), actualHash) + }) + + it('hashDepositPreauth', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const authorizedAddress = 'rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY' + const actualHash = hashDepositPreauth(account, authorizedAddress) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashDepositPreauth(account, authorizedAddress), actualHash) + + // Different order should give different hash + const reverseHash = hashDepositPreauth(authorizedAddress, account) + assert.notEqual(actualHash, reverseHash, 'Order should matter') + }) + + it('hashNFTokenPage', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const nfTokenIDLow96 = '000000000000000000000000000000000000000000000001' + const actualHash = hashNFTokenPage(account, nfTokenIDLow96) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashNFTokenPage(account, nfTokenIDLow96), actualHash) + }) + + it('hashNFTokenOffer', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const sequence = 789 + const actualHash = hashNFTokenOffer(account, sequence) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashNFTokenOffer(account, sequence), actualHash) + }) + + it('hashAMMRoot - XRP/USD pair', function () { + const currency1 = 'XRP' + const currency2 = 'USD' + const issuer2 = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const actualHash = hashAMMRoot(currency1, currency2, undefined, issuer2) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent regardless of order + const reverseHash = hashAMMRoot(currency2, currency1, issuer2, undefined) + assert.equal( + actualHash, + reverseHash, + 'Order should not matter for AMM pairs', + ) + }) + + it('hashAMMRoot - USD/EUR pair', function () { + const currency1 = 'USD' + const currency2 = 'EUR' + const issuer1 = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const issuer2 = 'rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY' + const actualHash = hashAMMRoot(currency1, currency2, issuer1, issuer2) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent regardless of order + const reverseHash = hashAMMRoot(currency2, currency1, issuer2, issuer1) + assert.equal( + actualHash, + reverseHash, + 'Order should not matter for AMM pairs', + ) + }) + + it('hashOracle', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const oracleID = 'ORACLE123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + const actualHash = hashOracle(account, oracleID) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashOracle(account, oracleID), actualHash) + }) + + it('hashHook', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const hookHash = + 'HOOK123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + const actualHash = hashHook(account, hookHash) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashHook(account, hookHash), actualHash) + }) + + it('hashHookState', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const hookHash = + 'HOOK123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + const hookStateKey = + 'STATE123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01234567' + const actualHash = hashHookState(account, hookHash, hookStateKey) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashHookState(account, hookHash, hookStateKey), actualHash) + }) + + it('hashHookDefinition', function () { + const hookHash = + 'HOOK123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + const actualHash = hashHookDefinition(hookHash) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashHookDefinition(hookHash), actualHash) + }) + + it('hashDID', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const actualHash = hashDID(account) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashDID(account), actualHash) + }) + + it('hashBridge', function () { + const door = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const otherChainSource = 'rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY' + const issuingChainDoor = 'r32UufnaCGL82HubijgJGDmdE5hac7ZvLw' + const issuingChainIssue = 'USD' + const lockingChainDoor = 'rDx69ebzbowuqztksVDmZXjizTd12BVr4x' + const lockingChainIssue = 'EUR' + + const actualHash = hashBridge( + door, + otherChainSource, + issuingChainDoor, + issuingChainIssue, + lockingChainDoor, + lockingChainIssue, + ) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal( + hashBridge( + door, + otherChainSource, + issuingChainDoor, + issuingChainIssue, + lockingChainDoor, + lockingChainIssue, + ), + actualHash, + ) + }) + + it('hashXChainOwnedClaimID', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const bridgeAccount = 'rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY' + const claimID = 12345 + const actualHash = hashXChainOwnedClaimID(account, bridgeAccount, claimID) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal( + hashXChainOwnedClaimID(account, bridgeAccount, claimID), + actualHash, + ) + }) + + it('hashXChainOwnedCreateAccountClaimID', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const bridgeAccount = 'rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY' + const claimID = 67890 + const actualHash = hashXChainOwnedCreateAccountClaimID( + account, + bridgeAccount, + claimID, + ) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal( + hashXChainOwnedCreateAccountClaimID(account, bridgeAccount, claimID), + actualHash, + ) + }) + + it('hashMPToken', function () { + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const mpTokenIssuanceID = + 'MPTOKEN123456789ABCDEF0123456789ABCDEF0123456789ABCDEF012' + const actualHash = hashMPToken(account, mpTokenIssuanceID) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashMPToken(account, mpTokenIssuanceID), actualHash) + }) + + it('hashMPTokenIssuance', function () { + const sequence = 98765 + const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' + const actualHash = hashMPTokenIssuance(sequence, account) + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent + assert.equal(hashMPTokenIssuance(sequence, account), actualHash) + }) + + it('hashNegativeUNL', function () { + const actualHash = hashNegativeUNL() + + assert.equal(actualHash.length, 64, 'Hash should be 64 characters') + assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') + + // Should be consistent (same hash every time since no parameters) + assert.equal(hashNegativeUNL(), actualHash) + }) + /* eslint-enable require-unicode-regexp */ + }) }) From 68ff347ecc1b441ff4c8a565f91a08466547df23 Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 23:49:39 +0530 Subject: [PATCH 2/3] feat: add validation for hex string parameters in hash functions --- packages/xrpl/src/utils/hashes/index.ts | 37 +++++++++++++++++++++++++ packages/xrpl/test/utils/hashes.test.ts | 27 ++++++------------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/xrpl/src/utils/hashes/index.ts b/packages/xrpl/src/utils/hashes/index.ts index a1aeee0568..a5cbb9d52a 100644 --- a/packages/xrpl/src/utils/hashes/index.ts +++ b/packages/xrpl/src/utils/hashes/index.ts @@ -242,12 +242,17 @@ export function hashDepositPreauth( * @param address - Account that owns the NFTokenPage. * @param nfTokenIDLow96 - The low 96 bits of a representative NFTokenID. * @returns Hash of the NFTokenPage. + * @throws Error if nfTokenIDLow96 is not a 24-character hex string. * @category Utilities */ export function hashNFTokenPage( address: string, nfTokenIDLow96: string, ): string { + // Validate that nfTokenIDLow96 is a 24-character hex string (96 bits) + if (!/^[0-9A-F]{24}$/iu.test(nfTokenIDLow96)) { + throw new Error('nfTokenIDLow96 must be a 24-character hex string') + } return sha512Half( ledgerSpaceHex('nfTokenPage') + addressToHex(address) + nfTokenIDLow96, ) @@ -309,9 +314,16 @@ export function hashAMMRoot( * @param address - Account that created the Oracle. * @param oracleID - The unique identifier for this Oracle. * @returns Hash of the Oracle. + * @throws Error if oracleID is not a 64-character uppercase hex string. * @category Utilities */ export function hashOracle(address: string, oracleID: string): string { + // Validate that oracleID is a 64-character uppercase hex string + if (!/^[0-9A-F]{64}$/u.test(oracleID)) { + throw new Error( + 'oracleID must be a 64-character uppercase hexadecimal string', + ) + } return sha512Half(ledgerSpaceHex('oracle') + addressToHex(address) + oracleID) } @@ -321,9 +333,14 @@ export function hashOracle(address: string, oracleID: string): string { * @param address - Account that installed the Hook. * @param hookHash - Hash of the Hook code. * @returns Hash of the Hook entry. + * @throws Error if hookHash is not a 64-character hex string. * @category Utilities */ export function hashHook(address: string, hookHash: string): string { + // Validate that hookHash is a 64-character hex string + if (!/^[0-9A-F]{64}$/iu.test(hookHash)) { + throw new Error('hookHash must be a 64-character hexadecimal string') + } return sha512Half(ledgerSpaceHex('hook') + addressToHex(address) + hookHash) } @@ -334,6 +351,7 @@ export function hashHook(address: string, hookHash: string): string { * @param hookHash - Hash of the Hook code. * @param hookStateKey - Key for the Hook State entry. * @returns Hash of the Hook State entry. + * @throws Error if hookHash or hookStateKey are not 64-character hex strings. * @category Utilities */ export function hashHookState( @@ -341,6 +359,13 @@ export function hashHookState( hookHash: string, hookStateKey: string, ): string { + // Validate parameters + if (!/^[0-9A-F]{64}$/iu.test(hookHash)) { + throw new Error('hookHash must be a 64-character hexadecimal string') + } + if (!/^[0-9A-F]{64}$/iu.test(hookStateKey)) { + throw new Error('hookStateKey must be a 64-character hexadecimal string') + } return sha512Half( ledgerSpaceHex('hookState') + addressToHex(address) + @@ -354,9 +379,14 @@ export function hashHookState( * * @param hookHash - Hash of the Hook code. * @returns Hash of the Hook Definition entry. + * @throws Error if hookHash is not a 64-character hex string. * @category Utilities */ export function hashHookDefinition(hookHash: string): string { + // Validate that hookHash is a 64-character hex string + if (!/^[0-9A-F]{64}$/iu.test(hookHash)) { + throw new Error('hookHash must be a 64-character hexadecimal string') + } return sha512Half(ledgerSpaceHex('hookDefinition') + hookHash) } @@ -452,12 +482,19 @@ export function hashXChainOwnedCreateAccountClaimID( * @param address - Account that holds the MPToken. * @param mpTokenIssuanceID - The MPToken issuance ID. * @returns Hash of the MPToken entry. + * @throws Error if mpTokenIssuanceID is not a 64-character hex string. * @category Utilities */ export function hashMPToken( address: string, mpTokenIssuanceID: string, ): string { + // Validate that mpTokenIssuanceID is a 64-character hex string + if (!/^[0-9A-F]{64}$/iu.test(mpTokenIssuanceID)) { + throw new Error( + 'mpTokenIssuanceID must be a 64-character hexadecimal string', + ) + } return sha512Half( ledgerSpaceHex('mpToken') + addressToHex(address) + mpTokenIssuanceID, ) diff --git a/packages/xrpl/test/utils/hashes.test.ts b/packages/xrpl/test/utils/hashes.test.ts index f7d81796ce..4d6ee109ce 100644 --- a/packages/xrpl/test/utils/hashes.test.ts +++ b/packages/xrpl/test/utils/hashes.test.ts @@ -296,12 +296,10 @@ describe('Hashes', function () { it('hashNFTokenPage', function () { const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' - const nfTokenIDLow96 = '000000000000000000000000000000000000000000000001' + const nfTokenIDLow96 = '0008138800000000C1ECF2F9' const actualHash = hashNFTokenPage(account, nfTokenIDLow96) assert.equal(actualHash.length, 64, 'Hash should be 64 characters') - assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') - // Should be consistent assert.equal(hashNFTokenPage(account, nfTokenIDLow96), actualHash) }) @@ -357,12 +355,11 @@ describe('Hashes', function () { it('hashOracle', function () { const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' - const oracleID = 'ORACLE123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + const oracleID = + '61E8E8ED53FA2CEBE192B23897071E9A75217BF5A410E9CB5B45AAB7AECA567A' const actualHash = hashOracle(account, oracleID) assert.equal(actualHash.length, 64, 'Hash should be 64 characters') - assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') - // Should be consistent assert.equal(hashOracle(account, oracleID), actualHash) }) @@ -370,12 +367,10 @@ describe('Hashes', function () { it('hashHook', function () { const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' const hookHash = - 'HOOK123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + 'CA4562711E4679FE9317DD767871E90A404C7A8B84FAFD35EC2CF0231F1F6DAF' const actualHash = hashHook(account, hookHash) assert.equal(actualHash.length, 64, 'Hash should be 64 characters') - assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') - // Should be consistent assert.equal(hashHook(account, hookHash), actualHash) }) @@ -383,26 +378,22 @@ describe('Hashes', function () { it('hashHookState', function () { const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' const hookHash = - 'HOOK123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + 'CA4562711E4679FE9317DD767871E90A404C7A8B84FAFD35EC2CF0231F1F6DAF' const hookStateKey = - 'STATE123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01234567' + '458101D51051230B1D56E9ACAFAA34451BF65FA000F95DF6F0FF5B3A62D83FC2' const actualHash = hashHookState(account, hookHash, hookStateKey) assert.equal(actualHash.length, 64, 'Hash should be 64 characters') - assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') - // Should be consistent assert.equal(hashHookState(account, hookHash, hookStateKey), actualHash) }) it('hashHookDefinition', function () { const hookHash = - 'HOOK123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + 'CA4562711E4679FE9317DD767871E90A404C7A8B84FAFD35EC2CF0231F1F6DAF' const actualHash = hashHookDefinition(hookHash) assert.equal(actualHash.length, 64, 'Hash should be 64 characters') - assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') - // Should be consistent assert.equal(hashHookDefinition(hookHash), actualHash) }) @@ -491,12 +482,10 @@ describe('Hashes', function () { it('hashMPToken', function () { const account = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' const mpTokenIssuanceID = - 'MPTOKEN123456789ABCDEF0123456789ABCDEF0123456789ABCDEF012' + '9EDF5DB29F536DD3919037F1E8A72B040D075571A10C9000294C57B5ECEEA791' const actualHash = hashMPToken(account, mpTokenIssuanceID) assert.equal(actualHash.length, 64, 'Hash should be 64 characters') - assert.match(actualHash, /^[A-F0-9]+$/, 'Hash should be uppercase hex') - // Should be consistent assert.equal(hashMPToken(account, mpTokenIssuanceID), actualHash) }) From 6bbb12d39d66a8bbcc9968a8982f0b2d6c889042 Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Fri, 5 Sep 2025 01:21:24 +0530 Subject: [PATCH 3/3] feat: add utility functions for currency and ID conversions in hash utilities --- packages/xrpl/src/utils/hashes/index.ts | 99 ++++++++++++++++++++----- 1 file changed, 79 insertions(+), 20 deletions(-) diff --git a/packages/xrpl/src/utils/hashes/index.ts b/packages/xrpl/src/utils/hashes/index.ts index a5cbb9d52a..5011c3debf 100644 --- a/packages/xrpl/src/utils/hashes/index.ts +++ b/packages/xrpl/src/utils/hashes/index.ts @@ -41,6 +41,57 @@ function currencyToHex(currency: string): string { return bytesToHex(Uint8Array.from(bytes)) } +/** + * Convert currency code to hex for Issue serialization. + * XRP uses 160-bit zero currency code, others use standard encoding. + * + * @param code - The currency code to convert. + * @returns The hex representation for use in Issue serialization. + */ +function toIssueCurrencyHex(code: string): string { + const upper = code.toUpperCase() + // Native XRP uses 160-bit zero currency code + if (upper === 'XRP') { + return '00'.repeat(20) + } + // If already a 160-bit hex code, normalize case + if (/^[0-9a-f]{40}$/iu.test(code)) { + return code.toUpperCase() + } + // Otherwise treat as 3-letter ISO-like code + return currencyToHex(upper) +} + +/** + * Convert a number, bigint, or string to a 64-bit hex string. + * + * @param id - The ID to convert (number, bigint, or string). + * @returns 64-bit hex string representation. + * @throws Error if the ID is out of range or invalid format. + */ +function toU64Hex(id: bigint | number | string): string { + let bi: bigint + if (typeof id === 'bigint') { + bi = id + } else if (typeof id === 'number') { + if (!Number.isSafeInteger(id) || id < 0) { + throw new Error( + 'claimID must be a non-negative safe integer, bigint, or decimal string', + ) + } + bi = BigInt(id) + } else { + const str = id.trim() + bi = + str.startsWith('0x') || str.startsWith('0X') ? BigInt(str) : BigInt(str) + } + const maxUint64 = BigInt('0xffffffffffffffff') + if (bi < BigInt(0) || bi > maxUint64) { + throw new Error('claimID out of range for uint64') + } + return bi.toString(16).toUpperCase().padStart(16, '0') +} + /** * Hash the given binary transaction data with the single-signing prefix. * @@ -249,12 +300,13 @@ export function hashNFTokenPage( address: string, nfTokenIDLow96: string, ): string { - // Validate that nfTokenIDLow96 is a 24-character hex string (96 bits) - if (!/^[0-9A-F]{24}$/iu.test(nfTokenIDLow96)) { + // Normalize and validate a 24-char hex (96 bits) + const normalized = nfTokenIDLow96.replace(/^0x/iu, '').toUpperCase() + if (!/^[0-9A-F]{24}$/u.test(normalized)) { throw new Error('nfTokenIDLow96 must be a 24-character hex string') } return sha512Half( - ledgerSpaceHex('nfTokenPage') + addressToHex(address) + nfTokenIDLow96, + ledgerSpaceHex('nfTokenPage') + addressToHex(address) + normalized, ) } @@ -290,8 +342,8 @@ export function hashAMMRoot( issuer1?: string, issuer2?: string, ): string { - const cur1Hex = currencyToHex(currency1) - const cur2Hex = currencyToHex(currency2) + const cur1Hex = toIssueCurrencyHex(currency1) + const cur2Hex = toIssueCurrencyHex(currency2) const iss1Hex = issuer1 ? addressToHex(issuer1) : '00'.repeat(20) const iss2Hex = issuer2 ? addressToHex(issuer2) : '00'.repeat(20) @@ -318,13 +370,16 @@ export function hashAMMRoot( * @category Utilities */ export function hashOracle(address: string, oracleID: string): string { - // Validate that oracleID is a 64-character uppercase hex string - if (!/^[0-9A-F]{64}$/u.test(oracleID)) { + const normalized = oracleID.replace(/^0x/iu, '').toUpperCase() + // eslint-disable-next-line require-unicode-regexp -- Targeting older ES version + if (!/^[0-9A-F]{64}$/.test(normalized)) { throw new Error( 'oracleID must be a 64-character uppercase hexadecimal string', ) } - return sha512Half(ledgerSpaceHex('oracle') + addressToHex(address) + oracleID) + return sha512Half( + ledgerSpaceHex('oracle') + addressToHex(address) + normalized, + ) } /** @@ -337,11 +392,12 @@ export function hashOracle(address: string, oracleID: string): string { * @category Utilities */ export function hashHook(address: string, hookHash: string): string { - // Validate that hookHash is a 64-character hex string - if (!/^[0-9A-F]{64}$/iu.test(hookHash)) { + const normalized = hookHash.replace(/^0x/iu, '').toUpperCase() + // eslint-disable-next-line require-unicode-regexp -- Targeting older ES version + if (!/^[0-9A-F]{64}$/.test(normalized)) { throw new Error('hookHash must be a 64-character hexadecimal string') } - return sha512Half(ledgerSpaceHex('hook') + addressToHex(address) + hookHash) + return sha512Half(ledgerSpaceHex('hook') + addressToHex(address) + normalized) } /** @@ -359,18 +415,21 @@ export function hashHookState( hookHash: string, hookStateKey: string, ): string { - // Validate parameters - if (!/^[0-9A-F]{64}$/iu.test(hookHash)) { + const hookHashNorm = hookHash.replace(/^0x/iu, '').toUpperCase() + const stateKeyNorm = hookStateKey.replace(/^0x/iu, '').toUpperCase() + // eslint-disable-next-line require-unicode-regexp -- Targeting older ES version + if (!/^[0-9A-F]{64}$/.test(hookHashNorm)) { throw new Error('hookHash must be a 64-character hexadecimal string') } - if (!/^[0-9A-F]{64}$/iu.test(hookStateKey)) { + // eslint-disable-next-line require-unicode-regexp -- Targeting older ES version + if (!/^[0-9A-F]{64}$/.test(stateKeyNorm)) { throw new Error('hookStateKey must be a 64-character hexadecimal string') } return sha512Half( ledgerSpaceHex('hookState') + addressToHex(address) + - hookHash + - hookStateKey, + hookHashNorm + + stateKeyNorm, ) } @@ -444,13 +503,13 @@ export function hashBridge( export function hashXChainOwnedClaimID( address: string, bridgeAccount: string, - claimID: number, + claimID: bigint | number | string, ): string { return sha512Half( ledgerSpaceHex('xchainOwnedClaimID') + addressToHex(address) + addressToHex(bridgeAccount) + - claimID.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + toU64Hex(claimID), ) } @@ -466,13 +525,13 @@ export function hashXChainOwnedClaimID( export function hashXChainOwnedCreateAccountClaimID( address: string, bridgeAccount: string, - claimID: number, + claimID: bigint | number | string, ): string { return sha512Half( ledgerSpaceHex('xchainOwnedCreateAccountClaimID') + addressToHex(address) + addressToHex(bridgeAccount) + - claimID.toString(HEX).padStart(BYTE_LENGTH * 2, '0'), + toU64Hex(claimID), ) }