From bec487cf719e0b5ab47b5546554162d855db7c67 Mon Sep 17 00:00:00 2001 From: Omar Khan Date: Wed, 18 Aug 2021 17:24:34 -0400 Subject: [PATCH] refactor: Define PaymentTransaction model (#1542) - Defines a TypeScript type for PaymentTransaction - Provides an optional function to users for verifying a PaymentTransaction instance at runtime: verifyPaymentTransaction() - Adds tests for verifyPaymentTransaction() - Adds isFlagEnabled() util to be used for models --- src/models/transactions/common.ts | 3 - src/models/transactions/index.ts | 1 + src/models/transactions/paymentTransaction.ts | 109 +++++++++++++++ src/models/transactions/transaction.ts | 25 ++-- src/models/utils/index.ts | 13 +- test/models/baseTransaction.ts | 15 -- test/models/paymentTransaction.ts | 132 ++++++++++++++++++ test/models/utils.ts | 29 ++++ 8 files changed, 296 insertions(+), 31 deletions(-) create mode 100644 src/models/transactions/paymentTransaction.ts create mode 100644 test/models/paymentTransaction.ts create mode 100644 test/models/utils.ts diff --git a/src/models/transactions/common.ts b/src/models/transactions/common.ts index f2c095b7..151e6135 100644 --- a/src/models/transactions/common.ts +++ b/src/models/transactions/common.ts @@ -110,9 +110,6 @@ export function verifyBaseTransaction(common: BaseTransaction): void { if (common.Sequence !== undefined && typeof common.Sequence !== 'number') throw new ValidationError("BaseTransaction: invalid Sequence") - if (common.Flags !== undefined && typeof common.Flags !== 'number') - throw new ValidationError("BaseTransaction: invalid Flags") - if (common.AccountTxnID !== undefined && typeof common.AccountTxnID !== 'string') throw new ValidationError("BaseTransaction: invalid AccountTxnID") diff --git a/src/models/transactions/index.ts b/src/models/transactions/index.ts index 9a249f80..1db85eaa 100644 --- a/src/models/transactions/index.ts +++ b/src/models/transactions/index.ts @@ -7,3 +7,4 @@ export * from './checkCash' export * from './checkCancel' export * from './accountDelete' export * from './signerListSet' +export * from './paymentTransaction' diff --git a/src/models/transactions/paymentTransaction.ts b/src/models/transactions/paymentTransaction.ts new file mode 100644 index 00000000..f56284bf --- /dev/null +++ b/src/models/transactions/paymentTransaction.ts @@ -0,0 +1,109 @@ +import { ValidationError } from '../../common/errors' +import { Amount, Path } from '../common' +import { isFlagEnabled } from '../utils' +import { BaseTransaction, isAmount, GlobalFlags, verifyBaseTransaction } from './common' + +export enum PaymentTransactionFlagsEnum { + tfNoDirectRipple = 0x00010000, + tfPartialPayment = 0x00020000, + tfLimitQuality = 0x00040000, +} + +export interface PaymentTransactionFlags extends GlobalFlags { + tfNoDirectRipple?: boolean + tfPartialPayment?: boolean + tfLimitQuality?: boolean +} +export interface PaymentTransaction extends BaseTransaction { + TransactionType: 'Payment' + Amount: Amount + Destination: string + DestinationTag?: number + InvoiceID?: string + Paths?: Path[] + SendMax?: Amount + DeliverMin?: Amount + Flags?: number | PaymentTransactionFlags +} + +/** + * @param {PaymentTransaction} tx A Payment Transaction. + * @returns {void} + * @throws {ValidationError} When the PaymentTransaction is malformed. + */ +export function verifyPaymentTransaction(tx: PaymentTransaction): void { + verifyBaseTransaction(tx) + + if (tx.Amount === undefined) { + throw new ValidationError('PaymentTransaction: missing field Amount') + } + + if (!isAmount(tx.Amount)) { + throw new ValidationError('PaymentTransaction: invalid Amount') + } + + if (tx.Destination === undefined) { + throw new ValidationError('PaymentTransaction: missing field Destination') + } + + if (!isAmount(tx.Destination)) { + throw new ValidationError('PaymentTransaction: invalid Destination') + } + + if (tx.DestinationTag !== undefined && typeof tx.DestinationTag !== 'number') { + throw new ValidationError('PaymentTransaction: DestinationTag must be a number') + } + + if (tx.InvoiceID !== undefined && typeof tx.InvoiceID !== 'string') { + throw new ValidationError('PaymentTransaction: InvoiceID must be a string') + } + + if (tx.Paths !== undefined && !isPaths(tx.Paths)) { + throw new ValidationError('PaymentTransaction: invalid Paths') + } + + if (tx.SendMax !== undefined && !isAmount(tx.SendMax)) { + throw new ValidationError('PaymentTransaction: invalid SendMax') + } + + if (tx.DeliverMin !== undefined) { + const isTfPartialPayment = typeof tx.Flags === 'number' ? + isFlagEnabled(tx.Flags, PaymentTransactionFlagsEnum.tfPartialPayment) : + tx.Flags?.tfPartialPayment ?? false + + if (!isTfPartialPayment) { + throw new ValidationError('PaymentTransaction: tfPartialPayment flag required with DeliverMin') + } + + if (!isAmount(tx.DeliverMin)) { + throw new ValidationError('PaymentTransaction: invalid DeliverMin') + } + } +} + +function isPaths(paths: Path[]): boolean { + if (!Array.isArray(paths) || paths.length === 0) { + return false + } + + for (const i in paths) { + const path = paths[i] + if (!Array.isArray(path) || path.length === 0) { + return false + } + + for (const j in path) { + const pathStep = path[j] + const { account, currency, issuer } = pathStep + if ( + (account !== undefined && typeof account !== 'string') || + (currency !== undefined && typeof currency !== 'string') || + (issuer !== undefined && typeof issuer !== 'string') + ) { + return false + } + } + } + + return true; +} diff --git a/src/models/transactions/transaction.ts b/src/models/transactions/transaction.ts index 99c565d1..6d19e53a 100644 --- a/src/models/transactions/transaction.ts +++ b/src/models/transactions/transaction.ts @@ -1,16 +1,17 @@ -import Metadata from "../common/metadata"; -import { AccountDelete } from "./accountDelete"; -import { AccountSet } from "./accountSet"; -import { CheckCancel } from "./checkCancel"; -import { CheckCash } from "./checkCash"; -import { CheckCreate } from "./checkCreate"; +import Metadata from "../common/metadata" +import { AccountDelete } from "./accountDelete" +import { AccountSet } from "./accountSet" +import { CheckCancel } from "./checkCancel" +import { CheckCash } from "./checkCash" +import { CheckCreate } from "./checkCreate" import { OfferCancel } from "./offerCancel" -import { OfferCreate } from "./offerCreate"; -import { SignerListSet } from "./signerListSet"; +import { OfferCreate } from "./offerCreate" +import { PaymentTransaction } from "./paymentTransaction" +import { SignerListSet } from "./signerListSet" export type Transaction = - AccountSet - | AccountDelete + AccountDelete + | AccountSet | CheckCancel | CheckCash | CheckCreate @@ -21,7 +22,7 @@ export type Transaction = | OfferCancel // | OfferCancel | OfferCreate -// | PaymentTransaction + | PaymentTransaction // | PaymentChannelClaim // | PaymentChannelCreate // | PaymentChannelFund @@ -31,6 +32,6 @@ export type Transaction = // | TrustSet export interface TransactionAndMetadata { - transaction: Transaction; + transaction: Transaction metadata: Metadata } diff --git a/src/models/utils/index.ts b/src/models/utils/index.ts index d9c38b0e..d6f2ac18 100644 --- a/src/models/utils/index.ts +++ b/src/models/utils/index.ts @@ -7,4 +7,15 @@ */ export function onlyHasFields(obj: object, fields: Array): boolean { return Object.keys(obj).every((key:string) => fields.includes(key)) -} \ No newline at end of file +} + +/** + * Perform bitwise AND (&) to check if a flag is enabled within Flags (as a number). + * + * @param {number} Flags A number that represents flags enabled. + * @param {number} checkFlag A specific flag to check if it's enabled within Flags. + * @returns {boolean} True if checkFlag is enabled within Flags. + */ +export function isFlagEnabled(Flags: number, checkFlag: number): boolean { + return (checkFlag & Flags) === checkFlag +} diff --git a/test/models/baseTransaction.ts b/test/models/baseTransaction.ts index 2fae7e62..6e4ef991 100644 --- a/test/models/baseTransaction.ts +++ b/test/models/baseTransaction.ts @@ -103,21 +103,6 @@ describe('Transaction Verification', function () { ) }) - it (`Handles invalid Flags`, () => { - const invalidFlags = { - Account: "r97KeayHuEsDwyU1yPBVtMLLoQr79QcRFe", - TransactionType: "Payment", - Flags: "1000" - } as any - - - assert.throws( - () => verifyBaseTransaction(invalidFlags), - ValidationError, - "BaseTransaction: invalid Flags" - ) - }) - it (`Handles invalid LastLedgerSequence`, () => { const invalidLastLedgerSequence = { Account: "r97KeayHuEsDwyU1yPBVtMLLoQr79QcRFe", diff --git a/test/models/paymentTransaction.ts b/test/models/paymentTransaction.ts new file mode 100644 index 00000000..59f47a25 --- /dev/null +++ b/test/models/paymentTransaction.ts @@ -0,0 +1,132 @@ +import { ValidationError } from 'xrpl-local/common/errors' +import { PaymentTransactionFlagsEnum, verifyPaymentTransaction } from './../../src/models/transactions/paymentTransaction' +import { assert } from 'chai' + +/** + * PaymentTransaction Verification Testing + * + * Providing runtime verification testing for each specific transaction type + */ +describe('Payment Transaction Verification', () => { + let paymentTransaction + + beforeEach(() => { + paymentTransaction = { + TransactionType: 'Payment', + Account: 'rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo', + Amount: '1234', + Destination: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + DestinationTag: 1, + InvoiceID: '6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59B', + Paths: [[{ account: 'aw0efji', currency: 'XRP', issuer: 'apsoeijf90wp34fh'}]], + SendMax: '100000000', + } as any + }) + + it (`verifies valid PaymentTransaction`, () => { + assert.doesNotThrow(() => verifyPaymentTransaction(paymentTransaction)) + }) + + it (`throws when Amount is missing`, () => { + delete paymentTransaction.Amount + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: missing field Amount' + ) + }) + + it (`throws when Amount is invalid`, () => { + paymentTransaction.Amount = 1234 + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: invalid Amount' + ) + }) + + it (`throws when Destination is missing`, () => { + delete paymentTransaction.Destination + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: missing field Destination' + ) + }) + + it (`throws when Destination is invalid`, () => { + paymentTransaction.Destination = 7896214 + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: invalid Destination' + ) + }) + + it (`throws when DestinationTag is not a number`, () => { + paymentTransaction.DestinationTag = '1' + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: DestinationTag must be a number' + ) + }) + + it (`throws when InvoiceID is not a string`, () => { + paymentTransaction.InvoiceID = 19832 + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: InvoiceID must be a string' + ) + }) + + it (`throws when Paths is invalid`, () => { + paymentTransaction.Paths = [[{ account: 123 }]] + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: invalid Paths' + ) + }) + + it (`throws when SendMax is invalid`, () => { + paymentTransaction.SendMax = 100000000 + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: invalid SendMax' + ) + }) + + it (`verifies valid DeliverMin with tfPartialPayment flag set as a number`, () => { + paymentTransaction.DeliverMin = '10000' + paymentTransaction.Flags = PaymentTransactionFlagsEnum.tfPartialPayment, + assert.doesNotThrow(() => verifyPaymentTransaction(paymentTransaction)) + }) + + it (`verifies valid DeliverMin with tfPartialPayment flag set as a boolean`, () => { + paymentTransaction.DeliverMin = '10000' + paymentTransaction.Flags = { tfPartialPayment: true } + assert.doesNotThrow(() => verifyPaymentTransaction(paymentTransaction)) + }) + + it (`throws when DeliverMin is invalid`, () => { + paymentTransaction.DeliverMin = 10000 + paymentTransaction.Flags = { tfPartialPayment: true } + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: invalid DeliverMin' + ) + }) + + it (`throws when tfPartialPayment flag is missing with valid DeliverMin`, () => { + paymentTransaction.DeliverMin = '10000' + assert.throws( + () => verifyPaymentTransaction(paymentTransaction), + ValidationError, + 'PaymentTransaction: tfPartialPayment flag required with DeliverMin' + ) + }) +}) diff --git a/test/models/utils.ts b/test/models/utils.ts new file mode 100644 index 00000000..07de6d25 --- /dev/null +++ b/test/models/utils.ts @@ -0,0 +1,29 @@ +import { isFlagEnabled } from '../../src/models/utils' +import { assert } from 'chai' + +/** + * Utils Testing + * + * Provides tests for utils used in models + */ +describe('Models Utils', () => { + describe('isFlagEnabled', () => { + let flags + const flag1 = 0x00010000 + const flag2 = 0x00020000 + + beforeEach(() => { + flags = 0x00000000 + }) + + it('verifies a flag is enabled', () => { + flags += flag1 + flag2 + assert.isTrue(isFlagEnabled(flags, flag1)) + }) + + it('verifies a flag is not enabled', () => { + flags += flag2 + assert.isFalse(isFlagEnabled(flags, flag1)) + }) + }) +})