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 @@ -202,6 +202,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")
62 changes: 58 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,53 @@ 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_postive(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_cancel_after_missing_on_mpt(self):
with self.assertRaises(XRPLModelException) as error:
EscrowCreate(
account=_SOURCE,
destination=_DESTINATION,
amount=MPTAmount(
mpt_issuance_id="rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy",
value="10.20",
),
)
self.assertEqual(
error.exception.args[0],
"{'cancel_after': "
"'cancel_after is required when creating an Escrow with IOU or MPT.'}",
)

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())
3 changes: 3 additions & 0 deletions xrpl/models/transactions/account_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ class AccountSetAsfFlag(int, Enum):
ASF_ALLOW_TRUSTLINE_CLAWBACK = 16
"""Allow trustline clawback feature"""

ASF_ALLOW_TRUSTLINE_LOCKING = 17
"""Allows Issuers's issued IOU tokens to be used in Escrow."""


class AccountSetFlag(int, Enum):
"""
Expand Down
Loading