Skip to content
1 change: 1 addition & 0 deletions .ci-config/rippled.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,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]
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [[Unreleased]]

### Added
- Support for `Token Escrow` (XLS-85d)

## [4.2.0] - 2025-6-09

### Added
Expand Down
241 changes: 240 additions & 1 deletion tests/integration/transactions/test_escrow_create.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
from tests.integration.integration_test_case import IntegrationTestCase
from tests.integration.it_utils import (
LEDGER_ACCEPT_REQUEST,
fund_wallet_async,
sign_and_reliable_submission_async,
test_async_and_sync,
)
from tests.integration.reusable_values import DESTINATION, WALLET
from xrpl.models import EscrowCreate, Ledger
from xrpl.models import (
EscrowCreate,
EscrowFinish,
Ledger,
MPTokenAuthorize,
MPTokenIssuanceCreate,
MPTokenIssuanceCreateFlag,
Payment,
)
from xrpl.models.amounts import MPTAmount
from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType
from xrpl.models.response import ResponseStatus
from xrpl.wallet.main import Wallet

ACCOUNT = WALLET.address

Expand Down Expand Up @@ -36,3 +49,229 @@ async def test_all_fields(self, client):
)
self.assertEqual(response.status, ResponseStatus.SUCCESS)
self.assertEqual(response.result["engine_result"], "tesSUCCESS")

@test_async_and_sync(globals())
async def test_mpt_based_escrow(self, client):
# Create issuer, source, and destination wallets.
issuer = Wallet.create()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can all this setup be done independently in setupTests or something (I might be misremembering the function name)? That way it doesn't have to execute 4 times

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mvadari I want to issue two different types of MPT's (with difference flags) for test_mpt_based_escrow and test_mpt_based_escrow_failure. Using setUpClass that executes only once will not help here. Do you see any other benefit of moving it?

Copy link
Collaborator

@mvadari mvadari Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way the tests run, it runs each test once per client (Sync/Async, Json/Websocket) so each test actually runs 4 times under the hood. So you could still set up 2 MPTs in setupClass and it would still save.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have moved account and MPT setup to setUpClass in af8a450.

Haven't reused WALLET AND DESTINATION since, MPT ledger objects were being tested in other MPT specific IT's and they were being interfered by MPT ledger objects created through EscrowFinish transaction.

await fund_wallet_async(issuer)

source = Wallet.create()
await fund_wallet_async(source)

destination = Wallet.create()
await fund_wallet_async(destination)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use DESTINATION here too


# Create MPToken that can be used in Escrow.
mp_token_issuance_tx = MPTokenIssuanceCreate(
account=issuer.classic_address,
flags=[
MPTokenIssuanceCreateFlag.TF_MPT_CAN_ESCROW,
MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER,
],
)

await sign_and_reliable_submission_async(mp_token_issuance_tx, issuer, client)

# confirm MPTokenIssuance ledger object was created
account_objects_response = await client.request(
AccountObjects(account=issuer.address, type=AccountObjectType.MPT_ISSUANCE)
)

self.assertEqual(len(account_objects_response.result["account_objects"]), 1)
mpt_issuance_id = account_objects_response.result["account_objects"][0][
"mpt_issuance_id"
]

# Source account authorizes itself to hold MPToken.
mp_token_authorize_tx = MPTokenAuthorize(
account=source.classic_address,
mptoken_issuance_id=mpt_issuance_id,
)

await sign_and_reliable_submission_async(mp_token_authorize_tx, source, client)

# Send some MPToken to the source wallet that can be further used in Escrow.
payment_tx = Payment(
account=issuer.address,
destination=source.address,
amount=MPTAmount(
mpt_issuance_id=mpt_issuance_id,
value="1000",
),
)

await sign_and_reliable_submission_async(payment_tx, issuer, client)

# Create Escrow with MPToken.
ledger = await client.request(Ledger(ledger_index="validated"))
close_time = ledger.result["ledger"]["close_time"]

escrowed_amount = MPTAmount(
mpt_issuance_id=mpt_issuance_id,
value="10",
)

finish_after = close_time + 2

escrow_create = EscrowCreate(
account=source.classic_address,
amount=escrowed_amount,
destination=destination.classic_address,
finish_after=finish_after,
cancel_after=close_time + 1000,
)
response = await sign_and_reliable_submission_async(
escrow_create, source, client
)
escrow_create_seq = response.result["tx_json"]["Sequence"]

self.assertEqual(response.status, ResponseStatus.SUCCESS)
self.assertEqual(response.result["engine_result"], "tesSUCCESS")

# Confirm EscrowCreate ledger object was created.
account_objects_response = await client.request(
AccountObjects(account=source.address, type=AccountObjectType.ESCROW)
)

self.assertEqual(len(account_objects_response.result["account_objects"]), 1)
self.assertEqual(
account_objects_response.result["account_objects"][0]["Amount"],
escrowed_amount.to_dict(),
)

# Confirm MPToken ledger object was created.
account_objects_response = await client.request(
AccountObjects(account=source.address, type=AccountObjectType.MPTOKEN)
)

self.assertEqual(len(account_objects_response.result["account_objects"]), 1)
self.assertEqual(
account_objects_response.result["account_objects"][0]["LockedAmount"],
escrowed_amount.value,
)
self.assertEqual(
account_objects_response.result["account_objects"][0]["MPTokenIssuanceID"],
escrowed_amount.mpt_issuance_id,
)

# Confirm MPTokenIssuance ledger object was created.
account_objects_response = await client.request(
AccountObjects(account=issuer.address, type=AccountObjectType.MPT_ISSUANCE)
)

self.assertEqual(len(account_objects_response.result["account_objects"]), 1)
self.assertEqual(
account_objects_response.result["account_objects"][0]["LockedAmount"],
escrowed_amount.value,
)

# Wait for the finish_after time to pass before finishing the escrow.
close_time = 0
while close_time <= finish_after:
await client.request(LEDGER_ACCEPT_REQUEST)
ledger = await client.request(Ledger(ledger_index="validated"))
close_time = ledger.result["ledger"]["close_time"]

# Finish the escrow.
escrow_finish = EscrowFinish(
account=destination.classic_address,
owner=source.classic_address,
offer_sequence=escrow_create_seq,
)
response = await sign_and_reliable_submission_async(
escrow_finish, destination, client
)
self.assertEqual(response.status, ResponseStatus.SUCCESS)
self.assertEqual(response.result["engine_result"], "tesSUCCESS")

# Confirm MPToken ledger object was created for destination.
account_objects_response = await client.request(
AccountObjects(account=destination.address, type=AccountObjectType.MPTOKEN)
)

self.assertEqual(len(account_objects_response.result["account_objects"]), 1)
self.assertEqual(
account_objects_response.result["account_objects"][0]["MPTAmount"],
escrowed_amount.value,
)
self.assertEqual(
account_objects_response.result["account_objects"][0]["MPTokenIssuanceID"],
escrowed_amount.mpt_issuance_id,
)

@test_async_and_sync(globals())
async def test_mpt_based_escrow_failure(self, client):
# Create issuer, source, and destination wallets.
issuer = Wallet.create()
await fund_wallet_async(issuer)

source = Wallet.create()
await fund_wallet_async(source)

destination = Wallet.create()
await fund_wallet_async(destination)

# Create MPToken that can be used in Escrow.
mp_token_issuance_tx = MPTokenIssuanceCreate(
account=issuer.classic_address,
)

await sign_and_reliable_submission_async(mp_token_issuance_tx, issuer, client)

# confirm MPTokenIssuance ledger object was created
account_objects_response = await client.request(
AccountObjects(account=issuer.address, type=AccountObjectType.MPT_ISSUANCE)
)

self.assertEqual(len(account_objects_response.result["account_objects"]), 1)
mpt_issuance_id = account_objects_response.result["account_objects"][0][
"mpt_issuance_id"
]

# Source account authorizes itself to hold MPToken.
mp_token_authorize_tx = MPTokenAuthorize(
account=source.classic_address,
mptoken_issuance_id=mpt_issuance_id,
)

await sign_and_reliable_submission_async(mp_token_authorize_tx, source, client)

# Send some MPToken to the source wallet that can be further used in Escrow.
payment_tx = Payment(
account=issuer.address,
destination=source.address,
amount=MPTAmount(
mpt_issuance_id=mpt_issuance_id,
value="1000",
),
)

await sign_and_reliable_submission_async(payment_tx, issuer, client)

# Create Escrow with MPToken.
ledger = await client.request(Ledger(ledger_index="validated"))
close_time = ledger.result["ledger"]["close_time"]

escrowed_amount = MPTAmount(
mpt_issuance_id=mpt_issuance_id,
value="10",
)

finish_after = close_time + 2

escrow_create = EscrowCreate(
account=source.classic_address,
amount=escrowed_amount,
destination=destination.classic_address,
finish_after=finish_after,
cancel_after=close_time + 1000,
)

# Transaction fails with tecNO_PERMISSION because lsfMPTCanEscrow is not set.
response = await sign_and_reliable_submission_async(
escrow_create, source, client
)

self.assertEqual(response.status, ResponseStatus.SUCCESS)
self.assertEqual(response.result["engine_result"], "tecNO_PERMISSION")
46 changes: 42 additions & 4 deletions tests/unit/models/transactions/test_escrow_create.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
from unittest import TestCase

from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount
from xrpl.models.exceptions import XRPLModelException
from xrpl.models.transactions import EscrowCreate

_SOURCE = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ"
_DESTINATION = "rJXXwHs6YYZmomBnJoYQdxwXSwJq56tJBn"


class TestEscrowCreate(TestCase):
def test_final_after_less_than_cancel_after(self):
account = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ"
amount = "amount"
account = _SOURCE
amount = "10.890"
cancel_after = 1
finish_after = 2
destination = "destination"
destination = _DESTINATION
fee = "0.00001"
sequence = 19048

with self.assertRaises(XRPLModelException):
with self.assertRaises(XRPLModelException) as error:
EscrowCreate(
account=account,
amount=amount,
Expand All @@ -24,3 +28,37 @@ def test_final_after_less_than_cancel_after(self):
finish_after=finish_after,
sequence=sequence,
)
self.assertEqual(
error.exception.args[0],
"{'EscrowCreate': "
"'The finish_after time must be before the cancel_after time.'}",
)

def test_amount_not_positive(self):
with self.assertRaises(XRPLModelException) as error:
EscrowCreate(
account=_SOURCE,
destination=_DESTINATION,
amount=IssuedCurrencyAmount(
issuer="rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy",
currency="USD",
value="0.00",
),
cancel_after=10,
)
self.assertEqual(
error.exception.args[0],
"{'amount': 'amount must be positive.'}",
)

def test_valid_escrow_create(self):
tx = EscrowCreate(
account=_SOURCE,
destination=_DESTINATION,
amount=MPTAmount(
mpt_issuance_id="rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy",
value="10.20",
),
cancel_after=10,
)
self.assertTrue(tx.is_valid())
6 changes: 6 additions & 0 deletions xrpl/models/transactions/account_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ class AccountSetAsfFlag(int, Enum):
ASF_ALLOW_TRUSTLINE_CLAWBACK = 16
"""Allow trustline clawback feature"""

ASF_ALLOW_TRUSTLINE_LOCKING = 17
"""
If this account is an Issuer of IOU tokens, this flag allows such tokens to be
used in Escrow.
"""


class AccountSetFlag(int, Enum):
"""
Expand Down
12 changes: 8 additions & 4 deletions xrpl/models/transactions/escrow_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from typing_extensions import Self

from xrpl.models.amounts import Amount
from xrpl.models.amounts import Amount, get_amount_value, is_issued_currency, is_mpt
from xrpl.models.required import REQUIRED
from xrpl.models.transactions.transaction import Transaction
from xrpl.models.transactions.types import TransactionType
Expand All @@ -24,8 +24,9 @@ class EscrowCreate(Transaction):

amount: Amount = REQUIRED # type: ignore
"""
Amount of XRP, in drops, to deduct from the sender's balance and set
aside in escrow. This field is required.
The amount to deduct from the sender's balance and set aside in escrow.
Can represent XRP in drops, an IOU token, or MPT.
This field is required and must be positive.

:meta hide-value:
"""
Expand All @@ -48,7 +49,7 @@ class EscrowCreate(Transaction):
cancel_after: Optional[int] = None
"""
The time, in seconds since the Ripple Epoch, when this escrow expires.
This value is immutable; the funds can only be returned the sender after
This value is immutable; the funds can only be returned to the sender after
this time.
"""

Expand Down Expand Up @@ -83,4 +84,7 @@ def _get_errors(self: Self) -> Dict[str, str]:
"The finish_after time must be before the cancel_after time."
)

if get_amount_value(self.amount) <= 0:
errors["amount"] = "amount must be positive."

return errors
Loading