Skip to content

Commit 72b8422

Browse files
committed
feat: Updates to PathSet binary-codec. Also included an additional Payment transaction integ test with explicit paths
1 parent b86aefa commit 72b8422

File tree

5 files changed

+246
-2
lines changed

5 files changed

+246
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Support for XLS-82d MPT-DEX
1212
- The `ledger_entry` RPC can now accept `AMM` input along with the two asset definitions.
1313
- The `MPTCurrency` model has been updated to validate the semantic correctness of `MPTIssuanceID` values. This is performed using regular-expression matching and does not involve any read-operations on the XRPL blockchain.
14+
- The binary-codec of `PathSet` type is updated to accommodate `mpt_issuance_id`. Please refer to XLS-82d MPT-DEX amendment for context behind this change.
1415

1516

1617
## [[4.5.0]]

tests/integration/transactions/test_payment.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
from tests.integration.integration_test_case import IntegrationTestCase
22
from tests.integration.it_utils import (
3+
create_mpt_token_and_authorize_source_async,
4+
fund_wallet_async,
35
sign_and_reliable_submission_async,
46
test_async_and_sync,
57
)
68
from tests.integration.reusable_values import DESTINATION, WALLET
79
from xrpl.models.amounts.mpt_amount import MPTAmount
810
from xrpl.models.exceptions import XRPLModelException
11+
from xrpl.models.path import PathStep
912
from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType
1013
from xrpl.models.transactions import Payment
11-
from xrpl.models.transactions.mptoken_issuance_create import MPTokenIssuanceCreate
14+
from xrpl.models.transactions.amm_create import AMMCreate
15+
from xrpl.models.transactions.mptoken_authorize import MPTokenAuthorize
16+
from xrpl.models.transactions.mptoken_issuance_create import (
17+
MPTokenIssuanceCreate,
18+
MPTokenIssuanceCreateFlag,
19+
)
20+
from xrpl.wallet import Wallet
1221

1322

1423
class TestPayment(IntegrationTestCase):
@@ -179,3 +188,67 @@ async def test_mpt_payment(self, client):
179188
client,
180189
)
181190
self.assertTrue(response.is_successful())
191+
192+
@test_async_and_sync(globals())
193+
async def test_payment_with_mpt_pathset(self, client):
194+
issuer = Wallet.create()
195+
await fund_wallet_async(issuer)
196+
lp_wallet = Wallet.create()
197+
await fund_wallet_async(lp_wallet)
198+
destination = Wallet.create()
199+
await fund_wallet_async(destination)
200+
201+
mpt_flags = [
202+
MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE,
203+
MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER,
204+
]
205+
206+
# Create MPT, authorize + fund LP wallet
207+
mpt_id = await create_mpt_token_and_authorize_source_async(
208+
issuer=issuer,
209+
source=lp_wallet,
210+
client=client,
211+
flags=mpt_flags,
212+
)
213+
214+
# Authorize destination to hold MPT
215+
auth_dest = MPTokenAuthorize(
216+
account=destination.classic_address,
217+
mptoken_issuance_id=mpt_id,
218+
)
219+
response = await sign_and_reliable_submission_async(
220+
auth_dest, destination, client
221+
)
222+
self.assertEqual(response.result["engine_result"], "tesSUCCESS")
223+
224+
# Create AMM pool: XRP / MPT
225+
amm_create = AMMCreate(
226+
account=lp_wallet.classic_address,
227+
amount="1000000",
228+
amount2=MPTAmount(mpt_issuance_id=mpt_id, value="1000"),
229+
trading_fee=12,
230+
)
231+
response = await sign_and_reliable_submission_async(
232+
amm_create, lp_wallet, client
233+
)
234+
self.assertEqual(response.result["engine_result"], "tesSUCCESS")
235+
236+
# Authorize sender to hold MPT
237+
auth_sender = MPTokenAuthorize(
238+
account=WALLET.classic_address,
239+
mptoken_issuance_id=mpt_id,
240+
)
241+
response = await sign_and_reliable_submission_async(auth_sender, WALLET, client)
242+
self.assertEqual(response.result["engine_result"], "tesSUCCESS")
243+
244+
# Cross-currency payment: XRP → MPT via XRP/MPT AMM pool
245+
pay_tx = Payment(
246+
account=WALLET.address,
247+
destination=destination.address,
248+
amount=MPTAmount(mpt_issuance_id=mpt_id, value="5"),
249+
send_max="500000",
250+
# Explicitly specify a Path with mpt_issuance_id intermediate Hop
251+
paths=[[PathStep(mpt_issuance_id=mpt_id)]],
252+
)
253+
response = await sign_and_reliable_submission_async(pay_tx, WALLET, client)
254+
self.assertEqual(response.result["engine_result"], "tesSUCCESS")

tests/unit/core/binarycodec/types/test_path_set.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from xrpl.core.binarycodec import XRPLBinaryCodecException
44
from xrpl.core.binarycodec.binary_wrappers.binary_parser import BinaryParser
5+
from xrpl.core.binarycodec.types.account_id import AccountID
6+
from xrpl.core.binarycodec.types.currency import Currency
57
from xrpl.core.binarycodec.types.path_set import PathSet
68

79
buffer = (
@@ -108,3 +110,130 @@ def test_from_parser_to_json(self):
108110
def test_raises_invalid_value_type(self):
109111
invalid_value = 1
110112
self.assertRaises(XRPLBinaryCodecException, PathSet.from_value, invalid_value)
113+
114+
# ── MPT PathSet serialization tests ──
115+
#
116+
# PathSet binary format reference (from rippled STPathSet.cpp):
117+
#
118+
# Each path step starts with a 1-byte type flag bitmask:
119+
# 0x01 = account (followed by 20-byte AccountID)
120+
# 0x10 = currency (followed by 20-byte Currency)
121+
# 0x20 = issuer (followed by 20-byte AccountID)
122+
# 0x40 = MPT (followed by 24-byte MPTID)
123+
#
124+
# Special marker bytes:
125+
# 0xFF = path boundary (separates alternative paths within a PathSet)
126+
# 0x00 = end of PathSet (terminates the entire PathSet)
127+
#
128+
# Currency (0x10) and MPT (0x40) are mutually exclusive within a
129+
# single path step — a step cannot carry both flags.
130+
131+
def test_one_path_with_one_mpt_hop(self):
132+
mpt_issuance_id = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8"
133+
path = [[{"mpt_issuance_id": mpt_issuance_id}]]
134+
135+
pathset = PathSet.from_value(path)
136+
# "40" → type byte: MPT flag (0x40)
137+
# mpt_issuance_id → raw 24-byte MPTID
138+
# "00" → end of PathSet
139+
expected_hex = "40" + mpt_issuance_id + "00"
140+
self.assertEqual(str(pathset).upper(), expected_hex)
141+
142+
# round-trip JSON equivalence
143+
self.assertEqual(pathset.to_json(), path)
144+
145+
# deserialization via BinaryParser
146+
parser = BinaryParser(expected_hex)
147+
self.assertEqual(str(PathSet.from_parser(parser)), str(pathset))
148+
149+
def test_two_paths_with_mpt_hops(self):
150+
mpt_id_1 = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8"
151+
mpt_id_2 = "000004C463C52827307480341125DA0577DEFC38405B0E3E"
152+
path = [
153+
[{"mpt_issuance_id": mpt_id_1}],
154+
[{"mpt_issuance_id": mpt_id_2}],
155+
]
156+
157+
pathset = PathSet.from_value(path)
158+
# "40" + mpt_id_1 → first path: one MPT hop
159+
# "FF" → path boundary separating alternative paths
160+
# "40" + mpt_id_2 → second path: one MPT hop
161+
# "00" → end of PathSet
162+
expected_hex = "40" + mpt_id_1 + "FF" + "40" + mpt_id_2 + "00"
163+
self.assertEqual(str(pathset).upper(), expected_hex)
164+
165+
self.assertEqual(pathset.to_json(), path)
166+
167+
parser = BinaryParser(expected_hex)
168+
self.assertEqual(str(PathSet.from_parser(parser)), str(pathset))
169+
170+
def test_path_with_mpt_and_currency_path_elements(self):
171+
"""One path with two distinct steps: an MPT hop followed by a Currency hop."""
172+
mpt_issuance_id = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8"
173+
currency_code = "ABC"
174+
path = [
175+
[
176+
{"mpt_issuance_id": mpt_issuance_id},
177+
{"currency": currency_code},
178+
]
179+
]
180+
181+
pathset = PathSet.from_value(path)
182+
currency_hex = str(Currency.from_value(currency_code)).upper()
183+
# "40" + mpt_id → first step: MPT hop (0x40 type flag)
184+
# "10" + currency_hex → second step: Currency hop (0x10 type flag)
185+
# "00" → end of PathSet
186+
expected_hex = "40" + mpt_issuance_id + "10" + currency_hex + "00"
187+
self.assertEqual(str(pathset).upper(), expected_hex)
188+
189+
self.assertEqual(pathset.to_json(), path)
190+
191+
parser = BinaryParser(expected_hex)
192+
self.assertEqual(str(PathSet.from_parser(parser)), str(pathset))
193+
194+
def test_path_with_mpt_and_issuer_path_elements(self):
195+
"""One path with two distinct steps: an MPT hop followed by an Issuer hop."""
196+
mpt_issuance_id = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8"
197+
issuer_account = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"
198+
path = [
199+
[
200+
{"mpt_issuance_id": mpt_issuance_id},
201+
{"issuer": issuer_account},
202+
]
203+
]
204+
205+
pathset = PathSet.from_value(path)
206+
issuer_hex = str(AccountID.from_value(issuer_account)).upper()
207+
# "40" + mpt_id → first step: MPT hop (0x40 type flag)
208+
# "20" + issuer_hex → second step: Issuer hop (0x20 type flag)
209+
# "00" → end of PathSet
210+
expected_hex = "40" + mpt_issuance_id + "20" + issuer_hex + "00"
211+
self.assertEqual(str(pathset).upper(), expected_hex)
212+
213+
self.assertEqual(pathset.to_json(), path)
214+
215+
parser = BinaryParser(expected_hex)
216+
self.assertEqual(str(PathSet.from_parser(parser)), str(pathset))
217+
218+
def test_currency_and_mpt_mutually_exclusive_in_serialization(self):
219+
"""Providing both currency and mpt_issuance_id in a single step must raise."""
220+
path = [
221+
[
222+
{
223+
"currency": "ABC",
224+
"mpt_issuance_id": "00000001B5F762798A53"
225+
"D543A014CAF8B297CFF8F2F937E8",
226+
}
227+
]
228+
]
229+
self.assertRaises(XRPLBinaryCodecException, PathSet.from_value, path)
230+
231+
def test_currency_and_mpt_mutually_exclusive_in_deserialization(self):
232+
"""A type byte with both Currency (0x10) and MPT (0x40) flags must raise."""
233+
# "50" = 0x10 | 0x40 — an invalid combination
234+
currency_hex = str(Currency.from_value("ABC")).upper()
235+
mpt_hex = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8"
236+
invalid_hex = "50" + currency_hex + mpt_hex + "00"
237+
238+
parser = BinaryParser(invalid_hex)
239+
self.assertRaises(XRPLBinaryCodecException, PathSet.from_parser, parser)

xrpl/core/binarycodec/types/path_set.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@
1212
from xrpl.core.binarycodec.exceptions import XRPLBinaryCodecException
1313
from xrpl.core.binarycodec.types.account_id import AccountID
1414
from xrpl.core.binarycodec.types.currency import Currency
15+
from xrpl.core.binarycodec.types.hash192 import Hash192
1516
from xrpl.core.binarycodec.types.serialized_type import SerializedType
1617

1718
# Constant for masking types of a PathStep
1819
_TYPE_ACCOUNT: Final[int] = 0x01
1920
_TYPE_CURRENCY: Final[int] = 0x10
2021
_TYPE_ISSUER: Final[int] = 0x20
22+
_TYPE_MPT: Final[int] = 0x40
23+
24+
_HASH192_BYTES: Final[int] = 24
2125

2226
# Constants for separating Paths in a PathSet
2327
_PATHSET_END_BYTE: Final[int] = 0x00
@@ -26,7 +30,12 @@
2630

2731
def _is_path_step(value: Dict[str, str]) -> bool:
2832
"""Helper function to determine if a dictionary represents a valid path step."""
29-
return "issuer" in value or "account" in value or "currency" in value
33+
return (
34+
"issuer" in value
35+
or "account" in value
36+
or "currency" in value
37+
or "mpt_issuance_id" in value
38+
)
3039

3140

3241
def _is_path_set(value: List[List[Dict[str, str]]]) -> bool:
@@ -57,6 +66,11 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self:
5766
f" received {value.__class__.__name__}."
5867
)
5968

69+
if "currency" in value and "mpt_issuance_id" in value:
70+
raise XRPLBinaryCodecException(
71+
"Currency and mpt_issuance_id are mutually exclusive in a path step"
72+
)
73+
6074
data_type = 0x00
6175
buffer = b""
6276
if "account" in value:
@@ -67,6 +81,10 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self:
6781
currency = Currency.from_value(value["currency"])
6882
buffer += bytes(currency)
6983
data_type |= _TYPE_CURRENCY
84+
elif "mpt_issuance_id" in value:
85+
mpt_id = Hash192.from_value(value["mpt_issuance_id"])
86+
buffer += bytes(mpt_id)
87+
data_type |= _TYPE_MPT
7088
if "issuer" in value:
7189
issuer = AccountID.from_value(value["issuer"])
7290
buffer += bytes(issuer)
@@ -90,12 +108,21 @@ def from_parser(
90108
data_type = parser.read_uint8()
91109
buffer = b""
92110

111+
if (data_type & _TYPE_CURRENCY) and (data_type & _TYPE_MPT):
112+
raise XRPLBinaryCodecException(
113+
"Invalid binary input: Currency and mpt_issuance_id are "
114+
"mutually exclusive in a path step"
115+
)
116+
93117
if data_type & _TYPE_ACCOUNT:
94118
account_id = parser.read(AccountID.LENGTH)
95119
buffer += account_id
96120
if data_type & _TYPE_CURRENCY:
97121
currency = parser.read(Currency.LENGTH)
98122
buffer += currency
123+
elif data_type & _TYPE_MPT:
124+
mpt_id = parser.read(_HASH192_BYTES)
125+
buffer += mpt_id
99126
if data_type & _TYPE_ISSUER:
100127
issuer = parser.read(AccountID.LENGTH)
101128
buffer += issuer
@@ -119,6 +146,9 @@ def to_json(self: Self) -> Dict[str, str]:
119146
if data_type & _TYPE_CURRENCY:
120147
currency = Currency.from_parser(parser).to_json()
121148
json["currency"] = currency
149+
elif data_type & _TYPE_MPT:
150+
mpt_id = parser.read(_HASH192_BYTES).hex().upper()
151+
json["mpt_issuance_id"] = mpt_id
122152
if data_type & _TYPE_ISSUER:
123153
issuer = AccountID.from_parser(parser).to_json()
124154
json["issuer"] = issuer

xrpl/models/path.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class PathStep(BaseModel):
2222
account: Optional[str] = None
2323
currency: Optional[str] = None
2424
issuer: Optional[str] = None
25+
mpt_issuance_id: Optional[str] = None
2526
type: Optional[int] = None
2627
type_hex: Optional[str] = None
2728

@@ -33,6 +34,7 @@ def _get_errors(self: Self) -> Dict[str, str]:
3334
"account": self._get_account_error(),
3435
"currency": self._get_currency_error(),
3536
"issuer": self._get_issuer_error(),
37+
"mpt_issuance_id": self._get_mpt_issuance_id_error(),
3638
}.items()
3739
if value is not None
3840
}
@@ -49,6 +51,8 @@ def _get_currency_error(self: Self) -> Optional[str]:
4951
return None
5052
if self.account is not None:
5153
return "Cannot set currency if account is set"
54+
if self.mpt_issuance_id is not None:
55+
return "Cannot set both currency and mpt_issuance_id"
5256
if self.issuer is not None and self.currency.upper() == "XRP":
5357
return "Cannot set issuer if currency is XRP"
5458
return None
@@ -62,6 +66,13 @@ def _get_issuer_error(self: Self) -> Optional[str]:
6266
return "Cannot set issuer if currency is XRP"
6367
return None
6468

69+
def _get_mpt_issuance_id_error(self: Self) -> Optional[str]:
70+
if self.mpt_issuance_id is None:
71+
return None
72+
if self.currency is not None:
73+
return "Cannot set both mpt_issuance_id and currency"
74+
return None
75+
6576

6677
Path = List[PathStep]
6778
"""

0 commit comments

Comments
 (0)