From 4ec741a050fa443fcc6e3152a63fae6dcc1bcdb5 Mon Sep 17 00:00:00 2001 From: Manasi Patel Date: Mon, 31 Aug 2026 08:19:42 -0700 Subject: [PATCH] Pin xrpl-py to main; drop the SponsorshipSet compat shim xrpl-py's main branch now carries every 3.3.0 feature, including the confidential MPT and sponsor models that kept us on pre-3.3-release-group. Move both refs together (pyproject branch + Dockerfile XRPL_PY_REF) so the proofs and the models stay on one ref. Knock-on changes: - sponsorship_compat.py is deleted. SponsorshipSet with FeeAmountDelta / RemainingOwnerCountDelta ships upstream, so the field-header injection shim has no reason to exist. - setup-confidential-crypto.sh gates the mpt-crypto version on a compatibility class rather than string equality. main targets 1.0.4 while both xrpld develop and staging/3.3.x-private pin 1.0.2, and git diff 1.0.2 1.0.4 -- include src is empty, so the pair is listed as equivalent and the crypto build (and the confidential valid paths) stay live. 1.0.5 is deliberately not listed: it adds a BSGS DLP solver. - Faulty vectors that main's tightened model validation now rejects at construction move onto submit_raw dict mutation, so rippled still does the rejecting: SponsorshipSet - zero deltas and the neither-field-set case raise XRPLModelException; caught and re-routed to keep temBAD_AMOUNT / temINVALID reachable. ConfidentialMPT* - the models now enforce the MPTokenIssuanceID length plus the issuer roles rippled checks in preflight: the issuer may not be the Account of MergeInbox / Convert / ConvertBack / Send nor the Destination of a Send, and Clawback's Account must be the issuer. Adds _pick_holder_pool() so the four holder-shaped types draw a non-issuer account, rebuilds _clawback_faulty from a tracked issuer, and moves the send_to_issuer, non_issuer and fake_mpt_id vectors onto mutate. All five check-* gates pass. --- CLAUDE.md | 10 +- Dockerfile.workload | 2 +- scripts/check-imports | 1 - workload/pyproject.toml | 10 +- workload/scripts/setup-confidential-crypto.sh | 32 +++- workload/src/workload/setup.py | 2 +- workload/src/workload/sponsorship_compat.py | 82 --------- .../workload/transactions/confidential_mpt.py | 159 ++++++++++-------- .../src/workload/transactions/sponsorship.py | 31 ++-- 9 files changed, 144 insertions(+), 185 deletions(-) delete mode 100644 workload/src/workload/sponsorship_compat.py diff --git a/CLAUDE.md b/CLAUDE.md index 4c6d63b..a72c706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,8 +113,8 @@ Reserve/fee sponsorship of any supported tx now rides the submit-time **sponsor ### Sponsor-specific assertions (`assertions.py`, gated on `features.SPONSOR`) `workload::always : no_sponsored_queue` — rippled's TxQ rejects only FEE-sponsored txns (`TxQ.cpp`: `sfSponsor && isFeeSponsored`), so a submit carrying `Sponsor` + `spfSponsorFee` must never yield `terQUEUED` — reserve-only sponsorship queues legitimately and is excluded; fired from `_assert_sponsor_submit_signals` (submit-side only — `ter*` never validates, so `tx_result`'s stream would silently miss it). Cross-type `sometimes` reachability signals ride the same submit-time hook plus `tx_result`'s validated side: `sponsor_fee_prefunded_used`/`sponsor_fee_cosigned_used` (validated `tesSUCCESS` + `Sponsor`+`spfSponsorFee`, split on `SponsorSignature` presence — needs validation, since "did the fee sponsor actually land" isn't certain pre-ledger), `sponsor_reserve_budget_exhausted` (`terNO_SPONSORSHIP` or `tecINSUFFICIENT_RESERVE` on a `Sponsor`-bearing submit), `sponsor_no_permission_seen` (`tecNO_SPONSOR_PERMISSION`, any tx), `sponsor_has_obligations_seen` (`AccountDelete` + `tecHAS_OBLIGATIONS`). The sponsor Modifier adds four more that replace the deleted per-type `Sponsored*` success/failure buckets (and their flakiness): `_assert_sponsor_reserve_usage` (validated, `tx_result`) fires `sponsor_reserve_succeeded` / `sponsor_reserve_failed` / `sponsor_reserve_exhausted` off any validated tx carrying `Sponsor`+`spfSponsorReserve` (a `tec` claims a fee and validates, so failures reach it); `sponsor_disallowed_type_rejected` fires submit-time from `_assert_sponsor_submit_signals` when a reserve sponsor rides a type outside `_RESERVE_SPONSOR_ALLOWED_TYPES` (mirror of rippled's `isReserveSponsorAllowed`) and gets `temINVALID_FLAG`. `SponsorshipSet` has no `_META_EXPECTATIONS` row: rippled keys the Sponsorship by account+sponsee with no ID field in the tx, so create and update are indistinguishable from the body, and a no-op update (e.g. a negative count delta clamping at an already-zero budget, or re-setting an already-set flag) drops the byte-identical modify from metadata — a legitimate tesSUCCESS with no Sponsorship node that the invariant can't tell from a regression. The `_IDEMPOTENT_UPDATE_MARKER` field trick needs a per-update field this type lacks, so the row is dropped rather than made unsound (`SponsorshipTransfer` likewise skipped — its target ledger-entry type varies between `Sponsorship`/account/object types). -### SponsorshipSet delta compat (`sponsorship_compat.py`) — TEMPORARY -xrpld 3.3.0-rc5 (xrpld-private #335) renamed the SponsorshipSet tx fields `sfFeeAmount`→`sfFeeAmountDelta` (Amount, nth 34) and `sfRemainingOwnerCount`→`sfRemainingOwnerCountDelta` (Int32, nth 2) with **delta** semantics: added to the existing object's value, must be positive on create, non-zero when present (`temBAD_AMOUNT`/`temINVALID`), all-fields-absent + no flags → `temREDUNDANT`, negative deltas clamp at zero on update. The pinned xrpl-py branch has neither the model fields nor the codec definitions, so `sponsorship_compat.py` injects the field headers into the live binarycodec maps at import and subclasses the model with `fee_amount_delta`/`remaining_owner_count_delta` — import `SponsorshipSet` from there, never from `xrpl.models`. Delete the shim (and revert imports) once xrpl-py's `pre-3.3-release-group` catches up; `_register_field` no-ops if the field appears upstream. The Sponsorship **ledger object** keeps absolute `FeeAmount`/`RemainingOwnerCount`, so meta-driven tracking (`_on_sponsorship_set`) needs no delta arithmetic. xrpl-py's codec can't encode negative XRP amounts, so the negative-delta fault vector rides the count field only. +### SponsorshipSet delta semantics +xrpld 3.3.0-rc5 (xrpld-private #335) renamed the SponsorshipSet tx fields `sfFeeAmount`→`sfFeeAmountDelta` (Amount, nth 34) and `sfRemainingOwnerCount`→`sfRemainingOwnerCountDelta` (Int32, nth 2) with **delta** semantics: added to the existing object's value, must be positive on create, non-zero when present (`temBAD_AMOUNT`/`temINVALID`), all-fields-absent + no flags → `temREDUNDANT`, negative deltas clamp at zero on update. Both the model fields (`fee_amount_delta`/`remaining_owner_count_delta`) and the codec definitions now ship upstream, so import `SponsorshipSet` from `xrpl.models` — the former `sponsorship_compat.py` shim that injected the field headers is deleted. The Sponsorship **ledger object** keeps absolute `FeeAmount`/`RemainingOwnerCount`, so meta-driven tracking (`_on_sponsorship_set`) needs no delta arithmetic. xrpl-py's codec special-cases delta fields to accept negative amounts (`binarycodec/types/amount.py`'s allow-list), so a negative `FeeAmountDelta` is encodable — the curated fault vector still rides the count field, leaving the negative-fee `temBAD_AMOUNT` path to generative fuzz. ### SponsorshipAudit (`sponsorship.py` + `app.py`) Read-only `ledger_entry`/`account_info` cross-check of tracked sponsor state against the validated ledger — not a transaction, so no `REGISTRY` row (no `engine_result` to feed `seen`/`success`/`failure`, and `register_assertions()`'s reachability entry would starve waiting for a hit that structurally never comes). `app.py`'s `create_app()` wires `/sponsorship/audit/random` directly onto `_make_endpoint` (same `XRPLException`/timeout handling as REGISTRY rows) instead, invisible to `TX_TYPES`/the fuzz-coverage scan. Picks one random tracked `Sponsorship` and one random `w.sponsored_accounts` entry; a genuine `entryNotFound`/`actNotFound` against the validated ledger prunes the stale tracking entry (state legitimately drifts — deletes reaching us through a path the WS listener doesn't parse) rather than failing anything. Consistency is a `sometimes` (`sponsorship_audit_object_consistent`/`sponsorship_audit_account_consistent`), deliberately not an `always`: only a systematic break (the bucket never satisfying across a whole run) is worth triaging, and an `always` here would be flaky by construction. @@ -126,13 +126,13 @@ No logger calls in `setup.py` or handlers — `send_event` + assertions cover ob Fault-exposed validators (`val0`–`val4` + the isolated `fuzzer` validator) run `online_delete=256` + `ledger_history=256`; the tracking node `xrpld` stays `full`. Driven by `prepare-workload` `settings.node_config.online_delete` (0 disables), rendered per role by `xrpld.cfg.mako` (gated `is_validator and online_delete`) and unconditionally by `isolated_validator_xrpld.cfg.mako`; `main.py` passes the value into both. 256 is rippled's networked minimum and the only small legal value (`ledger_history ≤ online_delete`, enforced at startup). SHAMapStore rotates two NuDB backends every 256 validated ledgers: copies the live state map into the writable backend, drops the archive dir, prunes SQLite. `lastRotated` seeds to the first validated seq (~2), so rotation 1 lands ~ledger 258 and runs the risky path (state-map copy + `rotate()` archive-delete/`SavedState`-persist crash window). A full-history node keeps genesis forever, so its `complete_ledgers` lower bound never rises; `sidecar.py` reads each validator's `server_info` `complete_ledgers` and treats any rise above the first value seen as proof rotation pruned — emitting an `online_delete_rotation` event and firing the `sometimes` "online_delete rotation observed" (no threshold, reachable at rotation 1). It's a `sometimes` because a short run may never rotate. A crash inside `rotate()` can brick a node (`state database inconsistency` on restart) — that's a real rippled finding, not workload noise; don't mask it. ### Confidential MPT (XLS-0096) -Unconditionally on: the pinned `pre-3.3-release-group` xrpl-py carries the `ConfidentialMPT*` models and xrpld `develop` carries the `ConfidentialTransfer` amendment. `Dockerfile.workload` always runs the crypto build (`scripts/setup-confidential-crypto.sh`). +Unconditionally on: the pinned `main` xrpl-py carries the `ConfidentialMPT*` models and xrpld `develop` carries the `ConfidentialTransfer` amendment. `Dockerfile.workload` always runs the crypto build (`scripts/setup-confidential-crypto.sh`). Five real on-ledger handlers (`transactions/confidential_mpt.py`: MergeInbox, Convert, Send, ConvertBack, Clawback) — true `ConfidentialMPT*` type, real-type `STATE_UPDATERS`, no synthetic-name mapping. -**Packaging.** Models come from xrpl-py's `pre-3.3-release-group` branch (git-pinned, in the core wheel; converges the pre-release sponsor + confidential WIP branches). Proof generation is `xrpl.ext.confidential` — the separate `xrpl-py-confidential` dist, EXCLUDED from the core wheel — so `uv sync` gets models but not proofs. `scripts/setup-confidential-crypto.sh` (in `Dockerfile.workload`) copies `xrpl/ext/confidential` into the venv, fetches `libmpt-crypto.so` from `XRPLF/mpt-crypto`'s public release, and compiles `_mpt_crypto` (fail-loud). Import is guarded: absent add-on → `CRYPTO_AVAILABLE=False`. +**Packaging.** Models come from xrpl-py's `main` branch (git-pinned, in the core wheel; carries all 3.3.0 features — the earlier `pre-3.3-release-group` pin predates that convergence). `Dockerfile.workload`'s `XRPL_PY_REF` must track the same ref so proofs and models agree. Proof generation is `xrpl.ext.confidential` — the separate `xrpl-py-confidential` dist, EXCLUDED from the core wheel — so `uv sync` gets models but not proofs. `scripts/setup-confidential-crypto.sh` (in `Dockerfile.workload`) copies `xrpl/ext/confidential` into the venv, fetches `libmpt-crypto.so` from `XRPLF/mpt-crypto`'s public release, and compiles `_mpt_crypto` (fail-loud). Import is guarded: absent add-on → `CRYPTO_AVAILABLE=False`. -**Version coherence (not hardcoded).** The script reads the target from the branch's `MPT_CRYPTO_VERSION` and cross-checks it against `RIPPLED_MPT_CRYPTO_VERSION` (`Dockerfile.workload` ARG) — the `mpt-crypto/*` pin of the **actual** xrpld the run builds. CI resolves that from the checked-out xrpld repo+ref (`start_experiment.yml`), NOT a hardcoded public fetch: the xrpld repo may be private (e.g. `XRPLF/xrpld-private staging/3.3.x-private`, which `raw.githubusercontent` can't read) and the ref isn't always `develop`. On divergence — or when the version is unset (a standalone build) — the crypto build is skipped (not failed): confidential valid paths go dark and a `.mpt_crypto_version_mismatch` marker fires `confidential_crypto_version_mismatch`. Currently `1.0.2`. +**Version coherence (not hardcoded).** The script reads the target from the branch's `MPT_CRYPTO_VERSION` and cross-checks it against `RIPPLED_MPT_CRYPTO_VERSION` (`Dockerfile.workload` ARG) — the `mpt-crypto/*` pin of the **actual** xrpld the run builds. CI resolves that from the checked-out xrpld repo+ref (`start_experiment.yml`), NOT a hardcoded public fetch: the xrpld repo may be private (e.g. `XRPLF/xrpld-private staging/3.3.x-private`, which `raw.githubusercontent` can't read) and the ref isn't always `develop`. On divergence — or when the version is unset (a standalone build) — the crypto build is skipped (not failed): confidential valid paths go dark and a `.mpt_crypto_version_mismatch` marker fires `confidential_crypto_version_mismatch`. The check is not raw string equality: `_COMPATIBLE_CLASSES` in the script lists releases whose library source is byte-identical, so bindings at one version still build against a rippled pinned to the other. xrpl-py `main` targets `1.0.4` while both xrpld `develop` and `staging/3.3.x-private` pin `1.0.2`, and `1.0.2..1.0.4` touches only CMake/packaging/tests (no `include/` or `src/` change) — hence that pair is listed. `1.0.5` adds a BSGS DLP solver, a real library change, so it is deliberately NOT equivalent; only extend the list after confirming `git diff -- include src` is empty. `cc.CRYPTO_AVAILABLE` gates **valid** paths only. Faulty paths aren't gated: trivial-on-curve fixed-length blobs (66B ciphertext, 33B key, bogus proof) reach preclaim → `tecBAD_PROOF`/`temMALFORMED`/`tecOBJECT_NOT_FOUND` with no real crypto. Models validate the lengths rippled enforces, so `confidential_crypto.py` holds the wire-size constants and `params.py` builds faulty bases at them. diff --git a/Dockerfile.workload b/Dockerfile.workload index c7e22aa..0e16902 100644 --- a/Dockerfile.workload +++ b/Dockerfile.workload @@ -20,7 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # on the xrpl-py binding version matching RIPPLED_MPT_CRYPTO_VERSION — the mpt-crypto # pin of the ACTUAL xrpld the run uses, resolved by CI (the xrpld repo may be private). # Empty (e.g. a standalone build) -> crypto skipped, not a build failure. -ARG XRPL_PY_REF=confidential-mpt +ARG XRPL_PY_REF=main ARG RIPPLED_MPT_CRYPTO_VERSION= RUN PYTHON=/opt/venv/bin/python bash scripts/setup-confidential-crypto.sh \ "$XRPL_PY_REF" "$RIPPLED_MPT_CRYPTO_VERSION" diff --git a/scripts/check-imports b/scripts/check-imports index 699d18e..a036bdd 100755 --- a/scripts/check-imports +++ b/scripts/check-imports @@ -42,7 +42,6 @@ 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.sponsorship_compat import SponsorshipSet 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/pyproject.toml b/workload/pyproject.toml index 713f52e..2989f91 100644 --- a/workload/pyproject.toml +++ b/workload/pyproject.toml @@ -35,8 +35,10 @@ warn_unused_ignores = true ignore_missing_imports = true # Confidential MPT (XLS-0096), sponsor (XLS-68), and the rest of the models come from -# xrpl-py's pre-3.3-release-group branch (converges the pre-release WIP branches; not yet -# released). Proof generation (xrpl/ext/confidential) is EXCLUDED from the core wheel this -# installs and is built separately in the Antithesis image; see scripts/setup-confidential-crypto.sh. +# xrpl-py's main branch, which now carries all 3.3.0 features (the earlier +# pre-3.3-release-group pin predates that convergence). Proof generation +# (xrpl/ext/confidential) is EXCLUDED from the core wheel this installs and is built +# separately in the Antithesis image; see scripts/setup-confidential-crypto.sh — keep +# Dockerfile.workload's XRPL_PY_REF on the same ref so models and proofs agree. [tool.uv.sources] -xrpl-py = { git = "https://github.com/XRPLF/xrpl-py.git", branch = "pre-3.3-release-group" } +xrpl-py = { git = "https://github.com/XRPLF/xrpl-py.git", branch = "main" } diff --git a/workload/scripts/setup-confidential-crypto.sh b/workload/scripts/setup-confidential-crypto.sh index 4f16fd4..b476c57 100755 --- a/workload/scripts/setup-confidential-crypto.sh +++ b/workload/scripts/setup-confidential-crypto.sh @@ -2,8 +2,8 @@ # Build the XLS-0096 Confidential MPT crypto add-on into the workload venv. # xrpl-py excludes proof generation (xrpl/ext/confidential) from its core wheel, so # fetch it from the branch and compile its cffi extension in place. The pin the -# bindings target must equal the mpt-crypto version rippled was built against — a -# mismatch would have rippled reject every proof. That rippled version is passed in +# bindings target must be crypto-compatible with the mpt-crypto version rippled was +# built against — a mismatch would have rippled reject every proof. That rippled version is passed in # ($2), resolved by the caller from the ACTUAL xrpld repo+ref used for the run: the # xrpld repo may be private (raw.githubusercontent can't read it) and the ref is not # always develop, so we no longer fetch a hardcoded public conanfile here. On @@ -42,14 +42,38 @@ if [ -z "$RIPPLED_VERSION" ]; then exit 0 fi -if [ "$version" != "$RIPPLED_VERSION" ]; then +# Releases whose library source is byte-identical, so bindings built against one +# emit proofs a rippled built against the other verifies. Only add a pair after +# confirming `git diff -- include src` on XRPLF/mpt-crypto is empty: +# 1.0.2..1.0.4 touches CMakeLists/BundleStatic/tests only (packaging + WASM CI), +# whereas 1.0.5 adds a BSGS DLP solver and is NOT equivalent to either. +_COMPATIBLE_CLASSES=("1.0.2 1.0.4") + +# Same crypto? Exact match, or both in one equivalence class above. +crypto_compatible() { + [ "$1" = "$2" ] && return 0 + local class + for class in "${_COMPATIBLE_CLASSES[@]}"; do + case " $class " in *" $1 "*) + case " $class " in *" $2 "*) return 0 ;; esac + ;; esac + done + return 1 +} + +if ! crypto_compatible "$version" "$RIPPLED_VERSION"; then echo "WARN: mpt-crypto divergence — xrpl-py($XRPL_PY_REF)=$version" \ "rippled=$RIPPLED_VERSION. Skipping confidential crypto build;" \ "confidential valid paths disabled (CRYPTO_AVAILABLE=False)." >&2 marker "xrpl-py=$version rippled=$RIPPLED_VERSION" exit 0 fi -echo "mpt-crypto $version agreed (xrpl-py $XRPL_PY_REF == rippled $RIPPLED_VERSION)" +if [ "$version" = "$RIPPLED_VERSION" ]; then + echo "mpt-crypto $version agreed (xrpl-py $XRPL_PY_REF == rippled $RIPPLED_VERSION)" +else + echo "mpt-crypto $version (xrpl-py $XRPL_PY_REF) vs $RIPPLED_VERSION (rippled):" \ + "same library source, proceeding." +fi # ── Drop the ext source beside the installed core (xrpl.ext = PEP 420 namespace) ── site="$("$PYTHON" -c 'import xrpl, os; print(os.path.dirname(os.path.dirname(xrpl.__file__)))')" diff --git a/workload/src/workload/setup.py b/workload/src/workload/setup.py index 6ff86f0..9ee0185 100644 --- a/workload/src/workload/setup.py +++ b/workload/src/workload/setup.py @@ -45,6 +45,7 @@ NFTokenMintFlag, Payment, PermissionedDomainSet, + SponsorshipSet, TicketCreate, Transaction, TrustSet, @@ -62,7 +63,6 @@ from workload.assertions import assert_no_internal_error_submit from workload.models import ConfidentialHolder, ConfidentialMPTIssuance, UserAccount from workload.sequence import SequenceTracker -from workload.sponsorship_compat import SponsorshipSet from workload.submit import submit_tx # ── Constants ─────────────────────────────────────────────────────────── diff --git a/workload/src/workload/sponsorship_compat.py b/workload/src/workload/sponsorship_compat.py deleted file mode 100644 index 4629158..0000000 --- a/workload/src/workload/sponsorship_compat.py +++ /dev/null @@ -1,82 +0,0 @@ -"""SponsorshipSet compat for xrpld 3.3.0-rc5+ (xrpld-private #335). - -rc5 renamed the SponsorshipSet tx fields sfFeeAmount -> sfFeeAmountDelta -(Amount, nth 34) and sfRemainingOwnerCount -> sfRemainingOwnerCountDelta -(Int32, nth 2) and switched both to DELTA semantics: added to the existing -object's value (negative deltas clamp at zero), must be positive on create, -non-zero when present (else temBAD_AMOUNT / temINVALID). The pinned xrpl-py -branch has neither the model fields nor the codec definitions, 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. - -The Sponsorship LEDGER OBJECT keeps sfFeeAmount/sfRemainingOwnerCount as -absolutes -- only the tx fields changed, so meta-driven state tracking -(_on_sponsorship_set) needs no delta arithmetic. - -xrpl-py's codec cannot encode negative XRP amounts, so a negative -fee-amount delta is unreachable from this workload; negative count deltas -(Int32) encode fine. -""" - -from __future__ import annotations - -from dataclasses import dataclass -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.amounts import Amount -from xrpl.models.transactions import SponsorshipSet as _UpstreamSponsorshipSet - - -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("FeeAmountDelta", "Amount", 34) -_register_field("RemainingOwnerCountDelta", "Int32", 2) - - -@dataclass(frozen=True, kw_only=True) -class SponsorshipSet(_UpstreamSponsorshipSet): - """Upstream model plus the rc5 delta fields. The inherited absolute - fields (fee_amount/remaining_owner_count) stay constructible but rippled - now rejects them at the template, so nothing here sets them.""" - - # Optional[...] not `| None`: xrpl-py's BaseModel._check_type introspects the - # annotation at construction and crashes on a PEP 604 UnionType. - fee_amount_delta: Optional[Amount] = None # noqa: UP045 - """XRP drops added to the Sponsorship's fee pool (delta; non-zero, - positive on create).""" - - remaining_owner_count_delta: Optional[int] = None # noqa: UP045 - """Owner-reserve count added to the Sponsorship (delta; non-zero, - positive on create; negative clamps at zero on update).""" - - -# 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 delta kwargs -# ("fee_amount_delta not a valid parameter", the exact failure of run -# 3a5d2846..): a plain subclass is invisible to that lookup. -_models.SponsorshipSet = SponsorshipSet diff --git a/workload/src/workload/transactions/confidential_mpt.py b/workload/src/workload/transactions/confidential_mpt.py index 5ab6903..12cd936 100644 --- a/workload/src/workload/transactions/confidential_mpt.py +++ b/workload/src/workload/transactions/confidential_mpt.py @@ -26,6 +26,28 @@ _pending_send_amounts: dict[tuple[str, int, str], tuple[int, str]] = {} +def _pick_holder_pool( + accounts: dict[str, UserAccount], + mpt_issuances: list[MPTokenIssuance], +) -> tuple[list[UserAccount], str, str | None] | None: + """Pick an issuance plus the accounts eligible to carry it. + + MergeInbox/Convert/ConvertBack/Send reject the issuer as Account (Send also as + Destination) at model construction, mirroring rippled's temMALFORMED preflight, + so the issuer is filtered out of the pool. Returns None when no eligible account + remains; the issuer address is None for a synthetic id. + """ + if not accounts: + return None + if mpt_issuances: + mpt = choice(mpt_issuances) + pool = [a for a in accounts.values() if a.address != mpt.issuer] + if not pool: + return None + return pool, mpt.mpt_issuance_id, mpt.issuer + return list(accounts.values()), params.fake_mpt_id(), None + + # ── MergeInbox ──────────────────────────────────────────────────────── @@ -72,10 +94,11 @@ async def _merge_inbox_faulty( mpt_issuances: list[MPTokenIssuance], client: AsyncJsonRpcClient, ) -> None: - if not accounts: + picked = _pick_holder_pool(accounts, mpt_issuances) + if picked is None: return - src = choice(list(accounts.values())) - real_id = choice(mpt_issuances).mpt_issuance_id if mpt_issuances else params.fake_mpt_id() + pool, real_id, _ = picked + src = choice(pool) mutation = choice(["fake_mpt_id", "non_holder", "invalid_flags", "non_owner", "fuzz"]) @@ -175,10 +198,11 @@ async def _convert_faulty( mpt_issuances: list[MPTokenIssuance], client: AsyncJsonRpcClient, ) -> None: - if not accounts: + picked = _pick_holder_pool(accounts, mpt_issuances) + if picked is None: return - src = choice(list(accounts.values())) - real_id = choice(mpt_issuances).mpt_issuance_id if mpt_issuances else params.fake_mpt_id() + pool, real_id, _ = picked + src = choice(pool) mutation = choice( [ @@ -356,12 +380,14 @@ async def _send_faulty( mpt_issuances: list[MPTokenIssuance], client: AsyncJsonRpcClient, ) -> None: - if len(accounts) < 2: + picked = _pick_holder_pool(accounts, mpt_issuances) + if picked is None: return - acct_list = list(accounts.values()) - src = choice(acct_list) - dst = choice([a for a in acct_list if a.address != src.address]) - real_id = choice(mpt_issuances).mpt_issuance_id if mpt_issuances else params.fake_mpt_id() + pool, real_id, issuer_addr = picked + if len(pool) < 2: + return + src = choice(pool) + dst = choice([a for a in pool if a.address != src.address]) mutation = choice( [ @@ -423,23 +449,16 @@ def _set_self(d: dict) -> None: mutate = _set_self elif mutation == "send_to_issuer": - # destination == issuer -> temMALFORMED. Model rejects account==destination at - # construction, so if src is the issuer, build distinct and rewrite Destination. - if not mpt_issuances: + # destination == issuer -> temMALFORMED. The model rejects an issuer Destination + # at construction, so build with a holder dest and rewrite it in the dict. + if issuer_addr is None: return - mpt = choice(mpt_issuances) - issuer = accounts.get(mpt.issuer) - if not issuer: - return - if issuer.address == src.address: - issuer_addr = issuer.address + dest_issuer = issuer_addr - def _set_issuer(d: dict) -> None: - d["Destination"] = issuer_addr + def _set_issuer(d: dict) -> None: + d["Destination"] = dest_issuer - mutate = _set_issuer - else: - base = _send_base(src.address, issuer.address, mpt.mpt_issuance_id) + mutate = _set_issuer elif mutation == "fake_mpt_id": # -> preclaim tecOBJECT_NOT_FOUND (validating-tec feeder). base = _send_base(src.address, dst.address, params.fake_mpt_id()) @@ -452,7 +471,7 @@ def _set_flags(d: dict) -> None: mutate = _set_flags else: # non_owner -> tefBAD_AUTH. - impostor = choice([a for a in acct_list if a.address != src.address]) + impostor = choice([a for a in accounts.values() if a.address != src.address]) wallet = impostor.wallet await submit_raw("ConfidentialMPTSend", base, client, wallet, mutate) @@ -531,10 +550,11 @@ async def _convert_back_faulty( mpt_issuances: list[MPTokenIssuance], client: AsyncJsonRpcClient, ) -> None: - if not accounts: + picked = _pick_holder_pool(accounts, mpt_issuances) + if picked is None: return - src = choice(list(accounts.values())) - real_id = choice(mpt_issuances).mpt_issuance_id if mpt_issuances else params.fake_mpt_id() + pool, real_id, _ = picked + src = choice(pool) mutation = choice( [ @@ -697,17 +717,18 @@ async def _clawback_faulty( if len(accounts) < 2: return acct_list = list(accounts.values()) - src = choice(acct_list) - holder = choice([a for a in acct_list if a.address != src.address]) - real_id = choice(mpt_issuances).mpt_issuance_id if mpt_issuances else params.fake_mpt_id() - - # Reaching preclaim needs a real issuance signed by its issuer (preflight: account==issuer). - # None when unavailable (then only seen-bucket vectors are possible). - real_issuer_mpt = None - for mpt in mpt_issuances: - if mpt.issuer in accounts and any(a.address != mpt.issuer for a in acct_list): - real_issuer_mpt = mpt - break + # Clawback is issuer-only: the model requires Account == the id's issuer (mirroring + # rippled's preflight), so every vector builds from a tracked issuer and mutates + # the dict from there. Return early when no tracked issuer holds an issuance. + issuer_mpts = [m for m in mpt_issuances if m.issuer in accounts] + if not issuer_mpts: + return + mpt = choice(issuer_mpts) + issuer = accounts[mpt.issuer] + targets = [a for a in acct_list if a.address != issuer.address] + if not targets: + return + holder = choice(targets) mutation = choice( [ @@ -722,24 +743,19 @@ async def _clawback_faulty( ] ) + base = _clawback_base(issuer.address, holder.address, mpt.mpt_issuance_id) + if mutation == "fuzz": - base = _clawback_base(src.address, holder.address, real_id) - await submit_fuzzed("ConfidentialMPTClawback", base, client, src.wallet) + await submit_fuzzed("ConfidentialMPTClawback", base, client, issuer.wallet) return - base = _clawback_base(src.address, holder.address, real_id) - wallet = src.wallet + wallet = issuer.wallet mutate: Callable[[dict], None] | None = None if mutation == "garbage_proof": - # Bogus proof signed by the REAL issuer -> preclaim tecBAD_PROOF/tecOBJECT_NOT_FOUND/ - # tecINSUFFICIENT_FUNDS; the reliable validating-tec failure feeder. No real issuer -> - # falls through to a self-signed seen-only temMALFORMED. - if real_issuer_mpt is not None: - issuer = accounts[real_issuer_mpt.issuer] - target = choice([a for a in acct_list if a.address != issuer.address]) - base = _clawback_base(issuer.address, target.address, real_issuer_mpt.mpt_issuance_id) - wallet = issuer.wallet + # Bogus proof signed by the real issuer -> preclaim tecBAD_PROOF/tecOBJECT_NOT_FOUND/ + # tecINSUFFICIENT_FUNDS; the reliable validating-tec failure feeder. + pass elif mutation == "wrong_length_proof": # -> temMALFORMED. bad = params.confidential_wrong_length_hex(params._CLAWBACK_PROOF_HEX_LEN) @@ -749,30 +765,34 @@ def _set_proof(d: dict) -> None: mutate = _set_proof elif mutation == "non_issuer": - # Non-issuer clawback -> temMALFORMED. Target holder must differ from impostor - # (model rejects account==holder at construction). - if mpt_issuances: - mpt = choice(mpt_issuances) - non_issuers = [a for a in acct_list if a.address != mpt.issuer] - if non_issuers: - impostor = choice(non_issuers) - targets = [a for a in acct_list if a.address != impostor.address] - if targets: - target = choice(targets) - base = _clawback_base(impostor.address, target.address, mpt.mpt_issuance_id) - wallet = impostor.wallet + # Non-issuer clawback -> temMALFORMED. The model requires Account == issuer, so + # rewrite Account to the impostor and sign as it. + impostor = choice(targets) + impostor_addr = impostor.address + + def _set_account(d: dict) -> None: + d["Account"] = impostor_addr + + mutate = _set_account + wallet = impostor.wallet elif mutation == "self_clawback": # account == holder -> temMALFORMED. Model rejects it at construction, so build - # with a distinct holder and rewrite Holder to src in the dict. - self_addr = src.address + # with a distinct holder and rewrite Holder to the issuer in the dict. + self_addr = issuer.address def _set_self(d: dict) -> None: d["Holder"] = self_addr mutate = _set_self elif mutation == "fake_mpt_id": - # Issuer derived from a random id won't equal src -> temMALFORMED before preclaim read. - base = _clawback_base(src.address, holder.address, params.fake_mpt_id()) + # Issuer derived from a random id won't equal Account -> temMALFORMED before the + # preclaim read. The model enforces the same rule, so swap the id in the dict. + fake_id = params.fake_mpt_id() + + def _set_id(d: dict) -> None: + d["MPTokenIssuanceID"] = fake_id + + mutate = _set_id elif mutation == "invalid_flags": # -> temINVALID_FLAG. flags = params.confidential_invalid_flags() @@ -782,8 +802,7 @@ def _set_flags(d: dict) -> None: mutate = _set_flags else: # non_owner -> tefBAD_AUTH. - impostor = choice([a for a in acct_list if a.address != src.address]) - wallet = impostor.wallet + wallet = choice(targets).wallet await submit_raw("ConfidentialMPTClawback", base, client, wallet, mutate) diff --git a/workload/src/workload/transactions/sponsorship.py b/workload/src/workload/transactions/sponsorship.py index ee937ce..8d8ddb1 100644 --- a/workload/src/workload/transactions/sponsorship.py +++ b/workload/src/workload/transactions/sponsorship.py @@ -30,6 +30,7 @@ from xrpl.models.transactions import ( Payment, PaymentFlag, + SponsorshipSet, SponsorshipSetFlag, SponsorshipTransfer, ) @@ -49,7 +50,6 @@ UserAccount, ) from workload.randoms import choice, randint, random, sample -from workload.sponsorship_compat import SponsorshipSet from workload.submit import submit_raw, submit_tx # Populated by _payment_sponsored_account_valid, consumed by the "Payment" real-type @@ -323,6 +323,7 @@ async def _sponsorship_set_faulty( "sponsee_attempts_create", "conflicting_flags", "delete_with_fields", + "zero_delta", ): # xrpl-py's SponsorshipSet._get_errors rejects every one of these at # construction time, so only rippled's raw preflight can be exercised. @@ -330,6 +331,7 @@ async def _sponsorship_set_faulty( if built is None: return base, wallet = built + zero_fee = random() < 0.5 def _mutate(d: dict) -> None: if mutation == "self_sponsorship": @@ -347,6 +349,16 @@ def _mutate(d: dict) -> None: ) elif mutation == "delete_with_fields": d["Flags"] = d.get("Flags", 0) | params.TF_SPONSORSHIP_DELETE_OBJECT + elif mutation == "zero_delta": + # A present-but-zero delta, the only one present: + # FeeAmountDelta -> temBAD_AMOUNT, RemainingOwnerCountDelta + # -> temINVALID. + if zero_fee: + d["FeeAmountDelta"] = "0" + d.pop("RemainingOwnerCountDelta", None) + else: + d["RemainingOwnerCountDelta"] = 0 + d.pop("FeeAmountDelta", None) await submit_raw("SponsorshipSet", base, client, wallet, _mutate) return @@ -362,25 +374,10 @@ def _mutate(d: dict) -> None: await submit_tx("SponsorshipSet", txn, client, accounts[sponsor_addr].wallet) return - if mutation == "zero_delta": - # A present-but-zero delta: FeeAmountDelta -> temBAD_AMOUNT, - # RemainingOwnerCountDelta -> temINVALID. - sponsor_addr, sponsee_addr = sample(list(accounts), 2) - zero_fee = random() < 0.5 - txn = SponsorshipSet( - account=sponsor_addr, - sponsee=sponsee_addr, - fee_amount_delta="0" if zero_fee else None, - remaining_owner_count_delta=None if zero_fee else 0, - ) - await submit_tx("SponsorshipSet", txn, client, accounts[sponsor_addr].wallet) - return - if mutation == "negative_count_delta": # Negative delta on a fresh pair takes the create path, where deltas # must be positive -> tecNO_PERMISSION; on the rare tracked pair it - # legitimately clamps at zero instead. (A negative FeeAmountDelta is - # out of reach: xrpl-py's codec can't encode negative XRP amounts.) + # legitimately clamps at zero instead. sponsor_addr, sponsee_addr = sample(list(accounts), 2) txn = SponsorshipSet( account=sponsor_addr,