diff --git a/packages/xrpl/src/index.ts b/packages/xrpl/src/index.ts index 39f84a77..12604513 100644 --- a/packages/xrpl/src/index.ts +++ b/packages/xrpl/src/index.ts @@ -7,6 +7,8 @@ export * from './models' export * from './utils' +export * from './sugar' + export * from './errors' export { default as Wallet } from './Wallet' diff --git a/packages/xrpl/src/models/common/index.ts b/packages/xrpl/src/models/common/index.ts index efafad38..e6020490 100644 --- a/packages/xrpl/src/models/common/index.ts +++ b/packages/xrpl/src/models/common/index.ts @@ -60,11 +60,11 @@ export interface HookGrant { */ HookGrant: { /** - * + * The hook hash of the grant. */ HookHash: string /** - * + * The account authorized on the grant. */ Authorize?: string } @@ -79,13 +79,13 @@ export interface HookParameter { */ HookParameter: { /** - * + * The name of the parameter. */ HookParameterName: string /** - * + * The value of the parameter. */ - HookParameterValue: number + HookParameterValue: string } } @@ -98,31 +98,31 @@ export interface Hook { */ Hook: { /** - * + * The code that is executed when the hook is triggered. */ CreateCode: string /** - * + * The flags that are set on the hook. */ Flags: number /** - * + * The transactions that triggers the hook. Represented as a 256Hash */ HookOn?: string /** - * + * The namespace of the hook. */ HookNamespace?: string /** - * + * The API version of the hook. */ HookApiVersion?: number /** - * + * The parameters of the hook. */ HookParameters?: HookParameter[] /** - * + * The grants of the hook. */ HookGrants?: HookGrant[] } diff --git a/packages/xrpl/src/models/transactions/setHook.ts b/packages/xrpl/src/models/transactions/setHook.ts index d55fb968..e7f334ba 100644 --- a/packages/xrpl/src/models/transactions/setHook.ts +++ b/packages/xrpl/src/models/transactions/setHook.ts @@ -17,6 +17,7 @@ export interface SetHook extends BaseTransaction { } const MAX_HOOKS = 4 +const HEX_REGEX = /^[0-9A-Fa-f]{64}$/u /** * Verify the form and type of an SetHook at runtime. @@ -27,21 +28,29 @@ const MAX_HOOKS = 4 export function validateSetHook(tx: Record): void { validateBaseTransaction(tx) - if (tx.Hooks === undefined) { - throw new ValidationError('SetHook: missing field Hooks') - } - if (!Array.isArray(tx.Hooks)) { throw new ValidationError('SetHook: invalid Hooks') } - if (tx.Hooks.length === 0) { - throw new ValidationError('SetHook: need at least 1 hook in Hooks') - } - if (tx.Hooks.length > MAX_HOOKS) { throw new ValidationError( `SetHook: maximum of ${MAX_HOOKS} hooks allowed in Hooks`, ) } + + for (const hook of tx.Hooks) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be a Hook + const hookObject = hook as Hook + const { HookOn, HookNamespace } = hookObject.Hook + if (HookOn !== undefined && !HEX_REGEX.test(HookOn)) { + throw new ValidationError( + `SetHook: HookOn in Hook must be a 256-bit (32-byte) hexadecimal value`, + ) + } + if (HookNamespace !== undefined && !HEX_REGEX.test(HookNamespace)) { + throw new ValidationError( + `SetHook: HookNamespace in Hook must be a 256-bit (32-byte) hexadecimal value`, + ) + } + } } diff --git a/packages/xrpl/src/sugar/autofill.ts b/packages/xrpl/src/sugar/autofill.ts index 05d50a96..79718bdf 100644 --- a/packages/xrpl/src/sugar/autofill.ts +++ b/packages/xrpl/src/sugar/autofill.ts @@ -8,7 +8,7 @@ import { Transaction } from '../models/transactions' import { setTransactionFlagsToNumber } from '../models/utils/flags' import { xrpToDrops } from '../utils' -import getFeeXrp from './getFeeXrp' +import { getFeeXrp } from './getFeeXrp' // Expire unconfirmed transactions after 20 ledger versions, approximately 1 minute, by default const LEDGER_OFFSET = 20 diff --git a/packages/xrpl/src/sugar/getFeeXrp.ts b/packages/xrpl/src/sugar/getFeeXrp.ts index 0285d821..72d4ea68 100644 --- a/packages/xrpl/src/sugar/getFeeXrp.ts +++ b/packages/xrpl/src/sugar/getFeeXrp.ts @@ -14,7 +14,7 @@ const BASE_10 = 10 * @param cushion - The fee cushion to use. * @returns The transaction fee. */ -export default async function getFeeXrp( +export async function getFeeXrp( client: Client, cushion?: number, ): Promise { @@ -43,3 +43,22 @@ export default async function getFeeXrp( // Round fee to 6 decimal places return new BigNumber(fee.toFixed(NUM_DECIMAL_PLACES)).toString(BASE_10) } + +/** + * Calculates the estimated transaction fee. + * Note: This is a public API that can be called directly. + * + * @param client - The Client used to connect to the ledger. + * @param txBlob - The encoded transaction to estimate the fee for. + * @returns The transaction fee. + */ +export async function getFeeEstimateXrp( + client: Client, + txBlob: string, +): Promise { + const response = await client.request({ + command: 'fee', + tx_blob: txBlob, + }) + return response.result.drops.base_fee +} diff --git a/packages/xrpl/src/sugar/index.ts b/packages/xrpl/src/sugar/index.ts index 991c1533..c132bf7f 100644 --- a/packages/xrpl/src/sugar/index.ts +++ b/packages/xrpl/src/sugar/index.ts @@ -5,6 +5,7 @@ export { getBalances, getXrpBalance } from './balances' export { default as getLedgerIndex } from './getLedgerIndex' export { default as getOrderbook } from './getOrderbook' +export { getFeeXrp, getFeeEstimateXrp } from './getFeeXrp' export * from './submit' diff --git a/packages/xrpl/src/utils/hooks.ts b/packages/xrpl/src/utils/hooks.ts new file mode 100644 index 00000000..6c334d7a --- /dev/null +++ b/packages/xrpl/src/utils/hooks.ts @@ -0,0 +1,128 @@ +/** + * @module tts + * @description + * This module contains the transaction types and the function to calculate the hook on + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- Required +import createHash = require('create-hash') + +import { HookParameter } from '../models/common' + +/** + * @constant tts + * @description + * Transaction types + */ +export const tts = { + ttPAYMENT: 0, + ttESCROW_CREATE: 1, + ttESCROW_FINISH: 2, + ttACCOUNT_SET: 3, + ttESCROW_CANCEL: 4, + ttREGULAR_KEY_SET: 5, + ttOFFER_CREATE: 7, + ttOFFER_CANCEL: 8, + ttTICKET_CREATE: 10, + ttSIGNER_LIST_SET: 12, + ttPAYCHAN_CREATE: 13, + ttPAYCHAN_FUND: 14, + ttPAYCHAN_CLAIM: 15, + ttCHECK_CREATE: 16, + ttCHECK_CASH: 17, + ttCHECK_CANCEL: 18, + ttDEPOSIT_PREAUTH: 19, + ttTRUST_SET: 20, + ttACCOUNT_DELETE: 21, + ttHOOK_SET: 22, + ttNFTOKEN_MINT: 25, + ttNFTOKEN_BURN: 26, + ttNFTOKEN_CREATE_OFFER: 27, + ttNFTOKEN_CANCEL_OFFER: 28, + ttNFTOKEN_ACCEPT_OFFER: 29, +} + +/** + * @typedef TTS + * @description + * Transaction types + */ +export type TTS = typeof tts + +/** + * Calculate the hook on + * + * @param arr - array of transaction types + * @returns the hook on + */ +export function calculateHookOn(arr: Array): string { + let hash = '0x3e3ff5bf' + arr.forEach((nth) => { + let value = BigInt(hash) + // eslint-disable-next-line no-bitwise -- Required + value ^= BigInt(1) << BigInt(tts[nth]) + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- Required + hash = `0x${value.toString(16)}` + }) + hash = hash.replace('0x', '') + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- Required + hash = hash.padStart(64, '0') + return hash.toUpperCase() +} + +/** + * Calculate the sha256 of a string + * + * @param string - the string to calculate the sha256 + * @returns the sha256 + */ +export async function sha256(string: string): Promise { + const hash = createHash('sha256') + hash.update(string) + const hashBuffer = hash.digest() + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- Required + .map((bytes) => bytes.toString(16).padStart(2, '0')) + .join('') + return hashHex +} + +/** + * Calculate the hex of a namespace + * + * @param namespace - the namespace to calculate the hex + * @returns the hex + */ +export async function hexNamespace(namespace: string): Promise { + return (await sha256(namespace)).toUpperCase() +} + +/** + * Calculate the hex of the hook parameters + * + * @param data - the hook parameters + * @returns the hex of the hook parameters + */ +export function hexHookParameters(data: HookParameter[]): HookParameter[] { + const hookParameters: HookParameter[] = [] + for (const parameter of data) { + hookParameters.push({ + HookParameter: { + HookParameterName: Buffer.from( + parameter.HookParameter.HookParameterName, + 'utf8', + ) + .toString('hex') + .toUpperCase(), + HookParameterValue: Buffer.from( + parameter.HookParameter.HookParameterValue, + 'utf8', + ) + .toString('hex') + .toUpperCase(), + }, + }) + } + return hookParameters +} diff --git a/packages/xrpl/src/utils/index.ts b/packages/xrpl/src/utils/index.ts index 299b27a7..452bf38c 100644 --- a/packages/xrpl/src/utils/index.ts +++ b/packages/xrpl/src/utils/index.ts @@ -40,6 +40,7 @@ import { hashEscrow, hashPaymentChannel, } from './hashes' +import { calculateHookOn, hexNamespace, hexHookParameters, TTS } from './hooks' import parseNFTokenID from './parseNFTokenID' import { percentToTransferRate, @@ -222,4 +223,8 @@ export { getNFTokenID, createCrossChainPayment, parseNFTokenID, + calculateHookOn, + hexNamespace, + hexHookParameters, + TTS, } diff --git a/packages/xrpl/test/client/getFeeXrp.test.ts b/packages/xrpl/test/client/getFeeXrp.test.ts index 90147f3a..93005019 100644 --- a/packages/xrpl/test/client/getFeeXrp.test.ts +++ b/packages/xrpl/test/client/getFeeXrp.test.ts @@ -1,6 +1,6 @@ import { assert } from 'chai' -import getFeeXrp from '../../src/sugar/getFeeXrp' +import { getFeeXrp } from '../../src/sugar/getFeeXrp' import rippled from '../fixtures/rippled' import { setupClient, diff --git a/packages/xrpl/test/models/setHook.test.ts b/packages/xrpl/test/models/setHook.test.ts new file mode 100644 index 00000000..a4acf5c0 --- /dev/null +++ b/packages/xrpl/test/models/setHook.test.ts @@ -0,0 +1,150 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateSetHook } from '../../src/models/transactions/setHook' + +/** + * SetHook Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('SetHook', function () { + let setHookTx + + beforeEach(function () { + setHookTx = { + Flags: 0, + TransactionType: 'SetHook', + Account: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn', + Fee: '12', + Hooks: [ + { + Hook: { + CreateCode: + '0061736D01000000011C0460057F7F7F7F7F017E60037F7F7E017E60027F7F017F60017F017E02230303656E76057472616365000003656E7606616363657074000103656E76025F670002030201030503010002062B077F0141B088040B7F004180080B7F0041A6080B7F004180080B7F0041B088040B7F0041000B7F0041010B07080104686F6F6B00030AC4800001C0800001017F230041106B220124002001200036020C41920841134180084112410010001A410022002000420010011A41012200200010021A200141106A240042000B0B2C01004180080B254163636570742E633A2043616C6C65642E00224163636570742E633A2043616C6C65642E22', + HookOn: + '000000000000000000000000000000000000000000000000000000003E3FF5B7', + Flags: 1, + HookApiVersion: 0, + HookNamespace: + '4FF9961269BF7630D32E15276569C94470174A5DA79FA567C0F62251AA9A36B9', + }, + }, + ], + } as any + }) + + it(`verifies valid SetHook`, function () { + assert.doesNotThrow(() => validateSetHook(setHookTx)) + assert.doesNotThrow(() => validate(setHookTx)) + }) + + // it(`throws w/ empty Hooks`, function () { + // setHookTx.Hooks = [] + + // assert.throws( + // () => validateSetHook(setHookTx), + // ValidationError, + // 'SetHook: need at least 1 member in Hooks', + // ) + // assert.throws( + // () => validate(setHookTx), + // ValidationError, + // 'SetHook: need at least 1 member in Hooks', + // ) + // }) + + it(`throws w/ invalid Hooks`, function () { + setHookTx.Hooks = 'khgfgyhujk' + + assert.throws( + () => validateSetHook(setHookTx), + ValidationError, + 'SetHook: invalid Hooks', + ) + assert.throws( + () => validate(setHookTx), + ValidationError, + 'SetHook: invalid Hooks', + ) + }) + + it(`throws w/ maximum of 4 members allowed in Hooks`, function () { + setHookTx.Hooks = [] + const hook = { + Hook: { + CreateCode: + '0061736D01000000011C0460057F7F7F7F7F017E60037F7F7E017E60027F7F017F60017F017E02230303656E76057472616365000003656E7606616363657074000103656E76025F670002030201030503010002062B077F0141B088040B7F004180080B7F0041A6080B7F004180080B7F0041B088040B7F0041000B7F0041010B07080104686F6F6B00030AC4800001C0800001017F230041106B220124002001200036020C41920841134180084112410010001A410022002000420010011A41012200200010021A200141106A240042000B0B2C01004180080B254163636570742E633A2043616C6C65642E00224163636570742E633A2043616C6C65642E22', + HookOn: + '000000000000000000000000000000000000000000000000000000003E3FF5B7', + Flags: 1, + HookApiVersion: 0, + HookNamespace: + '4FF9961269BF7630D32E15276569C94470174A5DA79FA567C0F62251AA9A36B9', + }, + } + setHookTx.Hooks.push(hook) + setHookTx.Hooks.push(hook) + setHookTx.Hooks.push(hook) + setHookTx.Hooks.push(hook) + setHookTx.Hooks.push(hook) + + const errorMessage = 'SetHook: maximum of 4 hooks allowed in Hooks' + assert.throws( + () => validateSetHook(setHookTx), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(setHookTx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid HookOn in Hooks`, function () { + setHookTx.SignerQuorum = 2 + setHookTx.Hooks = [ + { + Hook: { + CreateCode: + '0061736D01000000011C0460057F7F7F7F7F017E60037F7F7E017E60027F7F017F60017F017E02230303656E76057472616365000003656E7606616363657074000103656E76025F670002030201030503010002062B077F0141B088040B7F004180080B7F0041A6080B7F004180080B7F0041B088040B7F0041000B7F0041010B07080104686F6F6B00030AC4800001C0800001017F230041106B220124002001200036020C41920841134180084112410010001A410022002000420010011A41012200200010021A200141106A240042000B0B2C01004180080B254163636570742E633A2043616C6C65642E00224163636570742E633A2043616C6C65642E22', + HookOn: '', + Flags: 1, + HookApiVersion: 0, + HookNamespace: + '4FF9961269BF7630D32E15276569C94470174A5DA79FA567C0F62251AA9A36B9', + }, + }, + ] + const errorMessage = + 'SetHook: HookOn in Hook must be a 256-bit (32-byte) hexadecimal value' + assert.throws( + () => validateSetHook(setHookTx), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(setHookTx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid HookNamespace in Hooks`, function () { + setHookTx.SignerQuorum = 2 + setHookTx.Hooks = [ + { + Hook: { + CreateCode: + '0061736D01000000011C0460057F7F7F7F7F017E60037F7F7E017E60027F7F017F60017F017E02230303656E76057472616365000003656E7606616363657074000103656E76025F670002030201030503010002062B077F0141B088040B7F004180080B7F0041A6080B7F004180080B7F0041B088040B7F0041000B7F0041010B07080104686F6F6B00030AC4800001C0800001017F230041106B220124002001200036020C41920841134180084112410010001A410022002000420010011A41012200200010021A200141106A240042000B0B2C01004180080B254163636570742E633A2043616C6C65642E00224163636570742E633A2043616C6C65642E22', + HookOn: + '000000000000000000000000000000000000000000000000000000003E3FF5B7', + Flags: 1, + HookApiVersion: 0, + HookNamespace: '', + }, + }, + ] + const errorMessage = + 'SetHook: HookNamespace in Hook must be a 256-bit (32-byte) hexadecimal value' + assert.throws( + () => validateSetHook(setHookTx), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(setHookTx), ValidationError, errorMessage) + }) +}) diff --git a/packages/xrpl/test/utils/hooks.test.ts b/packages/xrpl/test/utils/hooks.test.ts new file mode 100644 index 00000000..d3c8b138 --- /dev/null +++ b/packages/xrpl/test/utils/hooks.test.ts @@ -0,0 +1,58 @@ +import { assert } from 'chai' + +import { + calculateHookOn, + hexNamespace, + hexHookParameters, + TTS, +} from '../../src' + +describe('test hook on', function () { + it('all', function () { + const result = calculateHookOn([]) + assert.equal( + result, + '000000000000000000000000000000000000000000000000000000003E3FF5BF', + ) + }) + it('one', function () { + const invokeOn: Array = ['ttACCOUNT_SET'] + const result = calculateHookOn(invokeOn) + assert.equal( + result, + '000000000000000000000000000000000000000000000000000000003E3FF5B7', + ) + }) +}) + +describe('test hook namespace', function () { + it('basic', async function () { + const result = await hexNamespace('starter') + assert.equal( + result, + '4FF9961269BF7630D32E15276569C94470174A5DA79FA567C0F62251AA9A36B9', + ) + }) +}) + +describe('test hook parameters', function () { + it('basic', async function () { + const parameters = [ + { + HookParameter: { + HookParameterName: 'name1', + HookParameterValue: 'value1', + }, + }, + ] + const result = hexHookParameters(parameters) + assert.deepEqual(result, [ + { + HookParameter: { + HookParameterName: '6E616D6531', + HookParameterValue: '76616C756531', + }, + }, + ]) + }) +})