Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 9 additions & 7 deletions prepare-workload/generate_genesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions scripts/check-imports
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 49 additions & 27 deletions workload/src/workload/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
115 changes: 115 additions & 0 deletions workload/src/workload/lending_v1_1_compat.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions workload/src/workload/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions workload/src/workload/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -127,16 +128,40 @@ 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()


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."""
Expand Down
26 changes: 24 additions & 2 deletions workload/src/workload/transactions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Loading