diff --git a/scripts/check-endpoints b/scripts/check-endpoints index 263ee46..a8edccc 100755 --- a/scripts/check-endpoints +++ b/scripts/check-endpoints @@ -55,6 +55,7 @@ try: '/mpt/create/random', '/mpt/authorize/random', '/mpt/set/random', + '/mpt/set/dynamic/random', '/mpt/destroy/random', '/loan/broker/set/random', '/loan/broker/delete/random', diff --git a/scripts/check-imports b/scripts/check-imports index 699d18e..442bf4a 100755 --- a/scripts/check-imports +++ b/scripts/check-imports @@ -22,6 +22,7 @@ from workload.transactions.deposit_preauth import deposit_preauth from workload.transactions.signer_list_set import signer_list_set from workload.transactions.did import did_set, did_delete from workload.transactions.mpt import mpt_create, mpt_authorize, mpt_issuance_set, mpt_destroy +from workload.transactions.mpt_dynamic import mpt_issuance_set_dynamic from workload.transactions.nft import nftoken_mint, nftoken_modify from workload.transactions.offers import offer_create, offer_cancel from workload.transactions.permissioned_dex import offer_create_domain, offer_create_hybrid, payment_domain, payment_domain_xc diff --git a/test_composer/all_transactions/parallel_driver_mpt_set_dynamic_random.sh b/test_composer/all_transactions/parallel_driver_mpt_set_dynamic_random.sh new file mode 100644 index 0000000..3298334 --- /dev/null +++ b/test_composer/all_transactions/parallel_driver_mpt_set_dynamic_random.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +curl --silent http://workload:8000/mpt/set/dynamic/random diff --git a/workload/pyproject.toml b/workload/pyproject.toml index 713f52e..d2b54bb 100644 --- a/workload/pyproject.toml +++ b/workload/pyproject.toml @@ -34,9 +34,15 @@ warn_unused_ignores = true # complementary flow/annotation checker, so third-party types resolve as Any here. ignore_missing_imports = true -# Confidential MPT (XLS-0096), sponsor (XLS-68), and the rest of the models come from -# xrpl-py's pre-3.3-release-group branch (converges the pre-release WIP branches; not yet -# released). Proof generation (xrpl/ext/confidential) is EXCLUDED from the core wheel this -# installs and is built separately in the Antithesis image; see scripts/setup-confidential-crypto.sh. +# Confidential MPT (XLS-0096), Dynamic MPT (XLS-0094), sponsor (XLS-68), and the rest of +# the models come from xrpl-py's pre-3.3-release-group branch (converges the pre-release WIP +# branches; not yet released). Proof generation (xrpl/ext/confidential) is EXCLUDED from the +# core wheel this installs and is built separately in the Antithesis image; see +# scripts/setup-confidential-crypto.sh. [tool.uv.sources] xrpl-py = { git = "https://github.com/XRPLF/xrpl-py.git", branch = "pre-3.3-release-group" } + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] diff --git a/workload/src/workload/models.py b/workload/src/workload/models.py index 53317f9..5c3847e 100644 --- a/workload/src/workload/models.py +++ b/workload/src/workload/models.py @@ -124,6 +124,10 @@ class MPTokenIssuance: can_transfer: bool = False require_auth: bool = False locked: bool = False + # XLS-0094: create-time MutableFlags bitmask (tmfMPT*, opt-in model); 0 = fully + # immutable issuance. A set bit declares that a flag/field may later be + # enabled/mutated via MPTokenIssuanceSet. + mutable_flags: int = 0 holders: set[str] = field(default_factory=set) diff --git a/workload/src/workload/params.py b/workload/src/workload/params.py index 9995f08..f360d19 100644 --- a/workload/src/workload/params.py +++ b/workload/src/workload/params.py @@ -175,6 +175,11 @@ def mpt_metadata() -> str: return bytes(randint(0, 255) for _ in range(length)).hex() +def mpt_transfer_fee() -> int: + """1/10th basis points (1-50000 = 0.001-50%); kMaxTransferFee per spec.""" + return randint(1, 50000) + + # ── AMM ───────────────────────────────────────────────────────────── def amm_trading_fee() -> int: """1/100,000th (0-1000 = 0-1%).""" diff --git a/workload/src/workload/setup.py b/workload/src/workload/setup.py index 6ff86f0..21d9c76 100644 --- a/workload/src/workload/setup.py +++ b/workload/src/workload/setup.py @@ -37,6 +37,7 @@ MPTokenAuthorize, MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag, + MPTokenIssuanceCreateMutableFlag, MPTokenIssuanceSet, MPTokenIssuanceSetFlag, NFTokenCreateOffer, @@ -99,6 +100,26 @@ _HOLDER_RANGE = range(62, 72) # accounts[62..71] _VAULT_RANGE = range(10, 17) # accounts[10..16] +# Dynamic MPT (XLS-0094): opt-in mutable cohort created with create-time +# MutableFlags declaring every capability as enable-able + metadata/fee mutable, +# on indices unused elsewhere. +_DYNAMIC_MPT_RANGE = range(53, 56) # accounts[53..55] +# Immutable cohort: created with NO MutableFlags, so every later flag-enable / +# metadata / transfer-fee mutation must fail with tecNO_PERMISSION. +_IMMUTABLE_MPT_RANGE = range(56, 59) # accounts[56..58] +# Opt-in create-time MutableFlags for the mutable cohort: declare every +# capability enable-able + metadata/transfer-fee mutable. +_DYNAMIC_MUTABLE_FLAGS = ( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_ENABLE_CAN_LOCK + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_ENABLE_REQUIRE_AUTH + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_ENABLE_CAN_ESCROW + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_ENABLE_CAN_TRADE + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_ENABLE_CAN_TRANSFER + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_ENABLE_CAN_CLAWBACK + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE +) + # Confidential MPT (XLS-0096): issuers + holders on indices unused elsewhere. _CONF_ISSUER_RANGE = range(7, 9) # accounts[7..8] _CONF_HOLDER_RANGE = range(72, 77) # accounts[72..76] @@ -967,6 +988,8 @@ async def run_setup(workload: Workload) -> dict[str, int]: | set(range(30, 36)) | set(range(40, 43)) | set(range(50, 53)) + | set(_DYNAMIC_MPT_RANGE) + | set(_IMMUTABLE_MPT_RANGE) | set(range(60, 72)) | set(range(72, 77)) # Cross-resource pool (Phase 4): rich accts + their delegates/sponsors must @@ -1093,6 +1116,52 @@ async def run_setup(workload: Workload) -> dict[str, int]: _mpt_issuance_exists, ) + # ── 4b. Dynamic MPT (XLS-0094) cohorts ────────────────────────── + # XLS-0094 is opt-in: an issuance is immutable unless a create-time + # MutableFlags (tmfMPT*) bit declares a capability/field as mutable. The + # mutable cohort (53-55) declares every CAN_ENABLE_* bit plus + # CAN_MUTATE_METADATA/CAN_MUTATE_TRANSFER_FEE, so every later flag-enable / + # metadata / TransferFee mutation passes preclaim; base CanTransfer lets + # fee>0 mutations stick and CanLock keeps the regular lock/unlock Set path + # reachable on this cohort. The immutable cohort (56-58) sends NO MutableFlags + # so every mutation reliably draws tecNO_PERMISSION. + summary["dynamic_mpt_issuances"] = await _run_phase( + workload, + "dynamic_mpt", + [ + ( + "MPTokenIssuanceCreate", + MPTokenIssuanceCreate( + account=accs[i].address, + flags=_LOCK | _XFER, + mutable_flags=_DYNAMIC_MUTABLE_FLAGS, + ), + accs[i].wallet, + ) + for i in _DYNAMIC_MPT_RANGE + if i < len(accs) + ], + _mpt_issuance_exists, + ) + + summary["immutable_mpt_issuances"] = await _run_phase( + workload, + "immutable_mpt", + [ + ( + "MPTokenIssuanceCreate", + MPTokenIssuanceCreate( + account=accs[i].address, + flags=_LOCK | _XFER, + ), + accs[i].wallet, + ) + for i in _IMMUTABLE_MPT_RANGE + if i < len(accs) + ], + _mpt_issuance_exists, + ) + # ── 5. MPT authorization: holders authorize for each issuance ──── mpt_auth_txns = [] for mpt in workload.mpt_issuances: diff --git a/workload/src/workload/transactions/__init__.py b/workload/src/workload/transactions/__init__.py index 23d09ab..9d04c36 100644 --- a/workload/src/workload/transactions/__init__.py +++ b/workload/src/workload/transactions/__init__.py @@ -77,8 +77,14 @@ loan_pay, loan_set, ) -from workload.transactions.mpt import mpt_authorize, mpt_create, mpt_destroy, mpt_issuance_set +from workload.transactions.mpt import ( + mpt_authorize, + mpt_create, + mpt_destroy, + mpt_issuance_set, +) from workload.transactions.mpt_dex import offer_create_mpt, payment_mpt +from workload.transactions.mpt_dynamic import mpt_issuance_set_dynamic from workload.transactions.nft import ( nftoken_accept_offer, nftoken_burn, @@ -287,6 +293,9 @@ def _on_mpt_create(w: Workload, tx: dict, meta: dict) -> None: require_auth=bool(flags & int(MPTokenIssuanceCreateFlag.TF_MPT_REQUIRE_AUTH)), # lock state set later by setup, not at create locked=False, + # XLS-0094: create-time MutableFlags (tmfMPT*, opt-in). 0 ⇒ fully + # immutable; a set bit declares a flag/field may later be enabled/mutated. + mutable_flags=int(tx.get("MutableFlags", 0) or 0), ) w.mpt_issuances.append(issuance) @@ -1098,6 +1107,17 @@ def _on_payment_maybe_sponsored_account(w: Workload, tx: dict, meta: dict) -> No lambda w: (w.accounts, w.mpt_issuances, w.client), None, ), + # DynamicMPTSet (XLS-0094): synthetic name; on-ledger type stays + # MPTokenIssuanceSet (mutation carries MutableFlags set-enable bits, + # MPTokenMetadata, or TransferFee). ws_listener fires tx_result for this + # bucket; no separate updater. + ( + "DynamicMPTSet", + "/mpt/set/dynamic/random", + mpt_issuance_set_dynamic, + lambda w: (w.accounts, w.mpt_issuances, w.client), + None, + ), ( "MPTokenIssuanceDestroy", "/mpt/destroy/random", diff --git a/workload/src/workload/transactions/mpt_dynamic.py b/workload/src/workload/transactions/mpt_dynamic.py new file mode 100644 index 0000000..393bb30 --- /dev/null +++ b/workload/src/workload/transactions/mpt_dynamic.py @@ -0,0 +1,204 @@ +"""Dynamic MPT (XLS-0094): DynamicMPTSet submitted as MPTokenIssuanceSet. + +The model is opt-in: an issuance is immutable unless a create-time MutableFlags +(tmfMPT*) bit declares a capability/field as mutable. A mutating +MPTokenIssuanceSet then either enables a capability via a MutableFlags set-enable +bit (each requires the matching CAN_ENABLE_* declared at create), rewrites +MPTokenMetadata (requires CAN_MUTATE_METADATA), or sets TransferFee (requires +CAN_MUTATE_TRANSFER_FEE and an already-enabled CanTransfer). All three ride the +typed xrpl-py fields; only the oversize-metadata malformation needs submit_raw. +""" + +from __future__ import annotations + +from xrpl.asyncio.clients import AsyncJsonRpcClient +from xrpl.models.transactions import ( + MPTokenIssuanceCreateMutableFlag, + MPTokenIssuanceSet, + MPTokenIssuanceSetMutableFlag, +) +from xrpl.wallet import Wallet + +from workload import params +from workload.fuzz import submit_fuzzed +from workload.models import MPTokenIssuance, UserAccount +from workload.randoms import choice +from workload.submit import submit_raw, submit_tx + +# tmfMPTSet* set-enable bits: which capability a mutation turns on. Each requires +# the matching TMF_MPT_CAN_ENABLE_* declared in the create-time MutableFlags. +_SET_ENABLE_FLAGS = [ + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK, + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_REQUIRE_AUTH, + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_ESCROW, + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_TRADE, + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_TRANSFER, + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_CLAWBACK, +] + +# create-time MutableFlags bits that must be present to mutate metadata / fee. +_TMF_CAN_MUTATE_METADATA = int(MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA) +_TMF_CAN_MUTATE_TRANSFER_FEE = int(MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE) + + +def _mutable_issuances( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], +) -> list[MPTokenIssuance]: + """Issuances we issue that opted into mutation (non-zero create-time MutableFlags).""" + return [m for m in mpt_issuances if m.mutable_flags and m.issuer in accounts] + + +def _immutable_issuances( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], +) -> list[MPTokenIssuance]: + """Issuances we issue that opted out (zero create-time MutableFlags): every mutation fails.""" + return [m for m in mpt_issuances if not m.mutable_flags and m.issuer in accounts] + + +async def mpt_issuance_set_dynamic( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], + client: AsyncJsonRpcClient, +) -> None: + if params.should_send_faulty(): + return await _mpt_issuance_set_dynamic_faulty(accounts, mpt_issuances, client) + return await _mpt_issuance_set_dynamic_valid(accounts, mpt_issuances, client) + + +def _mpt_issuance_set_dynamic_base( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], +) -> tuple[MPTokenIssuanceSet, Wallet] | None: + """Valid set-enable dynamic mutation + wallet; shared by valid and fuzz.""" + dyn = _mutable_issuances(accounts, mpt_issuances) + if not dyn: + return None + mpt = choice(dyn) + issuer = accounts[mpt.issuer] + txn = MPTokenIssuanceSet( + account=issuer.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + mutable_flags=choice(_SET_ENABLE_FLAGS), + ) + return txn, issuer.wallet + + +async def _mpt_issuance_set_dynamic_valid( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], + client: AsyncJsonRpcClient, +) -> None: + dyn = _mutable_issuances(accounts, mpt_issuances) + if not dyn: + return + mpt = choice(dyn) + issuer = accounts[mpt.issuer] + + mutation = choice(["flag_enable", "metadata", "transfer_fee"]) + if mutation == "metadata" and (mpt.mutable_flags & _TMF_CAN_MUTATE_METADATA): + txn = MPTokenIssuanceSet( + account=issuer.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + mptoken_metadata=params.mpt_metadata(), + ) + elif ( + mutation == "transfer_fee" + and (mpt.mutable_flags & _TMF_CAN_MUTATE_TRANSFER_FEE) + and mpt.can_transfer + ): + # fee>0 needs CanTransfer already enabled at issuance (preclaim rule). + txn = MPTokenIssuanceSet( + account=issuer.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + transfer_fee=params.mpt_transfer_fee(), + ) + else: + # set-enable latch; re-enabling an already-set capability is a no-op tesSUCCESS. + txn = MPTokenIssuanceSet( + account=issuer.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + mutable_flags=choice(_SET_ENABLE_FLAGS), + ) + await submit_tx("DynamicMPTSet", txn, client, issuer.wallet) + + +async def _mpt_issuance_set_dynamic_faulty( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], + client: AsyncJsonRpcClient, +) -> None: + built = _mpt_issuance_set_dynamic_base(accounts, mpt_issuances) + if built is None: + return + base, wallet = built + + mutation = choice( + ["fuzz", "fake_issuance", "non_issuer", "immutable_mutation", "oversize_metadata"] + ) + if mutation == "fuzz": + await submit_fuzzed("DynamicMPTSet", base, client, wallet) + return + + if mutation == "fake_issuance": + # Mutate a non-existent issuance -> tecOBJECT_NOT_FOUND. + if not accounts: + return + src = choice(list(accounts.values())) + txn = MPTokenIssuanceSet( + account=src.address, + mptoken_issuance_id=params.fake_mpt_id(), + mutable_flags=choice(_SET_ENABLE_FLAGS), + ) + await submit_tx("DynamicMPTSet", txn, client, src.wallet) + return + + if mutation == "non_issuer": + # Non-issuer submits a dynamic mutation -> tecNO_PERMISSION. + dyn = _mutable_issuances(accounts, mpt_issuances) + if not dyn: + return + mpt = choice(dyn) + others = [a for a in accounts if a != mpt.issuer] + if not others: + return + src = accounts[choice(others)] + txn = MPTokenIssuanceSet( + account=src.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + mutable_flags=choice(_SET_ENABLE_FLAGS), + ) + await submit_tx("DynamicMPTSet", txn, client, src.wallet) + return + + if mutation == "immutable_mutation": + # Mutate an issuance that opted out at create -> tecNO_PERMISSION. + immutable = _immutable_issuances(accounts, mpt_issuances) + if not immutable: + return + mpt = choice(immutable) + issuer = accounts[mpt.issuer] + txn = ( + MPTokenIssuanceSet( + account=issuer.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + mptoken_metadata=params.mpt_metadata(), + ) + if choice([True, False]) + else MPTokenIssuanceSet( + account=issuer.address, + mptoken_issuance_id=mpt.mpt_issuance_id, + mutable_flags=choice(_SET_ENABLE_FLAGS), + ) + ) + await submit_tx("DynamicMPTSet", txn, client, issuer.wallet) + return + + # oversize_metadata: MPTokenMetadata > 1024 bytes -> temMALFORMED (xrpl-py + # rejects the length at construction, so inject it raw). + def _mutate_oversize(d: dict) -> None: + d.pop("MutableFlags", None) + d["MPTokenMetadata"] = "AB" * 1025 + + await submit_raw("DynamicMPTSet", base, client, wallet, _mutate_oversize) diff --git a/workload/src/workload/ws_listener.py b/workload/src/workload/ws_listener.py index 0ce8d15..4dfc561 100644 --- a/workload/src/workload/ws_listener.py +++ b/workload/src/workload/ws_listener.py @@ -136,6 +136,17 @@ def _handle_validated_tx(workload: Workload, msg: dict) -> None: elif tx_type == "SponsorshipTransfer" and not tx.get("ObjectID"): tx_result("SponsorshipTransferAccount", result) + # Dynamic MPT (XLS-0094): a mutating MPTokenIssuanceSet carries MutableFlags + # set-enable bits, MPTokenMetadata, or TransferFee (rippled's `isMutate`), + # distinct from the lock/unlock Flags path and confidential key registration — + # route it to DynamicMPTSet. + if tx_type == "MPTokenIssuanceSet" and ( + tx.get("MutableFlags") is not None + or tx.get("MPTokenMetadata") is not None + or tx.get("TransferFee") is not None + ): + tx_result("DynamicMPTSet", result) + if engine_result == "tesSUCCESS": if tx_type in _RESERVE_SPONSOR_TX_TYPES: _on_reserve_sponsored_create(workload, tx, meta) diff --git a/workload/tests/test_mpt_dynamic.py b/workload/tests/test_mpt_dynamic.py new file mode 100644 index 0000000..1995cba --- /dev/null +++ b/workload/tests/test_mpt_dynamic.py @@ -0,0 +1,226 @@ +"""Unit tests for the Dynamic MPT (XLS-0094) issuance-set handler. + +Fully offline: the submit layer (submit_tx / submit_raw / submit_fuzzed) and +randoms.choice are monkeypatched, so no rippled node is required. Randomness is +made deterministic via a Chooser that returns configured mutations/flags/indices. +""" + +import asyncio + +from xrpl.models.transactions import MPTokenIssuanceCreateMutableFlag as CM +from xrpl.wallet import Wallet + +from workload import params +from workload.models import MPTokenIssuance, UserAccount +from workload.transactions import mpt_dynamic as mpt + +# Mutable cohort: opted into every capability enable + metadata/transfer-fee mutation. +_MUTABLE_FLAGS = int( + CM.TMF_MPT_CAN_ENABLE_CAN_LOCK + | CM.TMF_MPT_CAN_ENABLE_REQUIRE_AUTH + | CM.TMF_MPT_CAN_ENABLE_CAN_ESCROW + | CM.TMF_MPT_CAN_ENABLE_CAN_TRADE + | CM.TMF_MPT_CAN_ENABLE_CAN_TRANSFER + | CM.TMF_MPT_CAN_ENABLE_CAN_CLAWBACK + | CM.TMF_MPT_CAN_MUTATE_METADATA + | CM.TMF_MPT_CAN_MUTATE_TRANSFER_FEE +) +MUT_ID = "A0" * 24 # opted-in mutable issuance (mutable_flags != 0) +IMM_ID = "B1" * 24 # opted-out immutable issuance (mutable_flags == 0) + +_MUTATIONS = { + "flag_enable", "metadata", "transfer_fee", + "fuzz", "fake_issuance", "non_issuer", "immutable_mutation", "oversize_metadata", +} + + +class Chooser: + """Deterministic replacement for randoms.choice.""" + + def __init__(self, mutation=None, index=0, flag=0x08): + self.mutation, self.index, self.flag = mutation, index, flag + + def __call__(self, seq): + seq = list(seq) + first = seq[0] + if isinstance(first, bool): + return seq[self.index] + if isinstance(first, int): + return self.flag + if isinstance(first, str) and first in _MUTATIONS: + return self.mutation + return seq[self.index] + + +class Recorder: + def __init__(self): + self.txs, self.raws, self.fuzzes = [], [], [] + + async def tx(self, name, txn, client, wallet): + self.txs.append((name, txn, wallet)) + + async def raw(self, name, base, client, wallet, mutate=None): + d = base.to_xrpl() + if mutate is not None: + mutate(d) + self.raws.append((name, d)) + + async def fuzz(self, name, base, client, wallet): + self.fuzzes.append((name, base)) + + +def _run(coro): + return asyncio.run(coro) + + +def _accounts(): + a, b = UserAccount(wallet=Wallet.create()), UserAccount(wallet=Wallet.create()) + return {a.address: a, b.address: b} + + +def _issuances(accounts): + addrs = list(accounts) + return [ + MPTokenIssuance( + issuer=addrs[0], + mpt_issuance_id=MUT_ID, + mutable_flags=_MUTABLE_FLAGS, + can_transfer=True, + ), + MPTokenIssuance(issuer=addrs[0], mpt_issuance_id=IMM_ID), + ] + + +def _install(monkeypatch, rec, *, mutation=None, index=0, flag=0x08, faulty=False): + monkeypatch.setattr(mpt, "choice", Chooser(mutation, index, flag)) + monkeypatch.setattr(mpt, "submit_tx", rec.tx) + monkeypatch.setattr(mpt, "submit_raw", rec.raw) + monkeypatch.setattr(mpt, "submit_fuzzed", rec.fuzz) + monkeypatch.setattr(params, "should_send_faulty", lambda: faulty) + + +# ── Filters ────────────────────────────────────────────────────────── +def test_mutable_issuances_excludes_immutable_and_unowned(): + accts = _accounts() + isss = _issuances(accts) + foreign = MPTokenIssuance( + issuer="rForeignIssuerNotInAccounts9999", + mpt_issuance_id="C2" * 24, + mutable_flags=_MUTABLE_FLAGS, + ) + dyn = mpt._mutable_issuances(accts, [*isss, foreign]) + assert [m.mpt_issuance_id for m in dyn] == [MUT_ID] + + +def test_immutable_issuances_filter(): + accts = _accounts() + imm = mpt._immutable_issuances(accts, _issuances(accts)) + assert [m.mpt_issuance_id for m in imm] == [IMM_ID] + + +# ── Base builder ───────────────────────────────────────────────────── +def test_base_builds_set_enable_from_issuer(monkeypatch): + accts = _accounts() + monkeypatch.setattr(mpt, "choice", Chooser(flag=0x08)) + built = mpt._mpt_issuance_set_dynamic_base(accts, _issuances(accts)) + assert built is not None + txn, wallet = built + d = txn.to_xrpl() + assert d["MPTokenIssuanceID"] == MUT_ID + assert d["MutableFlags"] in mpt._SET_ENABLE_FLAGS + assert d["Account"] == wallet.address + + +# ── Valid paths ────────────────────────────────────────────────────── +def test_valid_flag_enable_submits_typed(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="flag_enable") + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.txs) == 1 and rec.txs[0][0] == "DynamicMPTSet" + assert not rec.raws and not rec.fuzzes + d = rec.txs[0][1].to_xrpl() + assert d["MutableFlags"] in mpt._SET_ENABLE_FLAGS + + +def test_valid_metadata_submits_typed(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="metadata") + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.txs) == 1 and not rec.raws + d = rec.txs[0][1].to_xrpl() + assert d["MPTokenMetadata"] and "MutableFlags" not in d + + +def test_valid_transfer_fee_submits_typed(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="transfer_fee") + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.txs) == 1 and not rec.raws + d = rec.txs[0][1].to_xrpl() + assert int(d["TransferFee"]) > 0 and "MutableFlags" not in d + + +# ── Faulty paths ───────────────────────────────────────────────────── +def test_faulty_fuzz_rides_submit_fuzzed(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="fuzz", faulty=True) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.fuzzes) == 1 and rec.fuzzes[0][0] == "DynamicMPTSet" + + +def test_faulty_fake_issuance_uses_fake_id(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="fake_issuance", faulty=True) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.txs) == 1 + d = rec.txs[0][1].to_xrpl() + assert d["MPTokenIssuanceID"] not in (MUT_ID, IMM_ID) + + +def test_faulty_non_issuer_submits_as_other(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="non_issuer", faulty=True) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.txs) == 1 + name, txn, wallet = rec.txs[0] + d = txn.to_xrpl() + isss = _issuances(accts) + assert d["MPTokenIssuanceID"] == MUT_ID and d["Account"] != isss[0].issuer + + +def test_faulty_immutable_mutation_targets_immutable(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="immutable_mutation", faulty=True) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.txs) == 1 and not rec.raws + d = rec.txs[0][1].to_xrpl() + assert d["MPTokenIssuanceID"] == IMM_ID and d["MPTokenMetadata"] + + +def test_faulty_oversize_metadata_exceeds_limit(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="oversize_metadata", faulty=True) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert len(rec.raws) == 1 + _, d = rec.raws[0] + assert "MutableFlags" not in d and len(d["MPTokenMetadata"]) > 2048 + + +# ── Dispatcher routing ─────────────────────────────────────────────── +def test_dispatch_routes_valid_vs_faulty(monkeypatch): + accts, rec = _accounts(), Recorder() + _install(monkeypatch, rec, mutation="flag_enable", faulty=False) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert rec.txs and not rec.fuzzes + rec.txs.clear() + _install(monkeypatch, rec, mutation="fuzz", faulty=True) + _run(mpt.mpt_issuance_set_dynamic(accts, _issuances(accts), None)) + assert rec.fuzzes and not rec.txs + + +# ── Empty-state preconditions ──────────────────────────────────────── +def test_no_dynamic_issuances_is_noop(monkeypatch): + rec = Recorder() + _install(monkeypatch, rec, mutation="flag_enable") + _run(mpt.mpt_issuance_set_dynamic({}, [], None)) + assert not rec.txs and not rec.raws and not rec.fuzzes