diff --git a/CLAUDE.md b/CLAUDE.md index a72c706..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,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, 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 b643595..c541c19 100644 --- a/prepare-workload/generate_genesis.py +++ b/prepare-workload/generate_genesis.py @@ -33,19 +33,21 @@ def sha512half(name: str) -> str: def parse_features_macro(macro_path: Path) -> list[str]: - """Extract supported amendment names from rippled's features.macro. - - Excludes retired amendments and comment examples. - """ - text = "\n".join(line for line in macro_path.read_text().splitlines() if not line.lstrip().startswith("//")) + """Extract non-obsolete amendments, including Supported::No entries.""" + 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): + 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\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/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 1afa91a..88c9a74 100644 --- a/workload/src/workload/assertions.py +++ b/workload/src/workload/assertions.py @@ -218,51 +218,70 @@ 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. -_BALANCE_FIELDS = { - "AccountRoot": "Balance", - "RippleState": "Balance", - "MPToken": "MPTAmount", - "MPTokenIssuance": "OutstandingAmount", +# Fields used for conservation and lending-accounting analysis. +_BALANCE_FIELDS: dict[str, tuple[str, ...]] = { + "AccountRoot": ("Balance",), + "RippleState": ("Balance",), + "MPToken": ("MPTAmount",), + "MPTokenIssuance": ("OutstandingAmount",), + "Vault": ("AssetsTotal", "AssetsAvailable", "LossUnrealized"), + "LoanBroker": ("DebtTotal",), } _MAX_BALANCE_CHANGES = 25 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.""" + (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"): 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, - } - ) + account = final.get("Account") or final.get("Owner", "") + for field in fields: + if kind == "CreatedNode": + if field not in final: + continue + 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 + 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: + for node in meta.get("AffectedNodes", []): + 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 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 +663,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( 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..163c4e0 --- /dev/null +++ b/workload/src/workload/lending_v1_1_compat.py @@ -0,0 +1,115 @@ +"""Temporary xrpl-py compatibility for LendingProtocolV1_1 (PR #1034).""" + +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 + header = FieldHeader(_defs._TYPE_ORDINAL_MAP[type_name], nth) + if header in _defs._FIELD_HEADER_NAME_MAP: + 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): + OPEN_ENDED = 0 + CLOSED_ENDED = 1 + + +class VaultPhase(IntEnum): + NO_PHASE = 0 + SUBSCRIPTION = 1 + INVESTMENT = 2 + REDEMPTION = 3 + + +# rippled Protocol.h kMinInvestmentPeriod. +MIN_INVESTMENT_PERIOD = 180 + + +def vault_phase( + kind: int | None, + subscription_date: int | None, + redemption_date: int | None, + now: int, +) -> VaultPhase: + 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): + """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 + subscription_date: Optional[int] = None # noqa: UP045 + redemption_date: Optional[int] = None # noqa: UP045 + + +@dataclass(frozen=True, kw_only=True) +class VaultDelete(_UpstreamVaultDelete): + """VaultDelete with server-validated MemoData.""" + + memo_data: Optional[str] = None # noqa: UP045 + + +# Transaction.from_dict resolves these classes from the live module namespace. +_models.VaultCreate = VaultCreate +_models.VaultDelete = VaultDelete + + +_enabled = False + + +def enabled() -> bool: + return _enabled + + +def note_vault_le_version(le_version: int | str | None) -> None: + 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..b59a588 100644 --- a/workload/src/workload/models.py +++ b/workload/src/workload/models.py @@ -106,6 +106,9 @@ class Vault: asset: IssuedCurrency | MPTCurrency | xrpl.models.XRP | None = None balance: int = 0 shareholders: set[str] = field(default_factory=set) + 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..8e76a94 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 @@ -127,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() @@ -137,6 +137,31 @@ 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: + return random() < 0.25 + + +def closed_ended_dates() -> tuple[int, int]: + 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]: + sub = _ripple_now() + randint(60, 600) + return sub, sub + randint(0, _lv.MIN_INVESTMENT_PERIOD - 1) + + +def closed_ended_expired_dates() -> tuple[int, int]: + sub = _ripple_now() - randint(3600, 86_400) + return sub, sub + randint(_lv.MIN_INVESTMENT_PERIOD, 3600) + + # ── 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..0a5dbda 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,33 @@ 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)) + 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..d782ac0 100644 --- a/workload/src/workload/transactions/vaults.py +++ b/workload/src/workload/transactions/vaults.py @@ -8,20 +8,46 @@ 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 +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 +# Account for ledger-close lag around phase boundaries. +_PHASE_SKEW = 60 +_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 _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 + + # ── Create ─────────────────────────────────────────────────────────── @@ -60,6 +86,27 @@ 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: + 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], @@ -81,12 +128,17 @@ 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 = _new_vault_create( + src.address, + asset, + vault_kind=int(lv.VaultKind.CLOSED_ENDED), + subscription_date=sub, + redemption_date=red, + ) + else: + txn = _new_vault_create(src.address, asset) return txn, src.wallet @@ -115,7 +167,10 @@ 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(): + 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: @@ -124,30 +179,42 @@ 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, - ) - else: # xrp_with_issuer + 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 = _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 = _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 = _new_vault_create( + src.address, + asset, + vault_kind=randint(2, 255), + subscription_date=sub, + redemption_date=red, ) await submit_tx("VaultCreate", txn, client, src.wallet) @@ -168,7 +235,9 @@ def _vault_deposit_base( ) -> tuple[VaultDeposit, Wallet] | None: if not vaults or not accounts: return None - vault = choice(vaults) + vault = _pick_vault(vaults, _DEPOSIT_BLOCKED) + if vault is None: + return None depositor = accounts[choice(list(accounts))] txn = VaultDeposit( account=depositor.address, @@ -194,7 +263,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 +292,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 +305,15 @@ async def _vault_deposit_faulty( vault_id=vault.vault_id, amount=amount, ) + else: # closed_phase — subscription window shut, so tecEXPIRED + closed = _pick_vault(vaults, _DEPOSIT_BLOCKED, require_blocked=True) + 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 +346,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_vault(vaults, _WITHDRAW_BLOCKED) + if vault is None or vault.owner not in accounts: return None owner = accounts[vault.owner] txn = VaultWithdraw( @@ -293,7 +374,22 @@ 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": + locked = _pick_vault(vaults, _WITHDRAW_BLOCKED, require_blocked=True) + 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 +535,11 @@ def _vault_delete_base( if vault.owner not in accounts: return None owner = accounts[vault.owner] + memo = params.vault_data() if lv.enabled() and random() < 0.5 else None txn = VaultDelete( account=owner.address, vault_id=vault.vault_id, + memo_data=memo, ) return txn, owner.wallet @@ -462,7 +560,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 +571,17 @@ async def _vault_delete_faulty( base, wallet = built await submit_fuzzed("VaultDelete", base, client, wallet) return + if mutation in ("empty_memo", "oversized_memo"): + 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.vault_data(257, 512)), + ) + await submit_tx("VaultDelete", txn, client, owner.wallet) + return if mutation == "fake_vault": if vault.owner not in accounts: return