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
56 changes: 55 additions & 1 deletion tests/integration/it_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import inspect
from threading import Timer as ThreadingTimer
from time import sleep
from typing import Any, Dict, cast
from typing import Any, Dict, List, Optional, cast

import xrpl # noqa: F401 - needed for sync tests
from xrpl.asyncio.clients import AsyncJsonRpcClient, AsyncWebsocketClient
Expand All @@ -15,10 +15,13 @@
from xrpl.clients.sync_client import SyncClient
from xrpl.constants import CryptoAlgorithm
from xrpl.models import GenericRequest, Payment, Request, Response, Transaction
from xrpl.models.amounts import MPTAmount
from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount
from xrpl.models.currencies.issued_currency import IssuedCurrency
from xrpl.models.currencies.xrp import XRP
from xrpl.models.requests import Ledger
from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType
from xrpl.models.transactions import MPTokenAuthorize, MPTokenIssuanceCreate
from xrpl.models.transactions.account_set import AccountSet, AccountSetAsfFlag
from xrpl.models.transactions.amm_create import AMMCreate
from xrpl.models.transactions.oracle_set import OracleSet
Expand Down Expand Up @@ -545,3 +548,54 @@ def compare_amm_values(val, val2, round_buffer):
f"with round_buffer {round_buffer}"
)
return True


def create_mpt_token_and_authorize_source(
issuer: Wallet,
source: Wallet,
client: SyncClient = JSON_RPC_CLIENT,
flags: Optional[List[int]] = None,
) -> str:

mp_token_issuance = MPTokenIssuanceCreate(
account=issuer.classic_address,
flags=flags,
)

tx_resp = sign_and_reliable_submission(mp_token_issuance, issuer, client=client)
seq = tx_resp.result["tx_json"]["Sequence"]

response = client.request(
AccountObjects(account=issuer.address, type=AccountObjectType.MPT_ISSUANCE)
)

mpt_issuance_id = ""
for obj in response.result["account_objects"]:
if obj.get("Issuer") == issuer.classic_address and obj.get("Sequence") == seq:
mpt_issuance_id = obj["mpt_issuance_id"]
break

if not mpt_issuance_id:
raise ValueError(
f"MPT issuance ID not found for issuer "
f"{issuer.classic_address} and sequence {seq}"
)

authorize_tx = MPTokenAuthorize(
account=source.classic_address,
mptoken_issuance_id=mpt_issuance_id,
)
sign_and_reliable_submission(authorize_tx, source, client=client)

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

return mpt_issuance_id
178 changes: 177 additions & 1 deletion tests/integration/transactions/test_escrow_create.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
from tests.integration.integration_test_case import IntegrationTestCase
from tests.integration.it_utils import (
JSON_RPC_CLIENT,
LEDGER_ACCEPT_REQUEST,
create_mpt_token_and_authorize_source,
fund_wallet,
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, MPTokenIssuanceCreateFlag
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 All @@ -18,6 +25,38 @@


class TestEscrowCreate(IntegrationTestCase):

@classmethod
def setUpClass(cls):
super().setUpClass()

client = JSON_RPC_CLIENT

# Create issuer, source, and destination wallets.
cls.issuer = Wallet.create()
cls.source = Wallet.create()
cls.destination = Wallet.create()

fund_wallet(cls.issuer)
fund_wallet(cls.source)
fund_wallet(cls.destination)

cls.good_mpt_issuance_id = create_mpt_token_and_authorize_source(
cls.issuer,
cls.source,
client,
[
MPTokenIssuanceCreateFlag.TF_MPT_CAN_ESCROW,
MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER,
],
)

cls.bad_mpt_issuance_id = create_mpt_token_and_authorize_source(
cls.issuer,
cls.source,
client,
)

@test_async_and_sync(globals())
async def test_all_fields(self, client):
ledger = await client.request(Ledger(ledger_index="validated"))
Expand All @@ -36,3 +75,140 @@ 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 Escrow with MPToken.
ledger = await client.request(Ledger(ledger_index="validated"))
close_time = ledger.result["ledger"]["close_time"]

escrowed_amount = MPTAmount(
mpt_issuance_id=self.good_mpt_issuance_id,
value="1",
)

finish_after = close_time + 2

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

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

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

escrow_objects = account_objects_response.result["account_objects"]
self.assertTrue(
any(obj["Amount"] == escrowed_amount.to_dict() for obj in escrow_objects),
"No Escrow object with expected Amount found",
)

# Confirm MPToken ledger object was created.
account_objects_response = await client.request(
AccountObjects(account=self.source.address, type=AccountObjectType.MPTOKEN)
)
mptoken_objects = account_objects_response.result["account_objects"]
self.assertTrue(
any(
obj["MPTokenIssuanceID"] == escrowed_amount.mpt_issuance_id
and obj["LockedAmount"] == escrowed_amount.value
for obj in mptoken_objects
),
"No MPTOKEN object with expected MPTokenIssuanceID amd LockedAmount found",
)

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

mpt_issuance_objects = account_objects_response.result["account_objects"]

self.assertTrue(
any(
obj["mpt_issuance_id"] == escrowed_amount.mpt_issuance_id
and obj["LockedAmount"] == escrowed_amount.value
for obj in mpt_issuance_objects
),
"No MPT_ISSUANCE object with expected "
"mpt_issuance_id and LockedAmount found",
)

# 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=self.destination.classic_address,
owner=self.source.classic_address,
offer_sequence=escrow_create_seq,
)
response = await sign_and_reliable_submission_async(
escrow_finish, self.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=self.destination.address, type=AccountObjectType.MPTOKEN
)
)

dest_mptoken_objects = account_objects_response.result["account_objects"]

self.assertTrue(
any(
obj["MPTokenIssuanceID"] == escrowed_amount.mpt_issuance_id
for obj in dest_mptoken_objects
),
"No destination MPTOKEN object with expected MPTokenIssuanceID found",
)

@test_async_and_sync(globals())
async def test_mpt_based_escrow_failure(self, 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=self.bad_mpt_issuance_id,
value="1",
)

finish_after = close_time + 2

escrow_create = EscrowCreate(
account=self.source.classic_address,
amount=escrowed_amount,
destination=self.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, self.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
Loading