From 6256eea1c7c47780fdb65d0cba6cdf94d220cbbe Mon Sep 17 00:00:00 2001 From: Manasi Patel Date: Tue, 25 Aug 2026 11:48:24 -0700 Subject: [PATCH 1/5] Observe LendingProtocolV1_1 principal-only accounting (XLS-66 PR #582) Track the four Vault/LoanBroker fields the amendment moves (AssetsTotal, AssetsAvailable, LossUnrealized, DebtTotal) and stamp Vault.LEVersion on any result touching a Vault node, since LEVersion -- not amendment activation -- gates which accounting model a Vault follows and both coexist post-activation. --- CLAUDE.md | 2 +- workload/src/workload/assertions.py | 85 ++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a72c706..45560b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Prevents `tefPAST_SEQ` cascades in setup: lazy-fetch each account's sequence, th Three-stage lifecycle so a run can be reconstructed from events alone (rippled logs only at WRN under Antithesis; asserts carry empty `details`): - `tx_submitting(name, body)` → `workload::submitted : {TxType}`, emitted **before** the submit RPC so the body lands on the branch at/before any apply-time assert (no vtime-nudge needed). Carries account/sequence/tx_type + object IDs + `tx` = the **full signed body** (redacted of `TxnSignature`/`SigningPubKey` and inner Signers/BatchSigners sigs via `_redact_tx`). Pass the FINAL signed/co-signed tx (post-autofill, post-cosign) so Sequence/Fee/co-sign signers are captured. Fires the `seen` reachability assert. - `tx_submitted(name, body, result)` → post-submit; runs the submit-time `no_internal_rippled_error_submit` + sponsor-signal checks against the tentative response. No event of its own (tentative engine_result lives in those asserts' details); the `seen` assert moved to `tx_submitting`. -- `tx_result()` → `workload::result : {TxType}`, from the validated WS msg. Carries account/sequence/tx_type + object IDs + `created_id`/`created_type`, `deleted_id`/`deleted_type`, `delivered_amount`, and `balance_changes` — a per-entry `[{entry,id,account,before,after}]` list from meta `AffectedNodes` (AccountRoot/RippleState/MPToken/MPTokenIssuance; values faithful — XRP drops as strings, IOU/MPT as amount objects; capped at 25 with `balance_changes_truncated`). This is the on-ledger effect conservation/rounding failures turn on. +- `tx_result()` → `workload::result : {TxType}`, from the validated WS msg. Carries account/sequence/tx_type + object IDs + `created_id`/`created_type`, `deleted_id`/`deleted_type`, `delivered_amount`, and `balance_changes` — a per-entry `[{entry,id,field,account,before,after}]` list from meta `AffectedNodes` (AccountRoot/RippleState/MPToken/MPTokenIssuance, plus Vault `AssetsTotal`/`AssetsAvailable`/`LossUnrealized` + LoanBroker `DebtTotal` for the XLS-66 `LendingProtocolV1_1` principal-only accounting — PR #582; an entry that moves several tracked fields emits one row per changed field, tagged by `field`; values faithful — XRP drops as strings, IOU/MPT as amount objects; capped at 25 with `balance_changes_truncated`). This is the on-ledger effect conservation/rounding failures turn on. Any result touching a `Vault` node also carries `vault_le_version` — `Vault.LEVersion` (XLS-65 §3.1.2.2), which is what actually gates the amended accounting: `0`/absent = legacy accrual-basis (`AssetsTotal` includes interest), `1` = cash-basis principal-only. Both coexist post-activation (a pre-amendment Vault never migrates), so the balance rows are unattributable without it; it's protocol-written and never a tx field, so meta is the only source. Every submit path must call `tx_submitting` before the RPC and `tx_submitted` after: `submit_tx`/`submit_raw` (`submit.py`) and the three co-sign paths (`lending.py` LoanSet, `sponsorship.py` ×2). Inner batch txns (`tfInnerBatchTxn`) also emit `workload::inner_batch_observed`; normal `tx_result()` still runs. diff --git a/workload/src/workload/assertions.py b/workload/src/workload/assertions.py index 1afa91a..9a86175 100644 --- a/workload/src/workload/assertions.py +++ b/workload/src/workload/assertions.py @@ -219,11 +219,19 @@ def scrub(o: object) -> object: # Ledger entries whose balance movement matters for conservation/rounding analysis. -_BALANCE_FIELDS = { - "AccountRoot": "Balance", - "RippleState": "Balance", - "MPToken": "MPTAmount", - "MPTokenIssuance": "OutstandingAmount", +# Each entry type lists every field worth tracking (an entry -- e.g. a Vault -- can +# move several). Vault/LoanBroker cover the lending totals: under LendingProtocolV1_1 +# (XLS-66, PR #582) AssetsTotal/DebtTotal move by principal only (no interest), LoanPay +# routes interest through AssetsTotal, and LoanManage impair/default moves LossUnrealized +# -- so capturing all three Vault fields plus DebtTotal lets a run observe the amended +# accounting across LoanSet/LoanPay/LoanManage instead of it being invisible. +_BALANCE_FIELDS: dict[str, tuple[str, ...]] = { + "AccountRoot": ("Balance",), + "RippleState": ("Balance",), + "MPToken": ("MPTAmount",), + "MPTokenIssuance": ("OutstandingAmount",), + "Vault": ("AssetsTotal", "AssetsAvailable", "LossUnrealized"), + "LoanBroker": ("DebtTotal",), } _MAX_BALANCE_CHANGES = 25 @@ -231,38 +239,62 @@ def scrub(o: object) -> object: def _balance_changes(meta: dict) -> tuple[list[dict[str, object]], bool]: """Per-entry balance before/after from meta AffectedNodes. Values stay faithful (XRP drops as strings; IOU/MPT as amount objects) so the reader does the math. - This is the on-ledger effect that conservation/rounding failures turn on.""" + This is the on-ledger effect that conservation/rounding failures turn on. An entry + can move several tracked fields, so each changed field emits its own tagged row.""" changes: list[dict[str, object]] = [] for node in meta.get("AffectedNodes", []): for kind in ("ModifiedNode", "CreatedNode", "DeletedNode"): n = node.get(kind) if not isinstance(n, dict): continue - field = _BALANCE_FIELDS.get(n.get("LedgerEntryType", "")) - if field is None: + fields = _BALANCE_FIELDS.get(n.get("LedgerEntryType", "")) + if fields is None: continue final = n.get("FinalFields") or n.get("NewFields") or {} prev = n.get("PreviousFields") or {} - if kind == "CreatedNode": - before, after = None, final.get(field) - elif kind == "DeletedNode": - before, after = final.get(field), None - else: - if field not in prev: - continue # this modification didn't touch the balance - before, after = prev.get(field), final.get(field) - changes.append( - { - "entry": n.get("LedgerEntryType", ""), - "id": n.get("LedgerIndex", ""), - "account": final.get("Account", ""), - "before": before, - "after": after, - } - ) + # Vault/LoanBroker key on Owner, not Account. + account = final.get("Account") or final.get("Owner", "") + for field in fields: + if kind == "CreatedNode": + if field not in final: + continue # entry doesn't carry this field on create + before, after = None, final.get(field) + elif kind == "DeletedNode": + if field not in final: + continue + before, after = final.get(field), None + else: + if field not in prev: + continue # this modification didn't touch the field + before, after = prev.get(field), final.get(field) + changes.append( + { + "entry": n.get("LedgerEntryType", ""), + "id": n.get("LedgerIndex", ""), + "field": field, + "account": account, + "before": before, + "after": after, + } + ) return changes[:_MAX_BALANCE_CHANGES], len(changes) > _MAX_BALANCE_CHANGES +def _vault_le_version(meta: dict) -> str | None: + """Vault.LEVersion (XLS-65 3.1.2.2, LendingProtocolV1_1) decides which accounting a + Vault follows -- absent/0 legacy accrual-basis, 1 principal-only cash-basis -- and both + coexist after activation, so a balance_changes row is unattributable without it. It is + protocol-written and never a transaction field, so the meta node is the only source.""" + for node in meta.get("AffectedNodes", []): + for kind in ("ModifiedNode", "CreatedNode", "DeletedNode"): + n = node.get(kind) + if not isinstance(n, dict) or n.get("LedgerEntryType") != "Vault": + continue + fields = n.get("FinalFields") or n.get("NewFields") or {} + return str(fields.get("LEVersion", 0)) + return None + + def _emit_catalog_entry(message: str, assert_type: str, display_type: str, must_hit: bool) -> None: """hit=False registers existence with Antithesis without claiming a hit.""" assert_raw( @@ -644,6 +676,9 @@ def tx_result(name: str, result: dict) -> None: details["balance_changes"] = changes if truncated: details["balance_changes_truncated"] = True + le_version = _vault_le_version(meta) + if le_version is not None: + details["vault_le_version"] = le_version send_event(f"workload::result : {name}", details) assert_raw( From d1605d67dac74f902cb8668c539e9d8320d03cb8 Mon Sep 17 00:00:00 2001 From: Manasi Patel Date: Wed, 26 Aug 2026 14:14:24 -0700 Subject: [PATCH 2/5] Exercise closed-ended vaults and the VaultDelete deletion reason (XLS-65) LendingProtocolV1_1 adds VaultKind/SubscriptionDate/RedemptionDate to VaultCreate and an optional MemoData deletion reason to VaultDelete. The pinned xrpl-py has neither the model fields nor the codec definitions, so lending_v1_1_compat registers the field headers into the live binarycodec maps and subclasses both models. Length and range checks stay server-side so faulty handlers can still build the out-of-range cases. The amendment is latched off metadata, not config: a validated Vault's LEVersion is 1 only once LendingProtocolV1_1 is active, so the V1.1 fields join only after one has been seen. Pre-amendment they are temDISABLED, which never validates and would starve the failure bucket. VaultCreate now mints a minority of closed-ended vaults, so open-ended ones keep the phase-free deposit/withdraw valid paths stocked. Vault tracks the kind and both dates from the created node, and deposit/withdraw pick a vault whose current phase permits the transaction. Faulty vectors cover the sub-kMinInvestmentPeriod gap, already-expired dates, an out-of-enum VaultKind, and for VaultDelete an empty and an over-256-byte MemoData (validDataLength rejects both). Genesis was activating only Supported::Yes amendments, but Dockerfile.xrpld rewrites Supported::No to Yes before building, so LendingProtocolV1_1 was known to the binary yet inactive in the ledger. Genesis now includes those too, skipping VoteBehavior::Obsolete (enabling one amendment-blocks the node). Also scopes XRPLD_NO_PATCH_NIX_BINARY to the fuzzer's conan install, whose build folder lacks the loader script rippled's PatchNixBinary.cmake expects. RXT-832 --- prepare-workload/generate_genesis.py | 17 +- workload/src/workload/lending_v1_1_compat.py | 161 ++++++++++++++++ workload/src/workload/models.py | 6 + workload/src/workload/params.py | 58 ++++++ .../src/workload/transactions/__init__.py | 28 ++- workload/src/workload/transactions/vaults.py | 177 ++++++++++++++++-- 6 files changed, 424 insertions(+), 23 deletions(-) create mode 100644 workload/src/workload/lending_v1_1_compat.py diff --git a/prepare-workload/generate_genesis.py b/prepare-workload/generate_genesis.py index b643595..1fd08ce 100644 --- a/prepare-workload/generate_genesis.py +++ b/prepare-workload/generate_genesis.py @@ -33,19 +33,28 @@ def sha512half(name: str) -> str: def parse_features_macro(macro_path: Path) -> list[str]: - """Extract supported amendment names from rippled's features.macro. + """Extract amendment names from rippled's features.macro. + + Includes Supported::No amendments — Dockerfile.xrpld rewrites them to + Supported::Yes before building, so the binary knows them and they need + testing too (e.g. LendingProtocolV1_1). VoteBehavior::Obsolete is skipped: + enabling one leaves the node amendment-blocked. Excludes retired amendments and comment examples. """ - text = "\n".join(line for line in macro_path.read_text().splitlines() if not line.lstrip().startswith("//")) + text = "\n".join( + line + for line in macro_path.read_text().splitlines() + if not line.lstrip().startswith("//") and "VoteBehavior::Obsolete" not in line + ) amendments = [] # rippled renamed Supported::yes/no to Supported::Yes/No in PR #6571 # (clang-tidy readability check). Match both cases. - for m in re.finditer(r"XRPL_FEATURE\s*\(\s*(\w+)\s*,\s*Supported::[Yy]es\s*,", text): + for m in re.finditer(r"XRPL_FEATURE\s*\(\s*(\w+)\s*,\s*Supported::(?:[Yy]es|[Nn]o)\s*,", text): amendments.append(m.group(1)) - for m in re.finditer(r"XRPL_FIX\s*\(\s*(\w+)\s*,\s*Supported::[Yy]es\s*,", text): + for m in re.finditer(r"XRPL_FIX\s*\(\s*(\w+)\s*,\s*Supported::(?:[Yy]es|[Nn]o)\s*,", text): amendments.append("fix" + m.group(1)) return sorted(amendments) diff --git a/workload/src/workload/lending_v1_1_compat.py b/workload/src/workload/lending_v1_1_compat.py new file mode 100644 index 0000000..526389a --- /dev/null +++ b/workload/src/workload/lending_v1_1_compat.py @@ -0,0 +1,161 @@ +"""Closed-ended Vault compat for LendingProtocolV1_1 (XLS-65, rippled PR #587). + +PR #587 adds three VaultCreate transaction fields -- sfVaultKind (UInt8, nth +22), sfSubscriptionDate (UInt32, nth 75) and sfRedemptionDate (UInt32, nth 76) +-- plus a Vault ledger field sfLEVersion (UInt8, nth 6). The pinned xrpl-py +branch carries neither the codec definitions nor the model fields, so this +module injects the field headers into the live binarycodec maps and extends the +model. TEMPORARY -- delete once xrpl-py's pre-3.3-release-group catches up, +then revert imports to xrpl.models. + +sfLEVersion needs no codec entry: it is protocol-written, never a transaction +field, and reaches the workload only as JSON metadata. VaultDelete's optional +sfMemoData deletion reason needs no codec entry either -- MemoData is a +long-standing Blob field -- only the model extension below. + +AMENDMENT GATING. featureLendingProtocolV1_1 is Supported::No on rippled +develop; generate_genesis.py now enables it anyway (matching Dockerfile.xrpld's +Supported::No rewrite), but a run against a node without it would get +temDISABLED on any of the three fields. So every closed-ended path is gated on +enabled(), which flips only after a validated Vault came back carrying +LEVersion >= 1 (rippled stamps it on every VaultCreate once the amendment is +active, so the setup vault phase settles this before any driver runs). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional + +import xrpl.models.transactions as _models +from xrpl.core.binarycodec.definitions import definitions as _defs +from xrpl.core.binarycodec.definitions.field_header import FieldHeader +from xrpl.core.binarycodec.definitions.field_info import FieldInfo +from xrpl.models.transactions import VaultCreate as _UpstreamVaultCreate +from xrpl.models.transactions import VaultDelete as _UpstreamVaultDelete + + +def _register_field(name: str, type_name: str, nth: int) -> None: + if name in _defs._FIELD_INFO_MAP: + return # xrpl-py caught up -- keep its definition, this shim is now dead + header = FieldHeader(_defs._TYPE_ORDINAL_MAP[type_name], nth) + if header in _defs._FIELD_HEADER_NAME_MAP: + # A silent collision would decode as the wrong field on every response. + raise RuntimeError( + f"field header {type_name}/{nth} already taken by" + f" {_defs._FIELD_HEADER_NAME_MAP[header]}" + ) + _defs._DEFINITIONS["FIELDS"][name] = { + "nth": nth, + "isVLEncoded": False, + "isSerialized": True, + "isSigningField": True, + "type": type_name, + } + _defs._FIELD_INFO_MAP[name] = FieldInfo(nth, False, True, True, type_name) + _defs._FIELD_HEADER_NAME_MAP[header] = name + + +_register_field("VaultKind", "UInt8", 22) +_register_field("SubscriptionDate", "UInt32", 75) +_register_field("RedemptionDate", "UInt32", 76) + + +class VaultKind(IntEnum): + """rippled's VaultKind (Protocol.h); absent sfVaultKind means OpenEnded.""" + + OPEN_ENDED = 0 + CLOSED_ENDED = 1 + + +class VaultPhase(IntEnum): + """Lifecycle phase of a Vault. Open-ended vaults are always NO_PHASE.""" + + NO_PHASE = 0 + SUBSCRIPTION = 1 + INVESTMENT = 2 + REDEMPTION = 3 + + +# Bounds on RedemptionDate - SubscriptionDate that VaultCreate preflight +# enforces (rippled Protocol.h kMin/kMaxInvestmentPeriod): min <= gap < max. +MIN_INVESTMENT_PERIOD = 60 +MAX_INVESTMENT_PERIOD = 946_708_560 + + +def vault_phase( + kind: int | None, + subscription_date: int | None, + redemption_date: int | None, + now: int, +) -> VaultPhase: + """rippled's getVaultPhase (VaultHelpers.cpp) over tracked Vault state. + Subscription includes now == SubscriptionDate; Investment starts strictly + after it and runs through RedemptionDate.""" + if ( + int(kind or 0) != VaultKind.CLOSED_ENDED + or subscription_date is None + or redemption_date is None + ): + return VaultPhase.NO_PHASE + if now <= subscription_date: + return VaultPhase.SUBSCRIPTION + if now <= redemption_date: + return VaultPhase.INVESTMENT + return VaultPhase.REDEMPTION + + +@dataclass(frozen=True, kw_only=True) +class VaultCreate(_UpstreamVaultCreate): + """Upstream model plus the PR #587 closed-ended fields. Validation stays + server-side so faulty handlers can build the malformed combinations.""" + + # Optional[...] not `| None`: xrpl-py's BaseModel._check_type introspects the + # annotation at construction and crashes on a PEP 604 UnionType. + vault_kind: Optional[int] = None # noqa: UP045 + """0 open-ended (default when absent), 1 closed-ended.""" + + subscription_date: Optional[int] = None # noqa: UP045 + """Ripple-epoch second the Subscription phase ends. Closed-ended only.""" + + redemption_date: Optional[int] = None # noqa: UP045 + """Ripple-epoch second the Investment phase ends. Closed-ended only.""" + + +@dataclass(frozen=True, kw_only=True) +class VaultDelete(_UpstreamVaultDelete): + """Upstream model plus the V1.1 sfMemoData deletion reason. Length stays + unvalidated so faulty handlers can build the out-of-range cases.""" + + memo_data: Optional[str] = None # noqa: UP045 + """Hex deletion reason, 1-256 bytes (rippled kMaxDataPayloadLength).""" + + +# autofill/sign round-trip every tx through Transaction.from_dict, which +# resolves the class by live getattr on this module namespace -- without this +# rebind it lands on the upstream class and rejects the new kwargs. +_models.VaultCreate = VaultCreate +_models.VaultDelete = VaultDelete + + +_enabled = False + + +def enabled() -> bool: + """True once a validated Vault proved featureLendingProtocolV1_1 is active.""" + return _enabled + + +def note_vault_le_version(le_version: int | str | None) -> None: + """Latch the amendment from a created Vault's LEVersion. Called from the + VaultCreate state updater, so the flag is set by a validated ledger entry + rather than an amendment-table read the public port may not serve.""" + global _enabled + if _enabled: + return + try: + if int(le_version or 0) >= 1: + _enabled = True + except (TypeError, ValueError): + return diff --git a/workload/src/workload/models.py b/workload/src/workload/models.py index 53317f9..ac087f2 100644 --- a/workload/src/workload/models.py +++ b/workload/src/workload/models.py @@ -106,6 +106,12 @@ class Vault: asset: IssuedCurrency | MPTCurrency | xrpl.models.XRP | None = None balance: int = 0 shareholders: set[str] = field(default_factory=set) + # XLS-65 closed-ended vaults; dates are ripple-epoch seconds and are None on + # open-ended vaults. Read from the created node so handlers can pick a vault + # whose current phase permits the transaction they are about to build. + vault_kind: int = 0 + subscription_date: int | None = None + redemption_date: int | None = None @dataclass diff --git a/workload/src/workload/params.py b/workload/src/workload/params.py index 9995f08..c2032ed 100644 --- a/workload/src/workload/params.py +++ b/workload/src/workload/params.py @@ -3,6 +3,7 @@ from xrpl.models.transactions import SponsorshipTransferFlag from workload import confidential_crypto as _cc +from workload import lending_v1_1_compat as _lv from workload.randoms import choice, randint, random @@ -137,6 +138,63 @@ def vault_assets_maximum() -> str: return str(randint(100_000_000, 10_000_000_000)) +# ── Closed-ended Vaults (XLS-65, LendingProtocolV1_1) ──────────────── +def should_create_closed_ended_vault() -> bool: + """A minority, so open-ended vaults keep the phase-free deposit/withdraw + valid paths well stocked.""" + return random() < 0.25 + + +def closed_ended_dates() -> tuple[int, int]: + """(SubscriptionDate, RedemptionDate) satisfying preflight's gap bound and + preclaim's non-expiry check. Two flavors: a long subscription window keeps + the vault depositable for the whole run, a short one walks it through + Subscription -> Investment -> Redemption so the phase gates get exercised.""" + if random() < 0.3: + sub = _ripple_now() + randint(5, 30) + gap = randint(_lv.MIN_INVESTMENT_PERIOD, _lv.MIN_INVESTMENT_PERIOD + 120) + else: + sub = _ripple_now() + randint(600, 1800) + gap = randint(_lv.MIN_INVESTMENT_PERIOD, 3600) + return sub, sub + gap + + +def closed_ended_short_gap() -> tuple[int, int]: + """Gap below kMinInvestmentPeriod → temMALFORMED.""" + sub = _ripple_now() + randint(60, 600) + return sub, sub + randint(0, _lv.MIN_INVESTMENT_PERIOD - 1) + + +def closed_ended_expired_dates() -> tuple[int, int]: + """Both dates already past → preclaim tecEXPIRED (a tec, so it validates + and feeds the failure bucket, unlike the tem malformations).""" + sub = _ripple_now() - randint(3600, 86_400) + return sub, sub + randint(_lv.MIN_INVESTMENT_PERIOD, 3600) + + +def invalid_vault_kind() -> int: + """Outside rippled's VaultKind enum → temMALFORMED.""" + return randint(2, 255) + + +def should_attach_delete_memo() -> bool: + """VaultDelete's deletion reason is optional; leave it off often enough that + the field-absent path stays covered.""" + return random() < 0.5 + + +def vault_delete_memo_data() -> str: + """1-256 bytes, the range VaultDelete preflight accepts.""" + length = randint(1, 256) + return bytes(randint(0, 255) for _ in range(length)).hex() + + +def oversized_vault_delete_memo_data() -> str: + """Over kMaxDataPayloadLength → temMALFORMED.""" + length = randint(257, 512) + return bytes(randint(0, 255) for _ in range(length)).hex() + + # ── Permissioned Domains ───────────────────────────────────────────── def domain_credential_count() -> int: """1-10 accepted credentials per domain.""" diff --git a/workload/src/workload/transactions/__init__.py b/workload/src/workload/transactions/__init__.py index 23d09ab..1738c59 100644 --- a/workload/src/workload/transactions/__init__.py +++ b/workload/src/workload/transactions/__init__.py @@ -14,7 +14,7 @@ from xrpl.models.currencies import MPTCurrency from xrpl.models.transactions import MPTokenIssuanceCreateFlag -from workload import params +from workload import lending_v1_1_compat, params from workload.models import ( AMM, DID, @@ -194,11 +194,35 @@ def _parse_asset( return xrpl.models.XRP() +def _created_vault_fields(meta: dict) -> dict: + for node in meta.get("AffectedNodes", []): + created = node.get("CreatedNode", {}) + if created.get("LedgerEntryType") == "Vault": + fields = created.get("NewFields") or {} + return fields if isinstance(fields, dict) else {} + return {} + + def _on_vault_create(w: Workload, tx: dict, meta: dict) -> None: vault_id = _extract_created_id(meta, "Vault") if vault_id: asset = _parse_asset(tx.get("Asset", {})) - w.vaults.append(Vault(owner=tx["Account"], vault_id=vault_id, asset=asset)) + # LEVersion / VaultKind / the phase dates are protocol-written, so the + # created node is the only source -- and LEVersion latches the amendment. + fields = _created_vault_fields(meta) + lending_v1_1_compat.note_vault_le_version(fields.get("LEVersion")) + sub = fields.get("SubscriptionDate") + red = fields.get("RedemptionDate") + w.vaults.append( + Vault( + owner=tx["Account"], + vault_id=vault_id, + asset=asset, + vault_kind=int(fields.get("VaultKind", 0) or 0), + subscription_date=int(sub) if sub is not None else None, + redemption_date=int(red) if red is not None else None, + ) + ) def _on_vault_delete(w: Workload, tx: dict, meta: dict) -> None: diff --git a/workload/src/workload/transactions/vaults.py b/workload/src/workload/transactions/vaults.py index 134ef1b..59f98ea 100644 --- a/workload/src/workload/transactions/vaults.py +++ b/workload/src/workload/transactions/vaults.py @@ -8,20 +8,58 @@ from xrpl.models.currencies import MPTCurrency from xrpl.models.transactions import ( VaultClawback, - VaultCreate, - VaultDelete, VaultDeposit, VaultSet, VaultWithdraw, ) from xrpl.wallet import Wallet +from workload import lending_v1_1_compat as lv from workload import params from workload.fuzz import submit_fuzzed + +# VaultCreate and VaultDelete ride the compat shim until xrpl-py carries the +# XLS-65 closed-ended fields and the V1.1 MemoData; see lending_v1_1_compat. +from workload.lending_v1_1_compat import VaultCreate, VaultDelete from workload.models import MPTokenIssuance, TrustLine, UserAccount, Vault from workload.randoms import choice, randint, random from workload.submit import submit_tx +# Ledger close time trails wall clock, so a vault whose phase would change +# within this many seconds is treated as being in both phases. +_PHASE_SKEW = 60 +# Phases in which rippled rejects the transaction (VaultDeposit tecEXPIRED, +# VaultWithdraw tecTOO_SOON). +_DEPOSIT_BLOCKED = frozenset({lv.VaultPhase.INVESTMENT, lv.VaultPhase.REDEMPTION}) +_WITHDRAW_BLOCKED = frozenset({lv.VaultPhase.INVESTMENT}) + + +def _phases_around(vault: Vault) -> set[lv.VaultPhase]: + now = params._ripple_now() + return { + lv.vault_phase(vault.vault_kind, vault.subscription_date, vault.redemption_date, t) + for t in (now - _PHASE_SKEW, now + _PHASE_SKEW) + } + + +def _phase_permits(vault: Vault, blocked: frozenset[lv.VaultPhase]) -> bool: + return not (_phases_around(vault) & blocked) + + +def _phase_blocks(vault: Vault, blocked: frozenset[lv.VaultPhase]) -> bool: + return _phases_around(vault) <= blocked + + +def _pick_permitted_vault(vaults: list[Vault], blocked: frozenset[lv.VaultPhase]) -> Vault | None: + eligible = [v for v in vaults if _phase_permits(v, blocked)] + return choice(eligible) if eligible else None + + +def _pick_blocked_vault(vaults: list[Vault], blocked: frozenset[lv.VaultPhase]) -> Vault | None: + eligible = [v for v in vaults if _phase_blocks(v, blocked)] + return choice(eligible) if eligible else None + + # ── Create ─────────────────────────────────────────────────────────── @@ -81,12 +119,24 @@ def _vault_create_base( return None src = accounts[choice(list(accounts))] asset = _random_asset(trust_lines, mpt_issuances) - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), - ) + if lv.enabled() and params.should_create_closed_ended_vault(): + sub, red = params.closed_ended_dates() + txn = VaultCreate( + account=src.address, + asset=asset, + assets_maximum=params.vault_assets_maximum(), + data=params.vault_data(), + vault_kind=int(lv.VaultKind.CLOSED_ENDED), + subscription_date=sub, + redemption_date=red, + ) + else: + txn = VaultCreate( + account=src.address, + asset=asset, + assets_maximum=params.vault_assets_maximum(), + data=params.vault_data(), + ) return txn, src.wallet @@ -115,7 +165,12 @@ async def _vault_create_faulty( return src = choice(list(accounts.values())) asset = _random_asset(trust_lines, mpt_issuances) - mutation = choice(["fuzz", "zero_max", "oversized_data", "xrp_with_issuer"]) + mutations = ["fuzz", "zero_max", "oversized_data", "xrp_with_issuer"] + if lv.enabled(): + # temDISABLED without the amendment, so these only join once a validated + # Vault proved it active. + mutations += ["short_gap", "expired_dates", "invalid_kind"] + mutation = choice(mutations) if mutation == "fuzz": built = _vault_create_base(accounts, trust_lines, mpt_issuances) if built is None: @@ -138,7 +193,7 @@ async def _vault_create_faulty( assets_maximum=params.vault_assets_maximum(), data=oversized, ) - else: # xrp_with_issuer + elif mutation == "xrp_with_issuer": bad_asset = IssuedCurrency( currency="XRP", issuer=choice(list(accounts.values())).address, @@ -149,6 +204,39 @@ async def _vault_create_faulty( assets_maximum=params.vault_assets_maximum(), data=params.vault_data(), ) + elif mutation == "short_gap": + sub, red = params.closed_ended_short_gap() + txn = VaultCreate( + account=src.address, + asset=asset, + assets_maximum=params.vault_assets_maximum(), + data=params.vault_data(), + vault_kind=int(lv.VaultKind.CLOSED_ENDED), + subscription_date=sub, + redemption_date=red, + ) + elif mutation == "expired_dates": + sub, red = params.closed_ended_expired_dates() + txn = VaultCreate( + account=src.address, + asset=asset, + assets_maximum=params.vault_assets_maximum(), + data=params.vault_data(), + vault_kind=int(lv.VaultKind.CLOSED_ENDED), + subscription_date=sub, + redemption_date=red, + ) + else: # invalid_kind — dates stay well-formed so the kind is the only fault + sub, red = params.closed_ended_dates() + txn = VaultCreate( + account=src.address, + asset=asset, + assets_maximum=params.vault_assets_maximum(), + data=params.vault_data(), + vault_kind=params.invalid_vault_kind(), + subscription_date=sub, + redemption_date=red, + ) await submit_tx("VaultCreate", txn, client, src.wallet) @@ -168,7 +256,9 @@ def _vault_deposit_base( ) -> tuple[VaultDeposit, Wallet] | None: if not vaults or not accounts: return None - vault = choice(vaults) + vault = _pick_permitted_vault(vaults, _DEPOSIT_BLOCKED) + if vault is None: + return None depositor = accounts[choice(list(accounts))] txn = VaultDeposit( account=depositor.address, @@ -194,7 +284,10 @@ async def _vault_deposit_faulty( if not accounts: return depositor = choice(list(accounts.values())) - mutation = choice(["fuzz", "fake_vault", "zero_amount", "mismatched_asset"]) + mutations = ["fuzz", "fake_vault", "zero_amount", "mismatched_asset"] + if lv.enabled(): + mutations.append("closed_phase") + mutation = choice(mutations) if mutation == "fuzz": built = _vault_deposit_base(accounts, vaults) if built is None: @@ -220,7 +313,7 @@ async def _vault_deposit_faulty( vault_id=vault.vault_id, amount="0", ) - else: # mismatched_asset + elif mutation == "mismatched_asset": if not vaults: return vault = choice(vaults) @@ -233,6 +326,15 @@ async def _vault_deposit_faulty( vault_id=vault.vault_id, amount=amount, ) + else: # closed_phase — subscription window shut, so tecEXPIRED + closed = _pick_blocked_vault(vaults, _DEPOSIT_BLOCKED) + if closed is None: + return + txn = VaultDeposit( + account=depositor.address, + vault_id=closed.vault_id, + amount=_amount_for_asset(closed.asset), + ) await submit_tx("VaultDeposit", txn, client, depositor.wallet) @@ -265,8 +367,8 @@ def _vault_withdraw_base( ) -> tuple[VaultWithdraw, Wallet] | None: if not vaults: return None - vault = choice(vaults) - if vault.owner not in accounts: + vault = _pick_permitted_vault(vaults, _WITHDRAW_BLOCKED) + if vault is None or vault.owner not in accounts: return None owner = accounts[vault.owner] txn = VaultWithdraw( @@ -293,7 +395,23 @@ async def _vault_withdraw_faulty( if not accounts or not vaults: return vault = choice(vaults) - mutation = choice(["fuzz", "fake_vault", "non_owner", "overdraw"]) + mutations = ["fuzz", "fake_vault", "non_owner", "overdraw"] + if lv.enabled(): + mutations.append("closed_phase") + mutation = choice(mutations) + if mutation == "closed_phase": + # Investment phase locks the vault, so a withdraw draws tecTOO_SOON. + locked = _pick_blocked_vault(vaults, _WITHDRAW_BLOCKED) + if locked is None or locked.owner not in accounts: + return + owner = accounts[locked.owner] + txn = VaultWithdraw( + account=owner.address, + vault_id=locked.vault_id, + amount=_amount_for_asset(locked.asset), + ) + await submit_tx("VaultWithdraw", txn, client, owner.wallet) + return if mutation == "fuzz": built = _vault_withdraw_base(accounts, vaults) if built is None: @@ -439,9 +557,17 @@ def _vault_delete_base( if vault.owner not in accounts: return None owner = accounts[vault.owner] + # MemoData is temDISABLED without the amendment, so it only joins once a + # validated Vault proved it active. + memo = ( + params.vault_delete_memo_data() + if lv.enabled() and params.should_attach_delete_memo() + else None + ) txn = VaultDelete( account=owner.address, vault_id=vault.vault_id, + memo_data=memo, ) return txn, owner.wallet @@ -462,7 +588,10 @@ async def _vault_delete_faulty( if not accounts or not vaults: return vault = choice(vaults) - mutation = choice(["fuzz", "fake_vault", "non_owner"]) + mutations = ["fuzz", "fake_vault", "non_owner"] + if lv.enabled(): + mutations += ["empty_memo", "oversized_memo"] + mutation = choice(mutations) if mutation == "fuzz": built = _vault_delete_base(accounts, vaults) if built is None: @@ -470,6 +599,20 @@ async def _vault_delete_faulty( base, wallet = built await submit_fuzzed("VaultDelete", base, client, wallet) return + if mutation in ("empty_memo", "oversized_memo"): + # validDataLength rejects both an empty and an over-256-byte MemoData. + if vault.owner not in accounts: + return + owner = accounts[vault.owner] + txn = VaultDelete( + account=owner.address, + vault_id=vault.vault_id, + memo_data=( + "" if mutation == "empty_memo" else params.oversized_vault_delete_memo_data() + ), + ) + await submit_tx("VaultDelete", txn, client, owner.wallet) + return if mutation == "fake_vault": if vault.owner not in accounts: return From 4adf5c80ed3f4a88a66d52b7cf3efdeec94e32bb Mon Sep 17 00:00:00 2001 From: Manasi Patel Date: Wed, 26 Aug 2026 14:33:19 -0700 Subject: [PATCH 3/5] Make the closed-ended vault paths observable Every XLS-65 path is gated on lending_v1_1_compat.enabled(), which latches off a validated Vault's LEVersion. If that never flips -- amendment inactive, or a genesis regression -- the whole feature goes dark while VaultCreate's success/failure dims stay satisfied off the open-ended vectors, so a run would report green having exercised none of it. Five sometimes buckets off tx_result's validated stream: lending_v1_1_active (the latch itself), vault_closed_ended_created (created node carries a nonzero VaultKind), vault_deposit_phase_blocked (tecEXPIRED) and vault_withdraw_phase_blocked (tecTOO_SOON) for the two phase gates, and vault_delete_reason_used (validated MemoData). All must_hit=False, matching conf_mpt_version_monotonic: they only fire against an xrpld with LendingProtocolV1_1 active, so a run without it must not starve. _fire_sometimes takes must_hit so the fire site can match its catalog entry. RXT-832 --- scripts/check-imports | 1 + workload/src/workload/assertions.py | 63 +++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/scripts/check-imports b/scripts/check-imports index a036bdd..ca33088 100755 --- a/scripts/check-imports +++ b/scripts/check-imports @@ -42,6 +42,7 @@ from workload.fuzz import submit_fuzzed, fuzz_mutate from workload.rawfuzz import escalate from workload.assembler import parse, reassemble from workload.sequence import SequenceTracker +from workload.lending_v1_1_compat import VaultCreate, VaultDelete, vault_phase, enabled from workload.ws_listener import start_ws_listener from workload.setup import run_setup from workload.params import should_send_faulty, fake_account, fake_id diff --git a/workload/src/workload/assertions.py b/workload/src/workload/assertions.py index 9a86175..ec897ad 100644 --- a/workload/src/workload/assertions.py +++ b/workload/src/workload/assertions.py @@ -295,6 +295,45 @@ def _vault_le_version(meta: dict) -> str | None: return None +def _created_vault_kind(meta: dict) -> str | None: + """VaultKind off a created Vault node. Absent means open-ended, so a None + here is a legacy vault and a "0" is an explicit open-ended V1.1 one.""" + for node in meta.get("AffectedNodes", []): + created = node.get("CreatedNode") + if isinstance(created, dict) and created.get("LedgerEntryType") == "Vault": + fields = created.get("NewFields") or {} + kind = fields.get("VaultKind") + return None if kind is None else str(kind) + return None + + +def _assert_vault_v1_1_signals( + name: str, tx_json: dict, meta: dict, engine_result: str, le_version: str | None, tx_hash: str +) -> None: + """Reachability for the XLS-65 closed-ended paths. Every one of them is gated + on lending_v1_1_compat.enabled(), so without these buckets the whole feature + can go dark while VaultCreate's success/failure dims stay satisfied off the + open-ended vectors. must_hit=False throughout: they only fire against an + xrpld with LendingProtocolV1_1 active.""" + details = {"tx_type": name, "engine_result": engine_result, "hash": tx_hash} + if le_version is not None and le_version not in ("0", ""): + _fire_sometimes("lending_v1_1_active", True, details, must_hit=False) + if name == "VaultCreate" and engine_result == "tesSUCCESS": + kind = _created_vault_kind(meta) + if kind is not None and kind != "0": + _fire_sometimes( + "vault_closed_ended_created", True, {**details, "vault_kind": kind}, must_hit=False + ) + # The phase gates: Investment/Redemption shuts deposits, Investment locks + # withdraws. Both are tec, so they validate and reach this stream. + if name == "VaultDeposit" and engine_result == "tecEXPIRED": + _fire_sometimes("vault_deposit_phase_blocked", True, details, must_hit=False) + if name == "VaultWithdraw" and engine_result == "tecTOO_SOON": + _fire_sometimes("vault_withdraw_phase_blocked", True, details, must_hit=False) + if name == "VaultDelete" and engine_result == "tesSUCCESS" and tx_json.get("MemoData"): + _fire_sometimes("vault_delete_reason_used", True, details, must_hit=False) + + def _emit_catalog_entry(message: str, assert_type: str, display_type: str, must_hit: bool) -> None: """hit=False registers existence with Antithesis without claiming a hit.""" assert_raw( @@ -398,6 +437,18 @@ def register_assertions() -> None: "sponsorship_audit_account_consistent", ): _emit_catalog_entry(f"workload::sometimes : {key}", "sometimes", "Sometimes", must_hit=True) + # XLS-65 closed-ended vaults. must_hit=False: these only fire against an xrpld + # with LendingProtocolV1_1 active, so a run without it must not starve. + for vault_key in ( + "lending_v1_1_active", + "vault_closed_ended_created", + "vault_deposit_phase_blocked", + "vault_withdraw_phase_blocked", + "vault_delete_reason_used", + ): + _emit_catalog_entry( + f"workload::sometimes : {vault_key}", "sometimes", "Sometimes", must_hit=False + ) for setup_key in [ "gateways", "trust_lines", @@ -450,9 +501,12 @@ def assert_no_internal_error_submit(name: str, result: dict) -> None: ) -def _fire_sometimes(key: str, condition: bool, details: dict[str, str]) -> None: +def _fire_sometimes( + key: str, condition: bool, details: dict[str, str], must_hit: bool = True +) -> None: """Shared plumbing for the sponsor-state reachability signals below -- - ``sometimes`` only needs one True hit, so a False call is a harmless no-op.""" + ``sometimes`` only needs one True hit, so a False call is a harmless no-op. + ``must_hit`` must match the key's register_assertions() catalog entry.""" msg = f"workload::sometimes : {key}" assert_raw( condition=condition, @@ -464,7 +518,7 @@ def _fire_sometimes(key: str, condition: bool, details: dict[str, str]) -> None: loc_begin_line=0, loc_begin_column=_LOC_COL, hit=True, - must_hit=True, + must_hit=must_hit, assert_type="sometimes", display_type="Sometimes", assert_id=msg, @@ -680,6 +734,9 @@ def tx_result(name: str, result: dict) -> None: if le_version is not None: details["vault_le_version"] = le_version send_event(f"workload::result : {name}", details) + _assert_vault_v1_1_signals( + name, tx_json, meta, engine_result, le_version, str(details.get("hash", "")) + ) assert_raw( condition=engine_result not in _RIPPLED_INTERNAL_ERRORS, From aca0cbbbca274b1d5e053ef2af05b1e5d4487e6a Mon Sep 17 00:00:00 2001 From: Manasi Patel Date: Tue, 8 Sep 2026 10:53:53 -0700 Subject: [PATCH 4/5] Clean up LendingProtocolV1_1 workload paths --- prepare-workload/generate_genesis.py | 5 +- workload/src/workload/assertions.py | 36 +++++---- workload/src/workload/lending_v1_1_compat.py | 11 ++- workload/src/workload/transactions/vaults.py | 82 +++++++++----------- 4 files changed, 66 insertions(+), 68 deletions(-) diff --git a/prepare-workload/generate_genesis.py b/prepare-workload/generate_genesis.py index 1fd08ce..db8e902 100644 --- a/prepare-workload/generate_genesis.py +++ b/prepare-workload/generate_genesis.py @@ -51,10 +51,11 @@ def parse_features_macro(macro_path: Path) -> list[str]: # rippled renamed Supported::yes/no to Supported::Yes/No in PR #6571 # (clang-tidy readability check). Match both cases. - for m in re.finditer(r"XRPL_FEATURE\s*\(\s*(\w+)\s*,\s*Supported::(?:[Yy]es|[Nn]o)\s*,", text): + supported = r"Supported::(?:[Yy]es|[Nn]o)" + for m in re.finditer(rf"XRPL_FEATURE\s*\(\s*(\w+)\s*,\s*{supported}\s*,", text): amendments.append(m.group(1)) - for m in re.finditer(r"XRPL_FIX\s*\(\s*(\w+)\s*,\s*Supported::(?:[Yy]es|[Nn]o)\s*,", text): + for m in re.finditer(rf"XRPL_FIX\s*\(\s*(\w+)\s*,\s*{supported}\s*,", text): amendments.append("fix" + m.group(1)) return sorted(amendments) diff --git a/workload/src/workload/assertions.py b/workload/src/workload/assertions.py index ec897ad..81dde34 100644 --- a/workload/src/workload/assertions.py +++ b/workload/src/workload/assertions.py @@ -280,38 +280,42 @@ def _balance_changes(meta: dict) -> tuple[list[dict[str, object]], bool]: return changes[:_MAX_BALANCE_CHANGES], len(changes) > _MAX_BALANCE_CHANGES +def _vault_fields(meta: dict, node_kinds: tuple[str, ...]) -> dict | None: + """Fields from the first affected Vault node of one of ``node_kinds``.""" + for node in meta.get("AffectedNodes", []): + for kind in node_kinds: + affected = node.get(kind) + if not isinstance(affected, dict) or affected.get("LedgerEntryType") != "Vault": + continue + fields = affected.get("FinalFields") or affected.get("NewFields") or {} + return fields if isinstance(fields, dict) else {} + return None + + def _vault_le_version(meta: dict) -> str | None: """Vault.LEVersion (XLS-65 3.1.2.2, LendingProtocolV1_1) decides which accounting a Vault follows -- absent/0 legacy accrual-basis, 1 principal-only cash-basis -- and both coexist after activation, so a balance_changes row is unattributable without it. It is protocol-written and never a transaction field, so the meta node is the only source.""" - for node in meta.get("AffectedNodes", []): - for kind in ("ModifiedNode", "CreatedNode", "DeletedNode"): - n = node.get(kind) - if not isinstance(n, dict) or n.get("LedgerEntryType") != "Vault": - continue - fields = n.get("FinalFields") or n.get("NewFields") or {} - return str(fields.get("LEVersion", 0)) - return None + fields = _vault_fields(meta, ("ModifiedNode", "CreatedNode", "DeletedNode")) + return None if fields is None else str(fields.get("LEVersion", 0)) def _created_vault_kind(meta: dict) -> str | None: """VaultKind off a created Vault node. Absent means open-ended, so a None here is a legacy vault and a "0" is an explicit open-ended V1.1 one.""" - for node in meta.get("AffectedNodes", []): - created = node.get("CreatedNode") - if isinstance(created, dict) and created.get("LedgerEntryType") == "Vault": - fields = created.get("NewFields") or {} - kind = fields.get("VaultKind") - return None if kind is None else str(kind) - return None + fields = _vault_fields(meta, ("CreatedNode",)) + if fields is None: + return None + kind = fields.get("VaultKind") + return None if kind is None else str(kind) def _assert_vault_v1_1_signals( name: str, tx_json: dict, meta: dict, engine_result: str, le_version: str | None, tx_hash: str ) -> None: """Reachability for the XLS-65 closed-ended paths. Every one of them is gated - on lending_v1_1_compat.enabled(), so without these buckets the whole feature + on validated amendment state, so without these buckets the whole feature can go dark while VaultCreate's success/failure dims stay satisfied off the open-ended vectors. must_hit=False throughout: they only fire against an xrpld with LendingProtocolV1_1 active.""" diff --git a/workload/src/workload/lending_v1_1_compat.py b/workload/src/workload/lending_v1_1_compat.py index 526389a..3028165 100644 --- a/workload/src/workload/lending_v1_1_compat.py +++ b/workload/src/workload/lending_v1_1_compat.py @@ -3,9 +3,9 @@ PR #587 adds three VaultCreate transaction fields -- sfVaultKind (UInt8, nth 22), sfSubscriptionDate (UInt32, nth 75) and sfRedemptionDate (UInt32, nth 76) -- plus a Vault ledger field sfLEVersion (UInt8, nth 6). The pinned xrpl-py -branch carries neither the codec definitions nor the model fields, so this +revision carries neither the codec definitions nor the model fields, so this module injects the field headers into the live binarycodec maps and extends the -model. TEMPORARY -- delete once xrpl-py's pre-3.3-release-group catches up, +model. TEMPORARY -- delete once xrpl-py's main branch carries PR #1034, then revert imports to xrpl.models. sfLEVersion needs no codec entry: it is protocol-written, never a transaction @@ -78,10 +78,9 @@ class VaultPhase(IntEnum): REDEMPTION = 3 -# Bounds on RedemptionDate - SubscriptionDate that VaultCreate preflight -# enforces (rippled Protocol.h kMin/kMaxInvestmentPeriod): min <= gap < max. -MIN_INVESTMENT_PERIOD = 60 -MAX_INVESTMENT_PERIOD = 946_708_560 +# Lower bound on RedemptionDate - SubscriptionDate enforced by VaultCreate +# preflight (rippled Protocol.h kMinInvestmentPeriod). +MIN_INVESTMENT_PERIOD = 180 def vault_phase( diff --git a/workload/src/workload/transactions/vaults.py b/workload/src/workload/transactions/vaults.py index 59f98ea..fec8445 100644 --- a/workload/src/workload/transactions/vaults.py +++ b/workload/src/workload/transactions/vaults.py @@ -98,6 +98,28 @@ def _random_asset( return xrpl.models.XRP() +def _new_vault_create( + account: str, + asset: IssuedCurrency | MPTCurrency | xrpl.models.XRP, + *, + assets_maximum: str | None = None, + data: str | None = None, + vault_kind: int | None = None, + subscription_date: int | None = None, + redemption_date: int | None = None, +) -> VaultCreate: + """Build a VaultCreate while keeping common randomized fields in one place.""" + return VaultCreate( + account=account, + asset=asset, + assets_maximum=assets_maximum or params.vault_assets_maximum(), + data=data or params.vault_data(), + vault_kind=vault_kind, + subscription_date=subscription_date, + redemption_date=redemption_date, + ) + + async def vault_create( accounts: dict[str, UserAccount], vaults: list[Vault], @@ -121,22 +143,15 @@ def _vault_create_base( asset = _random_asset(trust_lines, mpt_issuances) if lv.enabled() and params.should_create_closed_ended_vault(): sub, red = params.closed_ended_dates() - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), + txn = _new_vault_create( + src.address, + asset, vault_kind=int(lv.VaultKind.CLOSED_ENDED), subscription_date=sub, redemption_date=red, ) else: - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), - ) + txn = _new_vault_create(src.address, asset) return txn, src.wallet @@ -179,60 +194,39 @@ async def _vault_create_faulty( await submit_fuzzed("VaultCreate", base, client, wallet) return if mutation == "zero_max": - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum="0", - data=params.vault_data(), - ) + txn = _new_vault_create(src.address, asset, assets_maximum="0") elif mutation == "oversized_data": oversized = bytes(randint(0, 255) for _ in range(513)).hex() - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=oversized, - ) + txn = _new_vault_create(src.address, asset, data=oversized) elif mutation == "xrp_with_issuer": bad_asset = IssuedCurrency( currency="XRP", issuer=choice(list(accounts.values())).address, ) - txn = VaultCreate( - account=src.address, - asset=bad_asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), - ) + txn = _new_vault_create(src.address, bad_asset) elif mutation == "short_gap": sub, red = params.closed_ended_short_gap() - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), + txn = _new_vault_create( + src.address, + asset, vault_kind=int(lv.VaultKind.CLOSED_ENDED), subscription_date=sub, redemption_date=red, ) elif mutation == "expired_dates": sub, red = params.closed_ended_expired_dates() - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), + txn = _new_vault_create( + src.address, + asset, vault_kind=int(lv.VaultKind.CLOSED_ENDED), subscription_date=sub, redemption_date=red, ) else: # invalid_kind — dates stay well-formed so the kind is the only fault sub, red = params.closed_ended_dates() - txn = VaultCreate( - account=src.address, - asset=asset, - assets_maximum=params.vault_assets_maximum(), - data=params.vault_data(), + txn = _new_vault_create( + src.address, + asset, vault_kind=params.invalid_vault_kind(), subscription_date=sub, redemption_date=red, From 0e111fcadb93e270ad1222f994836b43a59d1392 Mon Sep 17 00:00:00 2001 From: Manasi Patel Date: Tue, 8 Sep 2026 11:07:25 -0700 Subject: [PATCH 5/5] Reduce LendingProtocolV1_1 implementation noise --- CLAUDE.md | 2 +- prepare-workload/generate_genesis.py | 10 +- workload/src/workload/assertions.py | 94 ++----------------- workload/src/workload/lending_v1_1_compat.py | 57 ++--------- workload/src/workload/models.py | 3 - workload/src/workload/params.py | 37 +------- .../src/workload/transactions/__init__.py | 2 - workload/src/workload/transactions/vaults.py | 59 ++++-------- 8 files changed, 37 insertions(+), 227 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 45560b7..60b13c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Prevents `tefPAST_SEQ` cascades in setup: lazy-fetch each account's sequence, th Three-stage lifecycle so a run can be reconstructed from events alone (rippled logs only at WRN under Antithesis; asserts carry empty `details`): - `tx_submitting(name, body)` → `workload::submitted : {TxType}`, emitted **before** the submit RPC so the body lands on the branch at/before any apply-time assert (no vtime-nudge needed). Carries account/sequence/tx_type + object IDs + `tx` = the **full signed body** (redacted of `TxnSignature`/`SigningPubKey` and inner Signers/BatchSigners sigs via `_redact_tx`). Pass the FINAL signed/co-signed tx (post-autofill, post-cosign) so Sequence/Fee/co-sign signers are captured. Fires the `seen` reachability assert. - `tx_submitted(name, body, result)` → post-submit; runs the submit-time `no_internal_rippled_error_submit` + sponsor-signal checks against the tentative response. No event of its own (tentative engine_result lives in those asserts' details); the `seen` assert moved to `tx_submitting`. -- `tx_result()` → `workload::result : {TxType}`, from the validated WS msg. Carries account/sequence/tx_type + object IDs + `created_id`/`created_type`, `deleted_id`/`deleted_type`, `delivered_amount`, and `balance_changes` — a per-entry `[{entry,id,field,account,before,after}]` list from meta `AffectedNodes` (AccountRoot/RippleState/MPToken/MPTokenIssuance, plus Vault `AssetsTotal`/`AssetsAvailable`/`LossUnrealized` + LoanBroker `DebtTotal` for the XLS-66 `LendingProtocolV1_1` principal-only accounting — PR #582; an entry that moves several tracked fields emits one row per changed field, tagged by `field`; values faithful — XRP drops as strings, IOU/MPT as amount objects; capped at 25 with `balance_changes_truncated`). This is the on-ledger effect conservation/rounding failures turn on. Any result touching a `Vault` node also carries `vault_le_version` — `Vault.LEVersion` (XLS-65 §3.1.2.2), which is what actually gates the amended accounting: `0`/absent = legacy accrual-basis (`AssetsTotal` includes interest), `1` = cash-basis principal-only. Both coexist post-activation (a pre-amendment Vault never migrates), so the balance rows are unattributable without it; it's protocol-written and never a tx field, so meta is the only source. +- `tx_result()` → `workload::result : {TxType}`, from the validated WS msg. Carries account/sequence/tx_type, object IDs, delivered amount, and per-field `balance_changes` from meta (capped at 25). Tracked lending fields are Vault `AssetsTotal`/`AssetsAvailable`/`LossUnrealized` and LoanBroker `DebtTotal`. Results touching a Vault also include `vault_le_version` to distinguish legacy accrual-basis from V1.1 principal-only accounting. Every submit path must call `tx_submitting` before the RPC and `tx_submitted` after: `submit_tx`/`submit_raw` (`submit.py`) and the three co-sign paths (`lending.py` LoanSet, `sponsorship.py` ×2). Inner batch txns (`tfInnerBatchTxn`) also emit `workload::inner_batch_observed`; normal `tx_result()` still runs. diff --git a/prepare-workload/generate_genesis.py b/prepare-workload/generate_genesis.py index db8e902..c541c19 100644 --- a/prepare-workload/generate_genesis.py +++ b/prepare-workload/generate_genesis.py @@ -33,15 +33,7 @@ def sha512half(name: str) -> str: def parse_features_macro(macro_path: Path) -> list[str]: - """Extract amendment names from rippled's features.macro. - - Includes Supported::No amendments — Dockerfile.xrpld rewrites them to - Supported::Yes before building, so the binary knows them and they need - testing too (e.g. LendingProtocolV1_1). VoteBehavior::Obsolete is skipped: - enabling one leaves the node amendment-blocked. - - Excludes retired amendments and comment examples. - """ + """Extract non-obsolete amendments, including Supported::No entries.""" text = "\n".join( line for line in macro_path.read_text().splitlines() diff --git a/workload/src/workload/assertions.py b/workload/src/workload/assertions.py index 81dde34..88c9a74 100644 --- a/workload/src/workload/assertions.py +++ b/workload/src/workload/assertions.py @@ -218,13 +218,7 @@ def scrub(o: object) -> object: return {k: scrub(v) for k, v in raw.items() if k not in _SIG_FIELDS} -# Ledger entries whose balance movement matters for conservation/rounding analysis. -# Each entry type lists every field worth tracking (an entry -- e.g. a Vault -- can -# move several). Vault/LoanBroker cover the lending totals: under LendingProtocolV1_1 -# (XLS-66, PR #582) AssetsTotal/DebtTotal move by principal only (no interest), LoanPay -# routes interest through AssetsTotal, and LoanManage impair/default moves LossUnrealized -# -- so capturing all three Vault fields plus DebtTotal lets a run observe the amended -# accounting across LoanSet/LoanPay/LoanManage instead of it being invisible. +# Fields used for conservation and lending-accounting analysis. _BALANCE_FIELDS: dict[str, tuple[str, ...]] = { "AccountRoot": ("Balance",), "RippleState": ("Balance",), @@ -238,9 +232,7 @@ def scrub(o: object) -> object: def _balance_changes(meta: dict) -> tuple[list[dict[str, object]], bool]: """Per-entry balance before/after from meta AffectedNodes. Values stay faithful - (XRP drops as strings; IOU/MPT as amount objects) so the reader does the math. - This is the on-ledger effect that conservation/rounding failures turn on. An entry - can move several tracked fields, so each changed field emits its own tagged row.""" + (XRP drops as strings; IOU/MPT as amount objects); one row per changed field.""" changes: list[dict[str, object]] = [] for node in meta.get("AffectedNodes", []): for kind in ("ModifiedNode", "CreatedNode", "DeletedNode"): @@ -252,12 +244,11 @@ def _balance_changes(meta: dict) -> tuple[list[dict[str, object]], bool]: continue final = n.get("FinalFields") or n.get("NewFields") or {} prev = n.get("PreviousFields") or {} - # Vault/LoanBroker key on Owner, not Account. account = final.get("Account") or final.get("Owner", "") for field in fields: if kind == "CreatedNode": if field not in final: - continue # entry doesn't carry this field on create + continue before, after = None, final.get(field) elif kind == "DeletedNode": if field not in final: @@ -265,7 +256,7 @@ def _balance_changes(meta: dict) -> tuple[list[dict[str, object]], bool]: before, after = final.get(field), None else: if field not in prev: - continue # this modification didn't touch the field + continue before, after = prev.get(field), final.get(field) changes.append( { @@ -280,64 +271,17 @@ def _balance_changes(meta: dict) -> tuple[list[dict[str, object]], bool]: return changes[:_MAX_BALANCE_CHANGES], len(changes) > _MAX_BALANCE_CHANGES -def _vault_fields(meta: dict, node_kinds: tuple[str, ...]) -> dict | None: - """Fields from the first affected Vault node of one of ``node_kinds``.""" +def _vault_le_version(meta: dict) -> str | None: for node in meta.get("AffectedNodes", []): - for kind in node_kinds: + for kind in ("ModifiedNode", "CreatedNode", "DeletedNode"): affected = node.get(kind) if not isinstance(affected, dict) or affected.get("LedgerEntryType") != "Vault": continue fields = affected.get("FinalFields") or affected.get("NewFields") or {} - return fields if isinstance(fields, dict) else {} + return str(fields.get("LEVersion", 0)) return None -def _vault_le_version(meta: dict) -> str | None: - """Vault.LEVersion (XLS-65 3.1.2.2, LendingProtocolV1_1) decides which accounting a - Vault follows -- absent/0 legacy accrual-basis, 1 principal-only cash-basis -- and both - coexist after activation, so a balance_changes row is unattributable without it. It is - protocol-written and never a transaction field, so the meta node is the only source.""" - fields = _vault_fields(meta, ("ModifiedNode", "CreatedNode", "DeletedNode")) - return None if fields is None else str(fields.get("LEVersion", 0)) - - -def _created_vault_kind(meta: dict) -> str | None: - """VaultKind off a created Vault node. Absent means open-ended, so a None - here is a legacy vault and a "0" is an explicit open-ended V1.1 one.""" - fields = _vault_fields(meta, ("CreatedNode",)) - if fields is None: - return None - kind = fields.get("VaultKind") - return None if kind is None else str(kind) - - -def _assert_vault_v1_1_signals( - name: str, tx_json: dict, meta: dict, engine_result: str, le_version: str | None, tx_hash: str -) -> None: - """Reachability for the XLS-65 closed-ended paths. Every one of them is gated - on validated amendment state, so without these buckets the whole feature - can go dark while VaultCreate's success/failure dims stay satisfied off the - open-ended vectors. must_hit=False throughout: they only fire against an - xrpld with LendingProtocolV1_1 active.""" - details = {"tx_type": name, "engine_result": engine_result, "hash": tx_hash} - if le_version is not None and le_version not in ("0", ""): - _fire_sometimes("lending_v1_1_active", True, details, must_hit=False) - if name == "VaultCreate" and engine_result == "tesSUCCESS": - kind = _created_vault_kind(meta) - if kind is not None and kind != "0": - _fire_sometimes( - "vault_closed_ended_created", True, {**details, "vault_kind": kind}, must_hit=False - ) - # The phase gates: Investment/Redemption shuts deposits, Investment locks - # withdraws. Both are tec, so they validate and reach this stream. - if name == "VaultDeposit" and engine_result == "tecEXPIRED": - _fire_sometimes("vault_deposit_phase_blocked", True, details, must_hit=False) - if name == "VaultWithdraw" and engine_result == "tecTOO_SOON": - _fire_sometimes("vault_withdraw_phase_blocked", True, details, must_hit=False) - if name == "VaultDelete" and engine_result == "tesSUCCESS" and tx_json.get("MemoData"): - _fire_sometimes("vault_delete_reason_used", True, details, must_hit=False) - - def _emit_catalog_entry(message: str, assert_type: str, display_type: str, must_hit: bool) -> None: """hit=False registers existence with Antithesis without claiming a hit.""" assert_raw( @@ -441,18 +385,6 @@ def register_assertions() -> None: "sponsorship_audit_account_consistent", ): _emit_catalog_entry(f"workload::sometimes : {key}", "sometimes", "Sometimes", must_hit=True) - # XLS-65 closed-ended vaults. must_hit=False: these only fire against an xrpld - # with LendingProtocolV1_1 active, so a run without it must not starve. - for vault_key in ( - "lending_v1_1_active", - "vault_closed_ended_created", - "vault_deposit_phase_blocked", - "vault_withdraw_phase_blocked", - "vault_delete_reason_used", - ): - _emit_catalog_entry( - f"workload::sometimes : {vault_key}", "sometimes", "Sometimes", must_hit=False - ) for setup_key in [ "gateways", "trust_lines", @@ -505,12 +437,9 @@ def assert_no_internal_error_submit(name: str, result: dict) -> None: ) -def _fire_sometimes( - key: str, condition: bool, details: dict[str, str], must_hit: bool = True -) -> None: +def _fire_sometimes(key: str, condition: bool, details: dict[str, str]) -> None: """Shared plumbing for the sponsor-state reachability signals below -- - ``sometimes`` only needs one True hit, so a False call is a harmless no-op. - ``must_hit`` must match the key's register_assertions() catalog entry.""" + ``sometimes`` only needs one True hit, so a False call is a harmless no-op.""" msg = f"workload::sometimes : {key}" assert_raw( condition=condition, @@ -522,7 +451,7 @@ def _fire_sometimes( loc_begin_line=0, loc_begin_column=_LOC_COL, hit=True, - must_hit=must_hit, + must_hit=True, assert_type="sometimes", display_type="Sometimes", assert_id=msg, @@ -738,9 +667,6 @@ def tx_result(name: str, result: dict) -> None: if le_version is not None: details["vault_le_version"] = le_version send_event(f"workload::result : {name}", details) - _assert_vault_v1_1_signals( - name, tx_json, meta, engine_result, le_version, str(details.get("hash", "")) - ) assert_raw( condition=engine_result not in _RIPPLED_INTERNAL_ERRORS, diff --git a/workload/src/workload/lending_v1_1_compat.py b/workload/src/workload/lending_v1_1_compat.py index 3028165..163c4e0 100644 --- a/workload/src/workload/lending_v1_1_compat.py +++ b/workload/src/workload/lending_v1_1_compat.py @@ -1,26 +1,4 @@ -"""Closed-ended Vault compat for LendingProtocolV1_1 (XLS-65, rippled PR #587). - -PR #587 adds three VaultCreate transaction fields -- sfVaultKind (UInt8, nth -22), sfSubscriptionDate (UInt32, nth 75) and sfRedemptionDate (UInt32, nth 76) --- plus a Vault ledger field sfLEVersion (UInt8, nth 6). The pinned xrpl-py -revision carries neither the codec definitions nor the model fields, so this -module injects the field headers into the live binarycodec maps and extends the -model. TEMPORARY -- delete once xrpl-py's main branch carries PR #1034, -then revert imports to xrpl.models. - -sfLEVersion needs no codec entry: it is protocol-written, never a transaction -field, and reaches the workload only as JSON metadata. VaultDelete's optional -sfMemoData deletion reason needs no codec entry either -- MemoData is a -long-standing Blob field -- only the model extension below. - -AMENDMENT GATING. featureLendingProtocolV1_1 is Supported::No on rippled -develop; generate_genesis.py now enables it anyway (matching Dockerfile.xrpld's -Supported::No rewrite), but a run against a node without it would get -temDISABLED on any of the three fields. So every closed-ended path is gated on -enabled(), which flips only after a validated Vault came back carrying -LEVersion >= 1 (rippled stamps it on every VaultCreate once the amendment is -active, so the setup vault phase settles this before any driver runs). -""" +"""Temporary xrpl-py compatibility for LendingProtocolV1_1 (PR #1034).""" from __future__ import annotations @@ -38,10 +16,9 @@ def _register_field(name: str, type_name: str, nth: int) -> None: if name in _defs._FIELD_INFO_MAP: - return # xrpl-py caught up -- keep its definition, this shim is now dead + return header = FieldHeader(_defs._TYPE_ORDINAL_MAP[type_name], nth) if header in _defs._FIELD_HEADER_NAME_MAP: - # A silent collision would decode as the wrong field on every response. raise RuntimeError( f"field header {type_name}/{nth} already taken by" f" {_defs._FIELD_HEADER_NAME_MAP[header]}" @@ -63,23 +40,18 @@ def _register_field(name: str, type_name: str, nth: int) -> None: class VaultKind(IntEnum): - """rippled's VaultKind (Protocol.h); absent sfVaultKind means OpenEnded.""" - OPEN_ENDED = 0 CLOSED_ENDED = 1 class VaultPhase(IntEnum): - """Lifecycle phase of a Vault. Open-ended vaults are always NO_PHASE.""" - NO_PHASE = 0 SUBSCRIPTION = 1 INVESTMENT = 2 REDEMPTION = 3 -# Lower bound on RedemptionDate - SubscriptionDate enforced by VaultCreate -# preflight (rippled Protocol.h kMinInvestmentPeriod). +# rippled Protocol.h kMinInvestmentPeriod. MIN_INVESTMENT_PERIOD = 180 @@ -89,9 +61,6 @@ def vault_phase( redemption_date: int | None, now: int, ) -> VaultPhase: - """rippled's getVaultPhase (VaultHelpers.cpp) over tracked Vault state. - Subscription includes now == SubscriptionDate; Investment starts strictly - after it and runs through RedemptionDate.""" if ( int(kind or 0) != VaultKind.CLOSED_ENDED or subscription_date is None @@ -107,33 +76,23 @@ def vault_phase( @dataclass(frozen=True, kw_only=True) class VaultCreate(_UpstreamVaultCreate): - """Upstream model plus the PR #587 closed-ended fields. Validation stays - server-side so faulty handlers can build the malformed combinations.""" + """VaultCreate with server-validated closed-ended fields.""" # Optional[...] not `| None`: xrpl-py's BaseModel._check_type introspects the # annotation at construction and crashes on a PEP 604 UnionType. vault_kind: Optional[int] = None # noqa: UP045 - """0 open-ended (default when absent), 1 closed-ended.""" - subscription_date: Optional[int] = None # noqa: UP045 - """Ripple-epoch second the Subscription phase ends. Closed-ended only.""" - redemption_date: Optional[int] = None # noqa: UP045 - """Ripple-epoch second the Investment phase ends. Closed-ended only.""" @dataclass(frozen=True, kw_only=True) class VaultDelete(_UpstreamVaultDelete): - """Upstream model plus the V1.1 sfMemoData deletion reason. Length stays - unvalidated so faulty handlers can build the out-of-range cases.""" + """VaultDelete with server-validated MemoData.""" memo_data: Optional[str] = None # noqa: UP045 - """Hex deletion reason, 1-256 bytes (rippled kMaxDataPayloadLength).""" -# autofill/sign round-trip every tx through Transaction.from_dict, which -# resolves the class by live getattr on this module namespace -- without this -# rebind it lands on the upstream class and rejects the new kwargs. +# Transaction.from_dict resolves these classes from the live module namespace. _models.VaultCreate = VaultCreate _models.VaultDelete = VaultDelete @@ -142,14 +101,10 @@ class VaultDelete(_UpstreamVaultDelete): def enabled() -> bool: - """True once a validated Vault proved featureLendingProtocolV1_1 is active.""" return _enabled def note_vault_le_version(le_version: int | str | None) -> None: - """Latch the amendment from a created Vault's LEVersion. Called from the - VaultCreate state updater, so the flag is set by a validated ledger entry - rather than an amendment-table read the public port may not serve.""" global _enabled if _enabled: return diff --git a/workload/src/workload/models.py b/workload/src/workload/models.py index ac087f2..b59a588 100644 --- a/workload/src/workload/models.py +++ b/workload/src/workload/models.py @@ -106,9 +106,6 @@ class Vault: asset: IssuedCurrency | MPTCurrency | xrpl.models.XRP | None = None balance: int = 0 shareholders: set[str] = field(default_factory=set) - # XLS-65 closed-ended vaults; dates are ripple-epoch seconds and are None on - # open-ended vaults. Read from the created node so handlers can pick a vault - # whose current phase permits the transaction they are about to build. vault_kind: int = 0 subscription_date: int | None = None redemption_date: int | None = None diff --git a/workload/src/workload/params.py b/workload/src/workload/params.py index c2032ed..8e76a94 100644 --- a/workload/src/workload/params.py +++ b/workload/src/workload/params.py @@ -128,9 +128,8 @@ def vault_withdraw_amount() -> str: return str(randint(100_000, 50_000_000)) -def vault_data() -> str: - """Max 256 bytes per spec.""" - length = randint(1, 256) +def vault_data(min_length: int = 1, max_length: int = 256) -> str: + length = randint(min_length, max_length) return bytes(randint(0, 255) for _ in range(length)).hex() @@ -140,16 +139,10 @@ def vault_assets_maximum() -> str: # ── Closed-ended Vaults (XLS-65, LendingProtocolV1_1) ──────────────── def should_create_closed_ended_vault() -> bool: - """A minority, so open-ended vaults keep the phase-free deposit/withdraw - valid paths well stocked.""" return random() < 0.25 def closed_ended_dates() -> tuple[int, int]: - """(SubscriptionDate, RedemptionDate) satisfying preflight's gap bound and - preclaim's non-expiry check. Two flavors: a long subscription window keeps - the vault depositable for the whole run, a short one walks it through - Subscription -> Investment -> Redemption so the phase gates get exercised.""" if random() < 0.3: sub = _ripple_now() + randint(5, 30) gap = randint(_lv.MIN_INVESTMENT_PERIOD, _lv.MIN_INVESTMENT_PERIOD + 120) @@ -160,41 +153,15 @@ def closed_ended_dates() -> tuple[int, int]: def closed_ended_short_gap() -> tuple[int, int]: - """Gap below kMinInvestmentPeriod → temMALFORMED.""" sub = _ripple_now() + randint(60, 600) return sub, sub + randint(0, _lv.MIN_INVESTMENT_PERIOD - 1) def closed_ended_expired_dates() -> tuple[int, int]: - """Both dates already past → preclaim tecEXPIRED (a tec, so it validates - and feeds the failure bucket, unlike the tem malformations).""" sub = _ripple_now() - randint(3600, 86_400) return sub, sub + randint(_lv.MIN_INVESTMENT_PERIOD, 3600) -def invalid_vault_kind() -> int: - """Outside rippled's VaultKind enum → temMALFORMED.""" - return randint(2, 255) - - -def should_attach_delete_memo() -> bool: - """VaultDelete's deletion reason is optional; leave it off often enough that - the field-absent path stays covered.""" - return random() < 0.5 - - -def vault_delete_memo_data() -> str: - """1-256 bytes, the range VaultDelete preflight accepts.""" - length = randint(1, 256) - return bytes(randint(0, 255) for _ in range(length)).hex() - - -def oversized_vault_delete_memo_data() -> str: - """Over kMaxDataPayloadLength → temMALFORMED.""" - length = randint(257, 512) - return bytes(randint(0, 255) for _ in range(length)).hex() - - # ── Permissioned Domains ───────────────────────────────────────────── def domain_credential_count() -> int: """1-10 accepted credentials per domain.""" diff --git a/workload/src/workload/transactions/__init__.py b/workload/src/workload/transactions/__init__.py index 1738c59..0a5dbda 100644 --- a/workload/src/workload/transactions/__init__.py +++ b/workload/src/workload/transactions/__init__.py @@ -207,8 +207,6 @@ def _on_vault_create(w: Workload, tx: dict, meta: dict) -> None: vault_id = _extract_created_id(meta, "Vault") if vault_id: asset = _parse_asset(tx.get("Asset", {})) - # LEVersion / VaultKind / the phase dates are protocol-written, so the - # created node is the only source -- and LEVersion latches the amendment. fields = _created_vault_fields(meta) lending_v1_1_compat.note_vault_le_version(fields.get("LEVersion")) sub = fields.get("SubscriptionDate") diff --git a/workload/src/workload/transactions/vaults.py b/workload/src/workload/transactions/vaults.py index fec8445..d782ac0 100644 --- a/workload/src/workload/transactions/vaults.py +++ b/workload/src/workload/transactions/vaults.py @@ -17,19 +17,13 @@ from workload import lending_v1_1_compat as lv from workload import params from workload.fuzz import submit_fuzzed - -# VaultCreate and VaultDelete ride the compat shim until xrpl-py carries the -# XLS-65 closed-ended fields and the V1.1 MemoData; see lending_v1_1_compat. from workload.lending_v1_1_compat import VaultCreate, VaultDelete from workload.models import MPTokenIssuance, TrustLine, UserAccount, Vault from workload.randoms import choice, randint, random from workload.submit import submit_tx -# Ledger close time trails wall clock, so a vault whose phase would change -# within this many seconds is treated as being in both phases. +# Account for ledger-close lag around phase boundaries. _PHASE_SKEW = 60 -# Phases in which rippled rejects the transaction (VaultDeposit tecEXPIRED, -# VaultWithdraw tecTOO_SOON). _DEPOSIT_BLOCKED = frozenset({lv.VaultPhase.INVESTMENT, lv.VaultPhase.REDEMPTION}) _WITHDRAW_BLOCKED = frozenset({lv.VaultPhase.INVESTMENT}) @@ -42,21 +36,15 @@ def _phases_around(vault: Vault) -> set[lv.VaultPhase]: } -def _phase_permits(vault: Vault, blocked: frozenset[lv.VaultPhase]) -> bool: - return not (_phases_around(vault) & blocked) - - -def _phase_blocks(vault: Vault, blocked: frozenset[lv.VaultPhase]) -> bool: - return _phases_around(vault) <= blocked - - -def _pick_permitted_vault(vaults: list[Vault], blocked: frozenset[lv.VaultPhase]) -> Vault | None: - eligible = [v for v in vaults if _phase_permits(v, blocked)] - return choice(eligible) if eligible else None - - -def _pick_blocked_vault(vaults: list[Vault], blocked: frozenset[lv.VaultPhase]) -> Vault | None: - eligible = [v for v in vaults if _phase_blocks(v, blocked)] +def _pick_vault( + vaults: list[Vault], blocked: frozenset[lv.VaultPhase], *, require_blocked: bool = False +) -> Vault | None: + eligible = [] + for vault in vaults: + phases = _phases_around(vault) + matches = phases <= blocked if require_blocked else not phases & blocked + if matches: + eligible.append(vault) return choice(eligible) if eligible else None @@ -108,7 +96,6 @@ def _new_vault_create( subscription_date: int | None = None, redemption_date: int | None = None, ) -> VaultCreate: - """Build a VaultCreate while keeping common randomized fields in one place.""" return VaultCreate( account=account, asset=asset, @@ -182,8 +169,6 @@ async def _vault_create_faulty( asset = _random_asset(trust_lines, mpt_issuances) mutations = ["fuzz", "zero_max", "oversized_data", "xrp_with_issuer"] if lv.enabled(): - # temDISABLED without the amendment, so these only join once a validated - # Vault proved it active. mutations += ["short_gap", "expired_dates", "invalid_kind"] mutation = choice(mutations) if mutation == "fuzz": @@ -227,7 +212,7 @@ async def _vault_create_faulty( txn = _new_vault_create( src.address, asset, - vault_kind=params.invalid_vault_kind(), + vault_kind=randint(2, 255), subscription_date=sub, redemption_date=red, ) @@ -250,7 +235,7 @@ def _vault_deposit_base( ) -> tuple[VaultDeposit, Wallet] | None: if not vaults or not accounts: return None - vault = _pick_permitted_vault(vaults, _DEPOSIT_BLOCKED) + vault = _pick_vault(vaults, _DEPOSIT_BLOCKED) if vault is None: return None depositor = accounts[choice(list(accounts))] @@ -321,7 +306,7 @@ async def _vault_deposit_faulty( amount=amount, ) else: # closed_phase — subscription window shut, so tecEXPIRED - closed = _pick_blocked_vault(vaults, _DEPOSIT_BLOCKED) + closed = _pick_vault(vaults, _DEPOSIT_BLOCKED, require_blocked=True) if closed is None: return txn = VaultDeposit( @@ -361,7 +346,7 @@ def _vault_withdraw_base( ) -> tuple[VaultWithdraw, Wallet] | None: if not vaults: return None - vault = _pick_permitted_vault(vaults, _WITHDRAW_BLOCKED) + vault = _pick_vault(vaults, _WITHDRAW_BLOCKED) if vault is None or vault.owner not in accounts: return None owner = accounts[vault.owner] @@ -394,8 +379,7 @@ async def _vault_withdraw_faulty( mutations.append("closed_phase") mutation = choice(mutations) if mutation == "closed_phase": - # Investment phase locks the vault, so a withdraw draws tecTOO_SOON. - locked = _pick_blocked_vault(vaults, _WITHDRAW_BLOCKED) + locked = _pick_vault(vaults, _WITHDRAW_BLOCKED, require_blocked=True) if locked is None or locked.owner not in accounts: return owner = accounts[locked.owner] @@ -551,13 +535,7 @@ def _vault_delete_base( if vault.owner not in accounts: return None owner = accounts[vault.owner] - # MemoData is temDISABLED without the amendment, so it only joins once a - # validated Vault proved it active. - memo = ( - params.vault_delete_memo_data() - if lv.enabled() and params.should_attach_delete_memo() - else None - ) + memo = params.vault_data() if lv.enabled() and random() < 0.5 else None txn = VaultDelete( account=owner.address, vault_id=vault.vault_id, @@ -594,16 +572,13 @@ async def _vault_delete_faulty( await submit_fuzzed("VaultDelete", base, client, wallet) return if mutation in ("empty_memo", "oversized_memo"): - # validDataLength rejects both an empty and an over-256-byte MemoData. if vault.owner not in accounts: return owner = accounts[vault.owner] txn = VaultDelete( account=owner.address, vault_id=vault.vault_id, - memo_data=( - "" if mutation == "empty_memo" else params.oversized_vault_delete_memo_data() - ), + memo_data=("" if mutation == "empty_memo" else params.vault_data(257, 512)), ) await submit_tx("VaultDelete", txn, client, owner.wallet) return