diff --git a/modules/sdk-coin-xrp/src/lib/constants.ts b/modules/sdk-coin-xrp/src/lib/constants.ts index abaf8fd77e..e8762a6544 100644 --- a/modules/sdk-coin-xrp/src/lib/constants.ts +++ b/modules/sdk-coin-xrp/src/lib/constants.ts @@ -26,3 +26,11 @@ export const VALID_ACCOUNT_SET_FLAGS = [ export const USER_KEY_SETTING_FLAG = 65536; export const MASTER_KEY_DEACTIVATION_FLAG = 1048576; export const REQUIRE_DESTINATION_TAG_FLAG = 131072; + +// https://xrpl.org/payment.html#payment-flags +// tfPartialPayment allows a Payment to deliver less than the Amount field. The actual +// delivered value is in the transaction metadata (meta.delivered_amount), NOT in the signed +// blob. NOTE: 0x00020000 is numerically identical to REQUIRE_DESTINATION_TAG_FLAG above — +// they are different flag spaces (Payment tx flag vs AccountRoot ledger flag) and must not +// be reused interchangeably. +export const TF_PARTIAL_PAYMENT = 0x00020000; diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index b071f5ae7b..324ef9b3d5 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -15,9 +15,25 @@ import { Signer, SignerEntry, SignerListSet, + TransactionMetadata, TrustSet, } from 'xrpl'; +/** + * XRP transaction types supported by this SDK. + * + * The string values mirror XRPL's `TransactionType` field names exactly and are part of the + * public SDK surface — downstream consumers (e.g. bitgo-microservices `xrpToken.ts`) compare + * transaction types against these members with `===`/`!==`. Do NOT change the string values + * (e.g. switch to numeric enum values) or rename members; that would silently break string + * comparisons across the SDK boundary with no compile error. + * + * AMM and other newer XRPL transaction types are intentionally absent — they are rejected by + * `Transaction.fromRawTransaction()` via the reverse-enum lookup. To add support for a new + * type (e.g. an AMM family member), add it here with its exact XRPL name AND extend the + * switch statements in `lib/transaction.ts` (`toJson`, `explainTransaction`, + * `fromRawTransaction`) and the coin-level `xrp.ts:explainTransaction`. + */ export enum XrpTransactionType { AccountDelete = 'AccountDelete', AccountSet = 'AccountSet', @@ -55,6 +71,13 @@ export interface ExplainTransactionOptions { halfSigned?: { txHex: string; // txHex is poorly named here; it is just a wrapped JSON object }; + /** + * Optional XRP transaction metadata. When present and the Payment `tfPartialPayment` flag + * is set, the explained `outputAmount` is taken from `meta.delivered_amount` (the actual + * delivered value) instead of the requested `Amount` field. Without metadata, a partial + * payment's delivered amount is unknown and `partialPayment: true` is surfaced instead. + */ + meta?: TransactionMetadata; } export interface VerifyAddressOptions extends BaseVerifyAddressOptions { @@ -105,7 +128,18 @@ export type TransactionExplanation = | AccountSetTransactionExplanation | TrustSetTransactionExplanation | SignerListSetTransactionExplanation - | MPTokenAuthorizeTransactionExplanation; + | MPTokenAuthorizeTransactionExplanation + | PaymentTransactionExplanation; + +export interface PaymentTransactionExplanation extends BaseTransactionExplanation { + /** + * True when the Payment `tfPartialPayment` flag is set, meaning the delivered amount may + * be less than the requested `Amount`. When `meta.delivered_amount` was provided to + * explainTransaction, `outputAmount`/`outputs[].amount` reflect the delivered value and + * this flag is still set so consumers can distinguish partial from full delivery. + */ + partialPayment?: boolean; +} export interface AccountSetTransactionExplanation extends BaseTransactionExplanation { accountSet: { diff --git a/modules/sdk-coin-xrp/src/lib/transaction.ts b/modules/sdk-coin-xrp/src/lib/transaction.ts index dc3a696ac9..fa75f90534 100644 --- a/modules/sdk-coin-xrp/src/lib/transaction.ts +++ b/modules/sdk-coin-xrp/src/lib/transaction.ts @@ -16,6 +16,7 @@ import BigNumber from 'bignumber.js'; import { MPTokenAuthorize, Signer } from 'xrpl'; import { AccountSetTransactionExplanation, + PaymentTransactionExplanation, SignerListSetTransactionExplanation, TransactionExplanation, TxData, @@ -249,7 +250,7 @@ export class Transaction extends BaseTransaction { }; } - private explainPaymentTransaction(): BaseTransactionExplanation { + private explainPaymentTransaction(): PaymentTransactionExplanation { const tx = this._xrpTransaction as xrpl.Payment; const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag }); let amount: string | number; @@ -261,8 +262,22 @@ export class Transaction extends BaseTransaction { amount = (tx.Amount as xrpl.IssuedCurrencyAmount).value; } + // The lib-level explainer only has the decoded signed blob, not transaction metadata, so + // it cannot resolve meta.delivered_amount. When tfPartialPayment is set, `amount` is the + // *requested* amount, not what was actually delivered — surface that via the flag so + // consumers don't treat outputAmount as the settled value. + const partialPayment = utils.isPartialPayment(tx.Flags as number); + return { - displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], + displayOrder: [ + 'id', + 'outputAmount', + 'changeAmount', + 'outputs', + 'changeOutputs', + 'fee', + ...(partialPayment ? ['partialPayment'] : []), + ], id: this._id as string, changeOutputs: [], outputAmount: amount, @@ -277,6 +292,7 @@ export class Transaction extends BaseTransaction { fee: tx.Fee as string, feeRate: undefined, }, + ...(partialPayment ? { partialPayment: true } : {}), }; } @@ -441,6 +457,12 @@ export class Transaction extends BaseTransaction { } else { value = (Amount as xrpl.IssuedCurrencyAmount).value; } + // NOTE: `value` reflects the requested `Amount`, not the delivered amount. If the + // tfPartialPayment flag is set the actual delivered value lives in metadata + // (meta.delivered_amount), which is not available on the decoded signed blob here. + // This path is used for build/sign flows (BitGo never builds partial payments); for + // display/verification of external partial payments use explainTransaction, which + // honors meta.delivered_amount and surfaces partialPayment: true. this.inputs.push({ address: Account, value, coin }); this.outputs.push({ address: utils.normalizeAddress({ address: Destination, destinationTag: DestinationTag }), diff --git a/modules/sdk-coin-xrp/src/lib/utils.ts b/modules/sdk-coin-xrp/src/lib/utils.ts index e9ba4302ea..87c5c65886 100644 --- a/modules/sdk-coin-xrp/src/lib/utils.ts +++ b/modules/sdk-coin-xrp/src/lib/utils.ts @@ -11,7 +11,7 @@ import * as rippleKeypairs from 'ripple-keypairs'; import * as url from 'url'; import * as xrpl from 'xrpl'; import { Amount, IssuedCurrencyAmount, isMPTAmount, MPTAmount } from 'xrpl'; -import { VALID_ACCOUNT_SET_FLAGS } from './constants'; +import { TF_PARTIAL_PAYMENT, VALID_ACCOUNT_SET_FLAGS } from './constants'; import { Address, SignerDetails } from './iface'; import { KeyPair as XrpKeyPair } from './keyPair'; import assert from 'assert'; @@ -234,6 +234,40 @@ class Utils implements BaseUtils { return isMPTAmount(amount); } + /** + * Returns true when the Payment `tfPartialPayment` flag is set on a transaction's Flags + * field. When set, the delivered amount is in `meta.delivered_amount`, not `tx.Amount`. + */ + public isPartialPayment(flags: number | undefined): boolean { + return (Number(flags) & TF_PARTIAL_PAYMENT) !== 0; + } + + /** + * Extracts the numeric/string delivered amount value from XRP transaction metadata. + * `meta.delivered_amount` is an `Amount | 'unavailable'`: + * - string → XRP drops (or the literal 'unavailable' for pre-2014 txs) + * - IssuedCurrencyAmount → `{ currency, issuer, value }` + * Returns undefined when metadata is missing, the field is 'unavailable', or the shape + * is unexpected — callers must fall back to `tx.Amount` in that case and surface the + * partial-payment flag so consumers know the value is requested, not delivered. + */ + public getDeliveredAmountValue(meta?: xrpl.TransactionMetadata): string | undefined { + const delivered = meta?.delivered_amount; + if (delivered === undefined || delivered === 'unavailable') { + return undefined; + } + if (typeof delivered === 'string') { + return delivered; + } + if (this.isIssuedCurrencyAmount(delivered)) { + return delivered.value; + } + if (this.isMPTAmount(delivered)) { + return delivered.value; + } + return undefined; + } + /** * Get the associated XRP Currency details from token name. Throws an error if token is unsupported * @param {string} tokenName - The token name diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index 90da2a4d53..eb8c5e697e 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -297,28 +297,94 @@ export class Xrp extends BaseCoin { mptIssuanceId: transaction.MPTokenIssuanceID, ...(transaction.MPTHolder !== undefined && { mptHolder: transaction.MPTHolder }), }; + } else if (transaction.TransactionType === 'AccountDelete') { + // AccountDelete sweeps the full account balance (minus fee) to Destination; the exact + // amount is unknown at build time, so we record '0' as a placeholder (matches the + // Transaction-class explainer in lib/transaction.ts). Without this branch the method + // previously fell through to the Payment shape and returned undefined outputAmount/amount, + // since AccountDelete carries no Amount field. + const address = + transaction.Destination + (transaction.DestinationTag >= 0 ? '?dt=' + transaction.DestinationTag : ''); + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], + id: id, + changeOutputs: [], + outputAmount: '0', + changeAmount: 0, + outputs: [ + { + address, + amount: '0', + }, + ], + fee: { + fee: transaction.Fee, + feeRate: undefined, + size: txHex.length / 2, + }, + }; + } else if (transaction.TransactionType === 'SignerListSet') { + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'signerListSet'], + id: id, + changeOutputs: [], + outputAmount: 0, + changeAmount: 0, + outputs: [], + fee: { + fee: transaction.Fee, + feeRate: undefined, + size: txHex.length / 2, + }, + signerListSet: { + signerQuorum: transaction.SignerQuorum, + signerEntries: transaction.SignerEntries, + }, + }; + } else if (transaction.TransactionType === 'Payment') { + const address = + transaction.Destination + (transaction.DestinationTag >= 0 ? '?dt=' + transaction.DestinationTag : ''); + // When tfPartialPayment is set, tx.Amount is the *requested* amount, not the delivered + // one. Prefer meta.delivered_amount when metadata was supplied; otherwise keep Amount + // and surface partialPayment: true so consumers know the value is not the settlement. + const partialPayment = utils.isPartialPayment(transaction.Flags as number); + const deliveredValue = partialPayment ? utils.getDeliveredAmountValue(params.meta) : undefined; + const outputAmount = deliveredValue !== undefined ? deliveredValue : transaction.Amount; + return { + displayOrder: [ + 'id', + 'outputAmount', + 'changeAmount', + 'outputs', + 'changeOutputs', + 'fee', + ...(partialPayment ? ['partialPayment'] : []), + ], + id: id, + changeOutputs: [], + outputAmount: outputAmount, + changeAmount: 0, + outputs: [ + { + address, + amount: outputAmount, + }, + ], + fee: { + fee: transaction.Fee, + feeRate: undefined, + size: txHex.length / 2, + }, + ...(partialPayment ? { partialPayment: true } : {}), + }; } - const address = - transaction.Destination + (transaction.DestinationTag >= 0 ? '?dt=' + transaction.DestinationTag : ''); - return { - displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], - id: id, - changeOutputs: [], - outputAmount: transaction.Amount, - changeAmount: 0, - outputs: [ - { - address, - amount: transaction.Amount, - }, - ], - fee: { - fee: transaction.Fee, - feeRate: undefined, - size: txHex.length / 2, - }, - }; + // No silent fallthrough: every other TransactionType (AMM*, Offer*, Escrow*, NFToken*, + // Check*, pseudo-tx, etc.) is unsupported by this explainer. Throwing here mirrors the + // safe switch in lib/transaction.ts:196-211 and prevents callers (verifyTransaction, + // recover) from receiving a Payment-shaped object with undefined fields for types that + // have no Amount/Destination. + throw new Error(`Unsupported XRP transaction type: ${transaction.TransactionType}`); } getTransactionTypeRawTxHex(txHex: string): XrpTransactionType | undefined { @@ -474,20 +540,41 @@ export class Xrp extends BaseCoin { const output = [...explanation.outputs, ...explanation.changeOutputs][0]; const expectedOutput = txParams.recipients && txParams.recipients[0]; + // A Payment carrying the tfPartialPayment flag may deliver less than its Amount field. + // BitGo never builds partial payments, so such a prebuild cannot match a send intent — + // reject it rather than risk verifying a transaction that under-delivers. + if ('partialPayment' in explanation && explanation.partialPayment === true) { + throw new Error('Partial payment (tfPartialPayment) is not permitted for verified send transactions'); + } + + // XRP Payment amounts arrive in two shapes: + // - string (XRP drops, base units) — recipient amount is also base units, compare directly. + // - object (IssuedCurrencyAmount / MPTAmount) — `value` is in display units, while the + // recipient amount from txParams is in base units. Convert the display value via the + // coin's base factor (getBaseFactor() returns 10^decimalPlaces and is overridden by + // XrpToken to use the *token's* decimals, not the base coin's). Previously the object + // case skipped the comparison entirely, leaving every cross-currency / token transfer + // unverified. + const toBaseUnits = (amount: any): string => { + if (amount === undefined || amount === null) { + return ''; + } + if (typeof amount === 'object' && 'value' in amount) { + return new BigNumber(amount.value).times(this.getBaseFactor()).toFixed(); + } + return new BigNumber(amount).toFixed(); + }; + const comparator = (recipient1, recipient2) => { if (utils.getAddressDetails(recipient1.address).address !== utils.getAddressDetails(recipient2.address).address) { return false; } - const amount1 = new BigNumber(recipient1.amount); - const amount2 = new BigNumber(recipient2.amount); + const amount1 = new BigNumber(toBaseUnits(recipient1.amount)); + const amount2 = new BigNumber(toBaseUnits(recipient2.amount)); return amount1.toFixed() === amount2.toFixed(); }; - if ( - (txParams.type === undefined || txParams.type === 'payment') && - typeof output.amount !== 'object' && - !comparator(output, expectedOutput) - ) { + if ((txParams.type === undefined || txParams.type === 'payment') && !comparator(output, expectedOutput)) { throw new Error('transaction prebuild does not match expected output'); } diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index d3cf64bf2c..f6cdf11b4b 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -352,6 +352,138 @@ describe('XRP:', function () { .should.be.rejectedWith('txHex needs to be either hex or JSON string for XRP'); }); + it('Should explain an AccountDelete transaction (no fallthrough to Payment shape)', async function () { + // Previously AccountDelete had no branch in the coin-level explainTransaction and fell + // through to the Payment shape, producing undefined outputAmount/outputs. It must now + // return a 0-value placeholder (full sweep, exact amount unknown at build time). + const txHex = JSON.stringify({ + TransactionType: 'AccountDelete', + Account: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', + Destination: 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP', + DestinationTag: 12345, + Flags: 2147483648, + Fee: '45', + Sequence: 7, + LastLedgerSequence: 1000007, + }); + const explanation = await basecoin.explainTransaction({ txHex }); + explanation.outputAmount.should.equal('0'); + explanation.outputs.should.have.length(1); + explanation.outputs[0].amount.should.equal('0'); + explanation.outputs[0].address.should.startWith('raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP'); + explanation.outputs[0].address.should.containEql('dt=12345'); + explanation.fee.fee.should.equal('45'); + }); + + it('Should explain a SignerListSet transaction (no fallthrough to Payment shape)', async function () { + const txHex = JSON.stringify({ + TransactionType: 'SignerListSet', + Account: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', + SignerQuorum: 2, + SignerEntries: [ + { SignerEntry: { Account: 'rwFcXstMseu91iejAdoYWCPaVR4GgdiV5i', SignerWeight: 1 } }, + { SignerEntry: { Account: 'r45kBeT5cmtaW6DHGAXzfjYHQzsVFhPX3M', SignerWeight: 1 } }, + { SignerEntry: { Account: 'r3mykfPQZt4eJZKLUGMNVB49eDSJiE9zh3', SignerWeight: 1 } }, + ], + Flags: 2147483648, + Fee: '45', + Sequence: 7, + }); + const explanation = await basecoin.explainTransaction({ txHex }); + explanation.outputAmount.should.equal(0); + explanation.outputs.should.have.length(0); + explanation.signerListSet.signerQuorum.should.equal(2); + explanation.signerListSet.signerEntries.should.have.length(3); + }); + + it('Should reject an unsupported transaction type instead of falling through to Payment', async function () { + // SetRegularKey is a valid XRPL type that the explainer does not handle. Before the fix + // it fell through to a Payment-shaped return with undefined Destination/Amount. Pre-encode + // to valid hex so the test exercises the explain path (not the internal JSON-encode path). + const txHex = xrpl.encode({ + TransactionType: 'SetRegularKey', + Account: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', + RegularKey: 'rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG', + Flags: 2147483648, + Fee: '45', + Sequence: 7, + SigningPubKey: '', + } as any); + await basecoin + .explainTransaction({ txHex }) + .should.be.rejectedWith(/Unsupported XRP transaction type: SetRegularKey/); + }); + + it('Should flag a partial payment (tfPartialPayment) without metadata', async function () { + // tfPartialPayment = 0x00020000 (131072); combined with tfCanonical (0x80000000) = 2147614720. + // Without metadata the delivered amount is unknown, so outputAmount stays as the requested + // Amount and partialPayment is surfaced. + const txHex = JSON.stringify({ + TransactionType: 'Payment', + Account: 'rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG', + Destination: 'rfjub8A4dpSD5nnszUFTsLprxu1W398jwc', + DestinationTag: 0, + Amount: '253481', + Flags: 2147614720, + LastLedgerSequence: 1626225, + Fee: '45', + Sequence: 7, + }); + const explanation = await basecoin.explainTransaction({ txHex }); + explanation.partialPayment.should.equal(true); + explanation.outputAmount.should.equal('253481'); + }); + + it('Should use delivered_amount from metadata for a partial payment', async function () { + const txHex = JSON.stringify({ + TransactionType: 'Payment', + Account: 'rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG', + Destination: 'rfjub8A4dpSD5nnszUFTsLprxu1W398jwc', + DestinationTag: 0, + Amount: '253481', + Flags: 2147614720, + LastLedgerSequence: 1626225, + Fee: '45', + Sequence: 7, + }); + const explanation = await basecoin.explainTransaction({ + txHex, + meta: { delivered_amount: '100000' } as any, + }); + explanation.partialPayment.should.equal(true); + explanation.outputAmount.should.equal('100000'); + explanation.outputs[0].amount.should.equal('100000'); + }); + + it('Should flag a partial payment with issued-currency delivered_amount from metadata', async function () { + const txHex = JSON.stringify({ + TransactionType: 'Payment', + Account: 'rsgg4mwHTGPRP7A4eGUmrpTxTeDZHQrHkQ', + Destination: 'raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a', + DestinationTag: 1, + Amount: { + value: '0.01', + currency: '524C555344000000000000000000000000000000', + issuer: 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV', + }, + Flags: 2147614720, + Fee: '45', + Sequence: 1760661, + }); + const explanation = await basecoin.explainTransaction({ + txHex, + meta: { + delivered_amount: { + value: '0.005', + currency: '524C555344000000000000000000000000000000', + issuer: 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV', + }, + } as any, + }); + explanation.partialPayment.should.equal(true); + explanation.outputAmount.should.equal('0.005'); + }); + describe('Fee Management', () => { const nockBasecoin: any = bitgo.coin('txrp'); @@ -763,7 +895,7 @@ describe('XRP:', function () { recipients: [ { address: 'raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a?dt=1', - amount: '10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + amount: '10000000000000', }, ], }; @@ -832,7 +964,7 @@ describe('XRP:', function () { recipients: [ { address: 'raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a?dt=1', - amount: '10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + amount: '10000000000000', }, ], }; @@ -843,6 +975,55 @@ describe('XRP:', function () { }); validTransaction.should.equal(true); }); + + it('should fail to verify a token transfer when the amount does not match (object Amount)', async function () { + // F2: previously the object-Amount case skipped the comparison entirely, so a mismatched + // cross-currency transfer passed verification. Now Amount.value is compared (in base + // units) against the recipient amount. + const txPrebuild = { + txHex: + '{"Account":"rsgg4mwHTGPRP7A4eGUmrpTxTeDZHQrHkQ","Fee":"45","Sequence":1760661,"Flags":2147483648,"TransactionType":"Payment","Destination":"raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a","Amount":{"value":"0.01","currency":"524C555344000000000000000000000000000000","issuer":"rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV"},"DestinationTag":1}', + }; + const txParams = { + coin: 'txrp:rlusd', + recipients: [ + { + // 0.01 RLUSD at 15 decimal places = 1e13 base units; use a wrong value (2e13). + address: 'raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a?dt=1', + amount: '20000000000000', + }, + ], + }; + + await token + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('transaction prebuild does not match expected output'); + }); + + it('should reject a partial payment (tfPartialPayment) prebuild', async function () { + // F1: BitGo never builds partial payments; a tfPartialPayment prebuild cannot match a + // send intent and must be rejected by verifyTransaction. + const txPrebuild = { + txHex: JSON.stringify({ + TransactionType: 'Payment', + Account: 'rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG', + Destination: 'rfjub8A4dpSD5nnszUFTsLprxu1W398jwc', + DestinationTag: 0, + Amount: '253481', + Flags: 2147614720, // tfCanonical | tfPartialPayment + LastLedgerSequence: 1626225, + Fee: '45', + Sequence: 7, + }), + }; + const txParams = { + recipients: [{ address: 'rfjub8A4dpSD5nnszUFTsLprxu1W398jwc', amount: '253481' }], + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('Partial payment (tfPartialPayment) is not permitted for verified send transactions'); + }); }); describe('Unit Tests for isWalletAddress function', function () {