diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 249ba16400..0687e83590 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -190,6 +190,7 @@ fixInvalidTxFlags # 2.5.0 Amendments PermissionDelegation Batch +TokenEscrow # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/packages/ripple-binary-codec/src/types/uint-64.ts b/packages/ripple-binary-codec/src/types/uint-64.ts index f6c9e523f7..f9b23739a6 100644 --- a/packages/ripple-binary-codec/src/types/uint-64.ts +++ b/packages/ripple-binary-codec/src/types/uint-64.ts @@ -12,7 +12,8 @@ function useBase10(fieldName: string): boolean { return ( fieldName === 'MaximumAmount' || fieldName === 'OutstandingAmount' || - fieldName === 'MPTAmount' + fieldName === 'MPTAmount' || + fieldName === 'LockedAmount' ) } diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 7fa719827e..8283e8b0ba 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -7,6 +7,9 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### Fixed * Fix `AccountRoot` ledger object to correctly parse `FirstNFTokenSequence` field. +### Added +* Implement support for XLS-85d Token Escrow amendment. + ## 4.3.0 (2025-6-09) ### Added diff --git a/packages/xrpl/src/models/ledger/AccountRoot.ts b/packages/xrpl/src/models/ledger/AccountRoot.ts index 82dd00f79b..4912f244cd 100644 --- a/packages/xrpl/src/models/ledger/AccountRoot.ts +++ b/packages/xrpl/src/models/ledger/AccountRoot.ts @@ -146,6 +146,12 @@ export interface AccountRootFlagsInterface { * This address can claw back issued IOUs. Once enabled, cannot be disabled. */ lsfAllowTrustLineClawback?: boolean + + /** + * If the issuer's account does not have the lsfAllowTrustLineLocking flag set, + * then Escrow ledger-objects cannot be created with such IOUs. + */ + lsfAllowTrustLineLocking?: boolean } export enum AccountRootFlags { @@ -210,4 +216,9 @@ export enum AccountRootFlags { * This address can claw back issued IOUs. Once enabled, cannot be disabled. */ lsfAllowTrustLineClawback = 0x80000000, + /** + * If the issuer's account does not have the lsfAllowTrustLineLocking flag set, + * then Escrow ledger-objects cannot be created with such IOUs. + */ + lsfAllowTrustLineLocking = 0x40000000, } diff --git a/packages/xrpl/src/models/ledger/Escrow.ts b/packages/xrpl/src/models/ledger/Escrow.ts index 42307f24de..9512c6f9e4 100644 --- a/packages/xrpl/src/models/ledger/Escrow.ts +++ b/packages/xrpl/src/models/ledger/Escrow.ts @@ -19,7 +19,8 @@ export default interface Escrow extends BaseLedgerEntry, HasPreviousTxnID { * successful. */ Destination: string - /** The amount of XRP, in drops, to be delivered by the held payment. */ + /** The amount to be delivered by the held payment. Can represent XRP, an IOU token, or an MPT. + * Must always be a positive value. */ Amount: string /** * A PREIMAGE-SHA-256 crypto-condition, as hexadecimal. If present, the @@ -61,4 +62,16 @@ export default interface Escrow extends BaseLedgerEntry, HasPreviousTxnID { * this object, in case the directory consists of multiple pages. */ DestinationNode?: string + + /** + * The transfer rate or fee at which the funds are escrowed, stored at creation + * and used during settlement. Applicable to both IOUs and MPTs. + */ + TransferRate?: number + + /** + * (Optional) The ledger index of the issuer's directory node associated with + * the Escrow. Used when the issuer is neither the source nor destination account. + */ + IssuerNode?: number } diff --git a/packages/xrpl/src/models/ledger/MPToken.ts b/packages/xrpl/src/models/ledger/MPToken.ts index 0de8d09a1f..39156aec22 100644 --- a/packages/xrpl/src/models/ledger/MPToken.ts +++ b/packages/xrpl/src/models/ledger/MPToken.ts @@ -8,4 +8,7 @@ export interface MPToken extends BaseLedgerEntry, HasPreviousTxnID { MPTAmount?: MPTAmount Flags: number OwnerNode?: string + + // (Optional) The total of all outstanding escrows for this issuance. + LockedAmount?: string } diff --git a/packages/xrpl/src/models/ledger/MPTokenIssuance.ts b/packages/xrpl/src/models/ledger/MPTokenIssuance.ts index 9bde70c7ab..1328a89574 100644 --- a/packages/xrpl/src/models/ledger/MPTokenIssuance.ts +++ b/packages/xrpl/src/models/ledger/MPTokenIssuance.ts @@ -10,4 +10,7 @@ export interface MPTokenIssuance extends BaseLedgerEntry, HasPreviousTxnID { TransferFee?: number MPTokenMetadata?: string OwnerNode?: string + + // (Optional) The total of all outstanding escrows for this issuance. + LockedAmount?: string } diff --git a/packages/xrpl/src/models/transactions/accountSet.ts b/packages/xrpl/src/models/transactions/accountSet.ts index 8a74c51e6e..33678ff1ff 100644 --- a/packages/xrpl/src/models/transactions/accountSet.ts +++ b/packages/xrpl/src/models/transactions/accountSet.ts @@ -61,6 +61,8 @@ export enum AccountSetAsfFlags { asfDisallowIncomingTrustline = 15, /** Permanently gain the ability to claw back issued IOUs */ asfAllowTrustLineClawback = 16, + /** Issuers allow their IOUs to be used as escrow amounts */ + asfAllowTrustLineLocking = 17, } /** diff --git a/packages/xrpl/src/models/transactions/escrowCreate.ts b/packages/xrpl/src/models/transactions/escrowCreate.ts index 4f15d77dd8..150d434781 100644 --- a/packages/xrpl/src/models/transactions/escrowCreate.ts +++ b/packages/xrpl/src/models/transactions/escrowCreate.ts @@ -1,4 +1,5 @@ import { ValidationError } from '../../errors' +import { Amount, MPTAmount } from '../common' import { Account, @@ -18,11 +19,12 @@ import { export interface EscrowCreate extends BaseTransaction { TransactionType: 'EscrowCreate' /** - * Amount of XRP, in drops, to deduct from the sender's balance and escrow. - * Once escrowed, the XRP can either go to the Destination address (after the. - * FinishAfter time) or returned to the sender (after the CancelAfter time). + * The amount to deduct from the sender's balance and and set aside in escrow. + * Once escrowed, this amount can either go to the Destination address (after any Finish times/conditions) + * or returned to the sender (after any cancellation times/conditions). Can represent XRP, in drops, + * an IOU token, or an MPT. Must always be a positive value. */ - Amount: string + Amount: Amount | MPTAmount /** Address to receive escrowed XRP. */ Destination: Account /** @@ -62,8 +64,10 @@ export function validateEscrowCreate(tx: Record): void { throw new ValidationError('EscrowCreate: missing field Amount') } - if (typeof tx.Amount !== 'string') { - throw new ValidationError('EscrowCreate: Amount must be a string') + if (typeof tx.Amount !== 'string' && typeof tx.Amount !== 'object') { + throw new ValidationError( + 'EscrowCreate: Amount must be a string (XRP) or object (IOU or MPT)', + ) } validateRequiredField(tx, 'Destination', isAccount) diff --git a/packages/xrpl/test/integration/transactions/escrowCreate.test.ts b/packages/xrpl/test/integration/transactions/escrowCreate.test.ts index 43a593f8f2..33fc0667da 100644 --- a/packages/xrpl/test/integration/transactions/escrowCreate.test.ts +++ b/packages/xrpl/test/integration/transactions/escrowCreate.test.ts @@ -1,13 +1,26 @@ import { assert } from 'chai' -import { EscrowCreate } from '../../../src' +import { + AccountSet, + AccountSetAsfFlags, + TrustSet, + Payment, + EscrowFinish, + EscrowCreate, + EscrowCancel, +} from '../../../src' import serverUrl from '../serverUrl' import { setupClient, teardownClient, type XrplIntegrationTestContext, } from '../setup' -import { generateFundedWallet, testTransaction } from '../utils' +import { + generateFundedWallet, + testTransaction, + sendLedgerAccept, + waitForAndForceProgressLedgerTime, +} from '../utils' // how long before each test case times out const TIMEOUT = 20000 @@ -56,4 +69,274 @@ describe('EscrowCreate', function () { }, TIMEOUT, ) + + it( + 'escrow with IOU -- validate EscrowCreate and EscrowFinish transactions', + async () => { + const wallet1 = await generateFundedWallet(testContext.client) + const wallet2 = await generateFundedWallet(testContext.client) + + // Step-1: configure Issuer (testContext.wallet.classicAddress) to allow their IOUs to be used as escrow amounts + const setupAccountSetTx: AccountSet = { + TransactionType: 'AccountSet', + Account: testContext.wallet.classicAddress, + SetFlag: AccountSetAsfFlags.asfAllowTrustLineLocking, + } + await testTransaction( + testContext.client, + setupAccountSetTx, + testContext.wallet, + ) + + // Step-2: setup appropriate trust lines to transfer the IOU. + // This is needed for both wallet1 and wallet2 to hold the USD IOU token. + const setupTrustSetTx_1: TrustSet = { + TransactionType: 'TrustSet', + Account: wallet1.classicAddress, + LimitAmount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction(testContext.client, setupTrustSetTx_1, wallet1) + + const setupTrustSetTx_2: TrustSet = { + TransactionType: 'TrustSet', + Account: wallet2.classicAddress, + LimitAmount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction(testContext.client, setupTrustSetTx_2, wallet2) + + // Step-3: transfer the USD IOU token to from Issuer to wallet1 + const setupPaymentTx: Payment = { + TransactionType: 'Payment', + Account: testContext.wallet.classicAddress, + Destination: wallet1.classicAddress, + Amount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction( + testContext.client, + setupPaymentTx, + testContext.wallet, + ) + + // Step-4: create the escrow + // get the most recent close_time from the standalone container for finish after. + const CLOSE_TIME: number = ( + await testContext.client.request({ + command: 'ledger', + ledger_index: 'validated', + }) + ).result.ledger.close_time + + const tx: EscrowCreate = { + Account: wallet1.classicAddress, + TransactionType: 'EscrowCreate', + Amount: { + currency: 'USD', + value: '100', + issuer: testContext.wallet.classicAddress, + }, + Destination: wallet2.classicAddress, + FinishAfter: CLOSE_TIME + 2, + CancelAfter: CLOSE_TIME + 4, + } + + const txn_response = await testTransaction( + testContext.client, + tx, + wallet1, + ) + + // Step-5: fetch the escrow object + const wallet1_objects = await testContext.client.request({ + command: 'account_objects', + account: wallet1.classicAddress, + type: 'escrow', + }) + assert.equal(wallet1_objects.result.account_objects.length, 1) + + // Step-6: check that the escrow object has the correct particulars + const escrowObject = wallet1_objects.result.account_objects[0] + assert.equal(escrowObject.LedgerEntryType, 'Escrow') + assert.equal(escrowObject.PreviousTxnID, txn_response.result.tx_json.hash) + + // Step-7: Execute the EscrowFinish transaction + const escrowFinishTx: EscrowFinish = { + TransactionType: 'EscrowFinish', + Account: wallet1.classicAddress, + Owner: wallet1.classicAddress, + OfferSequence: Number(txn_response.result.tx_json.Sequence), + } + + // Step 7.1: wait for the escrow to be ready to finish + await waitForAndForceProgressLedgerTime( + testContext.client, + CLOSE_TIME + 2, + ) + + // Step 7.2: rippled uses the close time of the previous ledger + await sendLedgerAccept(testContext.client) + + // Step 7.3: execute the EscrowFinish transaction + await testTransaction(testContext.client, escrowFinishTx, wallet1) + + // Step 8: check that the escrow object has been removed + const wallet1_objects_after_escrow_finish = + await testContext.client.request({ + command: 'account_objects', + account: wallet1.classicAddress, + type: 'escrow', + }) + assert.equal( + wallet1_objects_after_escrow_finish.result.account_objects.length, + 0, + ) + }, + TIMEOUT, + ) + + it( + 'escrow with IOU -- validate EscrowCancel transaction (Identical to previous test, except for Step 7-8)', + async () => { + const wallet1 = await generateFundedWallet(testContext.client) + const wallet2 = await generateFundedWallet(testContext.client) + + // Step-1: configure Issuer (testContext.wallet.classicAddress) to allow their IOUs to be used as escrow amounts + const setupAccountSetTx: AccountSet = { + TransactionType: 'AccountSet', + Account: testContext.wallet.classicAddress, + SetFlag: AccountSetAsfFlags.asfAllowTrustLineLocking, + } + await testTransaction( + testContext.client, + setupAccountSetTx, + testContext.wallet, + ) + + // Step-2: setup appropriate trust lines to transfer the IOU. + // This is needed for both wallet1 and wallet2 to hold the USD IOU token. + const setupTrustSetTx_1: TrustSet = { + TransactionType: 'TrustSet', + Account: wallet1.classicAddress, + LimitAmount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction(testContext.client, setupTrustSetTx_1, wallet1) + + const setupTrustSetTx_2: TrustSet = { + TransactionType: 'TrustSet', + Account: wallet2.classicAddress, + LimitAmount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction(testContext.client, setupTrustSetTx_2, wallet2) + + // Step-3: transfer the USD IOU token to from Issuer to wallet1 + const setupPaymentTx: Payment = { + TransactionType: 'Payment', + Account: testContext.wallet.classicAddress, + Destination: wallet1.classicAddress, + Amount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction( + testContext.client, + setupPaymentTx, + testContext.wallet, + ) + + // Step-4: create the escrow + // get the most recent close_time from the standalone container for finish after. + const CLOSE_TIME: number = ( + await testContext.client.request({ + command: 'ledger', + ledger_index: 'validated', + }) + ).result.ledger.close_time + + const tx: EscrowCreate = { + Account: wallet1.classicAddress, + TransactionType: 'EscrowCreate', + Amount: { + currency: 'USD', + value: '100', + issuer: testContext.wallet.classicAddress, + }, + Destination: wallet2.classicAddress, + FinishAfter: CLOSE_TIME + 2, + CancelAfter: CLOSE_TIME + 4, + } + + const txn_response = await testTransaction( + testContext.client, + tx, + wallet1, + ) + + // Step-5: fetch the escrow object + const wallet1_objects = await testContext.client.request({ + command: 'account_objects', + account: wallet1.classicAddress, + type: 'escrow', + }) + assert.equal(wallet1_objects.result.account_objects.length, 1) + + // Step-6: check that the escrow object has the correct particulars + const escrowObject = wallet1_objects.result.account_objects[0] + assert.equal(escrowObject.LedgerEntryType, 'Escrow') + assert.equal(escrowObject.PreviousTxnID, txn_response.result.tx_json.hash) + + // Step-7: Execute the EscrowCancel transaction + const escrowCancelTx: EscrowCancel = { + TransactionType: 'EscrowCancel', + Account: wallet1.classicAddress, + Owner: wallet1.classicAddress, + OfferSequence: Number(txn_response.result.tx_json.Sequence), + } + + // Step 7.1: wait for the escrow to be "cancellable" + await waitForAndForceProgressLedgerTime( + testContext.client, + CLOSE_TIME + 4, + ) + + // Step 7.2: rippled uses the close time of the previous ledger + await sendLedgerAccept(testContext.client) + + // Step 7.3: execute the EscrowCancel transaction + await testTransaction(testContext.client, escrowCancelTx, wallet1) + + // Step 8: check that the escrow object has been removed + const wallet1_objects_after_escrow_cancel = + await testContext.client.request({ + command: 'account_objects', + account: wallet1.classicAddress, + type: 'escrow', + }) + assert.equal( + wallet1_objects_after_escrow_cancel.result.account_objects.length, + 0, + ) + }, + TIMEOUT, + ) })