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
This commit is contained in:
Omar Khan
2021-08-18 17:24:34 -04:00
committed by Mayukha Vadari
parent c1edab547a
commit bec487cf71
8 changed files with 296 additions and 31 deletions

View File

@@ -110,9 +110,6 @@ export function verifyBaseTransaction(common: BaseTransaction): void {
if (common.Sequence !== undefined && typeof common.Sequence !== 'number') if (common.Sequence !== undefined && typeof common.Sequence !== 'number')
throw new ValidationError("BaseTransaction: invalid Sequence") throw new ValidationError("BaseTransaction: invalid Sequence")
if (common.Flags !== undefined && typeof common.Flags !== 'number')
throw new ValidationError("BaseTransaction: invalid Flags")
if (common.AccountTxnID !== undefined if (common.AccountTxnID !== undefined
&& typeof common.AccountTxnID !== 'string') && typeof common.AccountTxnID !== 'string')
throw new ValidationError("BaseTransaction: invalid AccountTxnID") throw new ValidationError("BaseTransaction: invalid AccountTxnID")

View File

@@ -7,3 +7,4 @@ export * from './checkCash'
export * from './checkCancel' export * from './checkCancel'
export * from './accountDelete' export * from './accountDelete'
export * from './signerListSet' export * from './signerListSet'
export * from './paymentTransaction'

View File

@@ -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;
}

View File

@@ -1,16 +1,17 @@
import Metadata from "../common/metadata"; import Metadata from "../common/metadata"
import { AccountDelete } from "./accountDelete"; import { AccountDelete } from "./accountDelete"
import { AccountSet } from "./accountSet"; import { AccountSet } from "./accountSet"
import { CheckCancel } from "./checkCancel"; import { CheckCancel } from "./checkCancel"
import { CheckCash } from "./checkCash"; import { CheckCash } from "./checkCash"
import { CheckCreate } from "./checkCreate"; import { CheckCreate } from "./checkCreate"
import { OfferCancel } from "./offerCancel" import { OfferCancel } from "./offerCancel"
import { OfferCreate } from "./offerCreate"; import { OfferCreate } from "./offerCreate"
import { SignerListSet } from "./signerListSet"; import { PaymentTransaction } from "./paymentTransaction"
import { SignerListSet } from "./signerListSet"
export type Transaction = export type Transaction =
AccountSet AccountDelete
| AccountDelete | AccountSet
| CheckCancel | CheckCancel
| CheckCash | CheckCash
| CheckCreate | CheckCreate
@@ -21,7 +22,7 @@ export type Transaction =
| OfferCancel | OfferCancel
// | OfferCancel // | OfferCancel
| OfferCreate | OfferCreate
// | PaymentTransaction | PaymentTransaction
// | PaymentChannelClaim // | PaymentChannelClaim
// | PaymentChannelCreate // | PaymentChannelCreate
// | PaymentChannelFund // | PaymentChannelFund
@@ -31,6 +32,6 @@ export type Transaction =
// | TrustSet // | TrustSet
export interface TransactionAndMetadata { export interface TransactionAndMetadata {
transaction: Transaction; transaction: Transaction
metadata: Metadata metadata: Metadata
} }

View File

@@ -7,4 +7,15 @@
*/ */
export function onlyHasFields(obj: object, fields: Array<string>): boolean { export function onlyHasFields(obj: object, fields: Array<string>): boolean {
return Object.keys(obj).every((key:string) => fields.includes(key)) return Object.keys(obj).every((key:string) => fields.includes(key))
} }
/**
* 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
}

View File

@@ -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`, () => { it (`Handles invalid LastLedgerSequence`, () => {
const invalidLastLedgerSequence = { const invalidLastLedgerSequence = {
Account: "r97KeayHuEsDwyU1yPBVtMLLoQr79QcRFe", Account: "r97KeayHuEsDwyU1yPBVtMLLoQr79QcRFe",

View File

@@ -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'
)
})
})

29
test/models/utils.ts Normal file
View File

@@ -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))
})
})
})