Skip to content

Commit 91c613d

Browse files
authored
fix(sdk-coin-xrp): partial-payment verify and explain fallthrough (CSHLD-1452)
2 parents f7cfb3f + d886bbd commit 91c613d

6 files changed

Lines changed: 399 additions & 33 deletions

File tree

modules/sdk-coin-xrp/src/lib/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@ export const VALID_ACCOUNT_SET_FLAGS = [
2626
export const USER_KEY_SETTING_FLAG = 65536;
2727
export const MASTER_KEY_DEACTIVATION_FLAG = 1048576;
2828
export const REQUIRE_DESTINATION_TAG_FLAG = 131072;
29+
30+
// https://xrpl.org/payment.html#payment-flags
31+
// tfPartialPayment allows a Payment to deliver less than the Amount field. The actual
32+
// delivered value is in the transaction metadata (meta.delivered_amount), NOT in the signed
33+
// blob. NOTE: 0x00020000 is numerically identical to REQUIRE_DESTINATION_TAG_FLAG above —
34+
// they are different flag spaces (Payment tx flag vs AccountRoot ledger flag) and must not
35+
// be reused interchangeably.
36+
export const TF_PARTIAL_PAYMENT = 0x00020000;

modules/sdk-coin-xrp/src/lib/iface.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,25 @@ import {
1515
Signer,
1616
SignerEntry,
1717
SignerListSet,
18+
TransactionMetadata,
1819
TrustSet,
1920
} from 'xrpl';
2021

22+
/**
23+
* XRP transaction types supported by this SDK.
24+
*
25+
* The string values mirror XRPL's `TransactionType` field names exactly and are part of the
26+
* public SDK surface — downstream consumers (e.g. bitgo-microservices `xrpToken.ts`) compare
27+
* transaction types against these members with `===`/`!==`. Do NOT change the string values
28+
* (e.g. switch to numeric enum values) or rename members; that would silently break string
29+
* comparisons across the SDK boundary with no compile error.
30+
*
31+
* AMM and other newer XRPL transaction types are intentionally absent — they are rejected by
32+
* `Transaction.fromRawTransaction()` via the reverse-enum lookup. To add support for a new
33+
* type (e.g. an AMM family member), add it here with its exact XRPL name AND extend the
34+
* switch statements in `lib/transaction.ts` (`toJson`, `explainTransaction`,
35+
* `fromRawTransaction`) and the coin-level `xrp.ts:explainTransaction`.
36+
*/
2137
export enum XrpTransactionType {
2238
AccountDelete = 'AccountDelete',
2339
AccountSet = 'AccountSet',
@@ -55,6 +71,13 @@ export interface ExplainTransactionOptions {
5571
halfSigned?: {
5672
txHex: string; // txHex is poorly named here; it is just a wrapped JSON object
5773
};
74+
/**
75+
* Optional XRP transaction metadata. When present and the Payment `tfPartialPayment` flag
76+
* is set, the explained `outputAmount` is taken from `meta.delivered_amount` (the actual
77+
* delivered value) instead of the requested `Amount` field. Without metadata, a partial
78+
* payment's delivered amount is unknown and `partialPayment: true` is surfaced instead.
79+
*/
80+
meta?: TransactionMetadata;
5881
}
5982

6083
export interface VerifyAddressOptions extends BaseVerifyAddressOptions {
@@ -105,7 +128,18 @@ export type TransactionExplanation =
105128
| AccountSetTransactionExplanation
106129
| TrustSetTransactionExplanation
107130
| SignerListSetTransactionExplanation
108-
| MPTokenAuthorizeTransactionExplanation;
131+
| MPTokenAuthorizeTransactionExplanation
132+
| PaymentTransactionExplanation;
133+
134+
export interface PaymentTransactionExplanation extends BaseTransactionExplanation {
135+
/**
136+
* True when the Payment `tfPartialPayment` flag is set, meaning the delivered amount may
137+
* be less than the requested `Amount`. When `meta.delivered_amount` was provided to
138+
* explainTransaction, `outputAmount`/`outputs[].amount` reflect the delivered value and
139+
* this flag is still set so consumers can distinguish partial from full delivery.
140+
*/
141+
partialPayment?: boolean;
142+
}
109143

110144
export interface AccountSetTransactionExplanation extends BaseTransactionExplanation {
111145
accountSet: {

modules/sdk-coin-xrp/src/lib/transaction.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import BigNumber from 'bignumber.js';
1616
import { MPTokenAuthorize, Signer } from 'xrpl';
1717
import {
1818
AccountSetTransactionExplanation,
19+
PaymentTransactionExplanation,
1920
SignerListSetTransactionExplanation,
2021
TransactionExplanation,
2122
TxData,
@@ -249,7 +250,7 @@ export class Transaction extends BaseTransaction {
249250
};
250251
}
251252

252-
private explainPaymentTransaction(): BaseTransactionExplanation {
253+
private explainPaymentTransaction(): PaymentTransactionExplanation {
253254
const tx = this._xrpTransaction as xrpl.Payment;
254255
const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag });
255256
let amount: string | number;
@@ -261,8 +262,22 @@ export class Transaction extends BaseTransaction {
261262
amount = (tx.Amount as xrpl.IssuedCurrencyAmount).value;
262263
}
263264

265+
// The lib-level explainer only has the decoded signed blob, not transaction metadata, so
266+
// it cannot resolve meta.delivered_amount. When tfPartialPayment is set, `amount` is the
267+
// *requested* amount, not what was actually delivered — surface that via the flag so
268+
// consumers don't treat outputAmount as the settled value.
269+
const partialPayment = utils.isPartialPayment(tx.Flags as number);
270+
264271
return {
265-
displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'],
272+
displayOrder: [
273+
'id',
274+
'outputAmount',
275+
'changeAmount',
276+
'outputs',
277+
'changeOutputs',
278+
'fee',
279+
...(partialPayment ? ['partialPayment'] : []),
280+
],
266281
id: this._id as string,
267282
changeOutputs: [],
268283
outputAmount: amount,
@@ -277,6 +292,7 @@ export class Transaction extends BaseTransaction {
277292
fee: tx.Fee as string,
278293
feeRate: undefined,
279294
},
295+
...(partialPayment ? { partialPayment: true } : {}),
280296
};
281297
}
282298

@@ -441,6 +457,12 @@ export class Transaction extends BaseTransaction {
441457
} else {
442458
value = (Amount as xrpl.IssuedCurrencyAmount).value;
443459
}
460+
// NOTE: `value` reflects the requested `Amount`, not the delivered amount. If the
461+
// tfPartialPayment flag is set the actual delivered value lives in metadata
462+
// (meta.delivered_amount), which is not available on the decoded signed blob here.
463+
// This path is used for build/sign flows (BitGo never builds partial payments); for
464+
// display/verification of external partial payments use explainTransaction, which
465+
// honors meta.delivered_amount and surfaces partialPayment: true.
444466
this.inputs.push({ address: Account, value, coin });
445467
this.outputs.push({
446468
address: utils.normalizeAddress({ address: Destination, destinationTag: DestinationTag }),

modules/sdk-coin-xrp/src/lib/utils.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as rippleKeypairs from 'ripple-keypairs';
1111
import * as url from 'url';
1212
import * as xrpl from 'xrpl';
1313
import { Amount, IssuedCurrencyAmount, isMPTAmount, MPTAmount } from 'xrpl';
14-
import { VALID_ACCOUNT_SET_FLAGS } from './constants';
14+
import { TF_PARTIAL_PAYMENT, VALID_ACCOUNT_SET_FLAGS } from './constants';
1515
import { Address, SignerDetails } from './iface';
1616
import { KeyPair as XrpKeyPair } from './keyPair';
1717
import assert from 'assert';
@@ -234,6 +234,40 @@ class Utils implements BaseUtils {
234234
return isMPTAmount(amount);
235235
}
236236

237+
/**
238+
* Returns true when the Payment `tfPartialPayment` flag is set on a transaction's Flags
239+
* field. When set, the delivered amount is in `meta.delivered_amount`, not `tx.Amount`.
240+
*/
241+
public isPartialPayment(flags: number | undefined): boolean {
242+
return (Number(flags) & TF_PARTIAL_PAYMENT) !== 0;
243+
}
244+
245+
/**
246+
* Extracts the numeric/string delivered amount value from XRP transaction metadata.
247+
* `meta.delivered_amount` is an `Amount | 'unavailable'`:
248+
* - string → XRP drops (or the literal 'unavailable' for pre-2014 txs)
249+
* - IssuedCurrencyAmount → `{ currency, issuer, value }`
250+
* Returns undefined when metadata is missing, the field is 'unavailable', or the shape
251+
* is unexpected — callers must fall back to `tx.Amount` in that case and surface the
252+
* partial-payment flag so consumers know the value is requested, not delivered.
253+
*/
254+
public getDeliveredAmountValue(meta?: xrpl.TransactionMetadata): string | undefined {
255+
const delivered = meta?.delivered_amount;
256+
if (delivered === undefined || delivered === 'unavailable') {
257+
return undefined;
258+
}
259+
if (typeof delivered === 'string') {
260+
return delivered;
261+
}
262+
if (this.isIssuedCurrencyAmount(delivered)) {
263+
return delivered.value;
264+
}
265+
if (this.isMPTAmount(delivered)) {
266+
return delivered.value;
267+
}
268+
return undefined;
269+
}
270+
237271
/**
238272
* Get the associated XRP Currency details from token name. Throws an error if token is unsupported
239273
* @param {string} tokenName - The token name

modules/sdk-coin-xrp/src/xrp.ts

Lines changed: 114 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -297,28 +297,94 @@ export class Xrp extends BaseCoin {
297297
mptIssuanceId: transaction.MPTokenIssuanceID,
298298
...(transaction.MPTHolder !== undefined && { mptHolder: transaction.MPTHolder }),
299299
};
300+
} else if (transaction.TransactionType === 'AccountDelete') {
301+
// AccountDelete sweeps the full account balance (minus fee) to Destination; the exact
302+
// amount is unknown at build time, so we record '0' as a placeholder (matches the
303+
// Transaction-class explainer in lib/transaction.ts). Without this branch the method
304+
// previously fell through to the Payment shape and returned undefined outputAmount/amount,
305+
// since AccountDelete carries no Amount field.
306+
const address =
307+
transaction.Destination + (transaction.DestinationTag >= 0 ? '?dt=' + transaction.DestinationTag : '');
308+
return {
309+
displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'],
310+
id: id,
311+
changeOutputs: [],
312+
outputAmount: '0',
313+
changeAmount: 0,
314+
outputs: [
315+
{
316+
address,
317+
amount: '0',
318+
},
319+
],
320+
fee: {
321+
fee: transaction.Fee,
322+
feeRate: undefined,
323+
size: txHex.length / 2,
324+
},
325+
};
326+
} else if (transaction.TransactionType === 'SignerListSet') {
327+
return {
328+
displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'signerListSet'],
329+
id: id,
330+
changeOutputs: [],
331+
outputAmount: 0,
332+
changeAmount: 0,
333+
outputs: [],
334+
fee: {
335+
fee: transaction.Fee,
336+
feeRate: undefined,
337+
size: txHex.length / 2,
338+
},
339+
signerListSet: {
340+
signerQuorum: transaction.SignerQuorum,
341+
signerEntries: transaction.SignerEntries,
342+
},
343+
};
344+
} else if (transaction.TransactionType === 'Payment') {
345+
const address =
346+
transaction.Destination + (transaction.DestinationTag >= 0 ? '?dt=' + transaction.DestinationTag : '');
347+
// When tfPartialPayment is set, tx.Amount is the *requested* amount, not the delivered
348+
// one. Prefer meta.delivered_amount when metadata was supplied; otherwise keep Amount
349+
// and surface partialPayment: true so consumers know the value is not the settlement.
350+
const partialPayment = utils.isPartialPayment(transaction.Flags as number);
351+
const deliveredValue = partialPayment ? utils.getDeliveredAmountValue(params.meta) : undefined;
352+
const outputAmount = deliveredValue !== undefined ? deliveredValue : transaction.Amount;
353+
return {
354+
displayOrder: [
355+
'id',
356+
'outputAmount',
357+
'changeAmount',
358+
'outputs',
359+
'changeOutputs',
360+
'fee',
361+
...(partialPayment ? ['partialPayment'] : []),
362+
],
363+
id: id,
364+
changeOutputs: [],
365+
outputAmount: outputAmount,
366+
changeAmount: 0,
367+
outputs: [
368+
{
369+
address,
370+
amount: outputAmount,
371+
},
372+
],
373+
fee: {
374+
fee: transaction.Fee,
375+
feeRate: undefined,
376+
size: txHex.length / 2,
377+
},
378+
...(partialPayment ? { partialPayment: true } : {}),
379+
};
300380
}
301381

302-
const address =
303-
transaction.Destination + (transaction.DestinationTag >= 0 ? '?dt=' + transaction.DestinationTag : '');
304-
return {
305-
displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'],
306-
id: id,
307-
changeOutputs: [],
308-
outputAmount: transaction.Amount,
309-
changeAmount: 0,
310-
outputs: [
311-
{
312-
address,
313-
amount: transaction.Amount,
314-
},
315-
],
316-
fee: {
317-
fee: transaction.Fee,
318-
feeRate: undefined,
319-
size: txHex.length / 2,
320-
},
321-
};
382+
// No silent fallthrough: every other TransactionType (AMM*, Offer*, Escrow*, NFToken*,
383+
// Check*, pseudo-tx, etc.) is unsupported by this explainer. Throwing here mirrors the
384+
// safe switch in lib/transaction.ts:196-211 and prevents callers (verifyTransaction,
385+
// recover) from receiving a Payment-shaped object with undefined fields for types that
386+
// have no Amount/Destination.
387+
throw new Error(`Unsupported XRP transaction type: ${transaction.TransactionType}`);
322388
}
323389

324390
getTransactionTypeRawTxHex(txHex: string): XrpTransactionType | undefined {
@@ -474,20 +540,41 @@ export class Xrp extends BaseCoin {
474540
const output = [...explanation.outputs, ...explanation.changeOutputs][0];
475541
const expectedOutput = txParams.recipients && txParams.recipients[0];
476542

543+
// A Payment carrying the tfPartialPayment flag may deliver less than its Amount field.
544+
// BitGo never builds partial payments, so such a prebuild cannot match a send intent —
545+
// reject it rather than risk verifying a transaction that under-delivers.
546+
if ('partialPayment' in explanation && explanation.partialPayment === true) {
547+
throw new Error('Partial payment (tfPartialPayment) is not permitted for verified send transactions');
548+
}
549+
550+
// XRP Payment amounts arrive in two shapes:
551+
// - string (XRP drops, base units) — recipient amount is also base units, compare directly.
552+
// - object (IssuedCurrencyAmount / MPTAmount) — `value` is in display units, while the
553+
// recipient amount from txParams is in base units. Convert the display value via the
554+
// coin's base factor (getBaseFactor() returns 10^decimalPlaces and is overridden by
555+
// XrpToken to use the *token's* decimals, not the base coin's). Previously the object
556+
// case skipped the comparison entirely, leaving every cross-currency / token transfer
557+
// unverified.
558+
const toBaseUnits = (amount: any): string => {
559+
if (amount === undefined || amount === null) {
560+
return '';
561+
}
562+
if (typeof amount === 'object' && 'value' in amount) {
563+
return new BigNumber(amount.value).times(this.getBaseFactor()).toFixed();
564+
}
565+
return new BigNumber(amount).toFixed();
566+
};
567+
477568
const comparator = (recipient1, recipient2) => {
478569
if (utils.getAddressDetails(recipient1.address).address !== utils.getAddressDetails(recipient2.address).address) {
479570
return false;
480571
}
481-
const amount1 = new BigNumber(recipient1.amount);
482-
const amount2 = new BigNumber(recipient2.amount);
572+
const amount1 = new BigNumber(toBaseUnits(recipient1.amount));
573+
const amount2 = new BigNumber(toBaseUnits(recipient2.amount));
483574
return amount1.toFixed() === amount2.toFixed();
484575
};
485576

486-
if (
487-
(txParams.type === undefined || txParams.type === 'payment') &&
488-
typeof output.amount !== 'object' &&
489-
!comparator(output, expectedOutput)
490-
) {
577+
if ((txParams.type === undefined || txParams.type === 'payment') && !comparator(output, expectedOutput)) {
491578
throw new Error('transaction prebuild does not match expected output');
492579
}
493580

0 commit comments

Comments
 (0)