diff --git a/.ci-config/Dockerfile.nightly b/.ci-config/Dockerfile.nightly index a87c7af0..4a0a0773 100644 --- a/.ci-config/Dockerfile.nightly +++ b/.ci-config/Dockerfile.nightly @@ -10,7 +10,7 @@ RUN install -m 0755 -d /usr/share/keyrings \ # rippled was renamed to xrpld on the develop branch; the nightly channel publishes it as the "xrpld" package. # The version must be pinned: the timestamp format changed from 14 to 12 digits mid-2026, # so Debian version ordering ranks old 14-digit builds above the newer 12-digit ones. -ARG XRPLD_VERSION=3.3.0~b1+202607110018.8306ac77-1 +ARG XRPLD_VERSION=3.4.0~b0+202608111815.26cc683e-1 RUN echo "deb [signed-by=/usr/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/rippled-deb jammy nightly" > /etc/apt/sources.list.d/ripple.list \ && apt-get update \ && apt-get install -y --no-install-recommends --allow-downgrades "xrpld=${XRPLD_VERSION}" \ diff --git a/.ci-config/bump-nightly-pin.sh b/.ci-config/bump-nightly-pin.sh new file mode 100755 index 00000000..975cc95f --- /dev/null +++ b/.ci-config/bump-nightly-pin.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Move the nightly stand to the newest xrpld build published in the nightly apt +# channel: rewrite ARG XRPLD_VERSION in Dockerfile.nightly and regenerate +# rippled.batchv11.cfg from the matching develop commit. +# +# Why this needs to happen regularly, not only when a new amendment is wanted: +# definitions-watch raises its stand from docker-compose.batchv11.yml, i.e. from +# THIS pin. While the pin is stale the weekly monitor diffs definitions.json +# against a build older than the one CI runs and reports "in sync" about the +# past — which is how rippled 3.3.0 renaming sfMutableFlags to sfImmutableFlags +# and moving SponsorshipSet onto delta fields reached the CI bump PR unnoticed. +# +# The generated config MUST come from the commit the pinned binary was built +# from: a newer ref can emit feature names the binary does not know, and rippled +# rejects unknown names in config at startup. The commit is taken from the +# version string itself, so the two cannot drift apart. +# +# Usage: +# .ci-config/bump-nightly-pin.sh # bump to the newest build +# .ci-config/bump-nightly-pin.sh --check # report only, change nothing +# +# Outputs (stdout, and $GITHUB_OUTPUT when set): +# old_version, new_version, new_ref, age_days (age of the CURRENT pin) +# +# Exit codes: 0 done or already current (see `bumped`), 1 error. + +set -euo pipefail + +PACKAGES_URL="https://repos.ripple.com/repos/rippled-deb/dists/jammy/nightly/binary-amd64/Packages" +DIR="$(cd "$(dirname "$0")" && pwd)" +DOCKERFILE="$DIR/Dockerfile.nightly" +CFG="$DIR/rippled.batchv11.cfg" + +check_only=false +if [ "${1:-}" = "--check" ]; then + check_only=true +elif [ $# -gt 0 ]; then + echo "usage: $(basename "$0") [--check]" >&2 + exit 1 +fi + +for f in "$DOCKERFILE" "$CFG"; do + [ -f "$f" ] || { echo "error: not found: $f" >&2; exit 1; } +done + +old_version=$(sed -n -E 's/^ARG XRPLD_VERSION=(.+)$/\1/p' "$DOCKERFILE") +if [ -z "$old_version" ]; then + echo "error: could not read ARG XRPLD_VERSION from $DOCKERFILE" >&2 + exit 1 +fi + +packages=$(curl -sf --max-time 60 "$PACKAGES_URL") || { + echo "error: could not fetch $PACKAGES_URL" >&2 + exit 1 +} + +# Version strings look like 3.4.0~b0+202608111815.26cc683e-1: upstream version, +# a build timestamp and the develop commit it was built from. The timestamp +# format shrank from 14 digits (YYYYMMDDHHMMSS) to 12 (YYYYMMDDHHMM) mid-2026, +# which is why plain version sorting ranks old builds above new ones and why the +# pin exists at all. Truncating both to YYYYMMDDHHMM makes them comparable. +newest=$(printf '%s\n' "$packages" \ + | awk '/^Package: xrpld$/ { p = 1; next } /^Version: / { if (p) print $2; p = 0 }' \ + | while IFS= read -r v; do + ts=$(printf '%s' "$v" | sed -n -E 's/.*\+([0-9]{12,14})\..*/\1/p') + [ -n "$ts" ] && printf '%s %s\n' "${ts:0:12}" "$v" + done \ + | LC_ALL=C sort -k1,1n | tail -1) + +if [ -z "$newest" ]; then + echo "error: no parseable xrpld versions in the nightly Packages index" >&2 + exit 1 +fi + +new_version=${newest#* } +new_ts=${newest%% *} +new_ref=$(printf '%s' "$new_version" | sed -n -E 's/.*\+[0-9]{12,14}\.([0-9a-f]+).*/\1/p') +if [ -z "$new_ref" ]; then + echo "error: could not extract the develop commit from '$new_version'" >&2 + exit 1 +fi + +old_ts=$(printf '%s' "$old_version" | sed -n -E 's/.*\+([0-9]{12,14})\..*/\1/p') +old_ts=${old_ts:0:12} +age_days="" +if [ -n "$old_ts" ]; then + old_epoch=$(date -u -d "${old_ts:0:8} ${old_ts:8:2}:${old_ts:10:2}" +%s 2>/dev/null || echo "") + if [ -n "$old_epoch" ]; then + age_days=$(( ( $(date -u +%s) - old_epoch ) / 86400 )) + fi +fi + +bumped=false +if [ "$old_version" = "$new_version" ]; then + echo "Nightly pin is already the newest build ($old_version)." +elif [ "$check_only" = true ]; then + echo "Nightly pin $old_version is behind $new_version (build ${new_ts}); run without --check to bump." +else + # Match the whole value so a partially-rewritten pin cannot survive. + sed -i -E "s#^ARG XRPLD_VERSION=.+\$#ARG XRPLD_VERSION=${new_version}#" "$DOCKERFILE" + written=$(sed -n -E 's/^ARG XRPLD_VERSION=(.+)$/\1/p' "$DOCKERFILE") + if [ "$written" != "$new_version" ]; then + echo "error: rewriting ARG XRPLD_VERSION failed (file now holds '$written')" >&2 + exit 1 + fi + bash "$DIR/generate-amendments.sh" "$new_ref" "$CFG" + bumped=true + echo "Nightly pin bumped: $old_version -> $new_version (ref $new_ref)." +fi + +[ -n "$age_days" ] && echo "Current pin age before this run: ${age_days}d." + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "old_version=$old_version" + echo "new_version=$new_version" + echo "new_ref=$new_ref" + echo "age_days=${age_days:-unknown}" + echo "bumped=$bumped" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.ci-config/docker-compose.ci.yml b/.ci-config/docker-compose.ci.yml index bf42c40a..81eef46f 100644 --- a/.ci-config/docker-compose.ci.yml +++ b/.ci-config/docker-compose.ci.yml @@ -1,6 +1,6 @@ services: xrpld: - image: xrpllabsofficial/xrpld:3.2.0 + image: xrpllabsofficial/xrpld:3.3.0 container_name: rippled-service command: ["-a", "--start"] ports: diff --git a/.ci-config/rippled.batchv11.cfg b/.ci-config/rippled.batchv11.cfg index c68c941f..f2999f3c 100644 --- a/.ci-config/rippled.batchv11.cfg +++ b/.ci-config/rippled.batchv11.cfg @@ -115,13 +115,13 @@ TokenEscrow XChainBridge XRPFees fixAMMClawbackRounding -fixAMMOverflowOffer fixAMMv1_1 fixAMMv1_2 fixAMMv1_3 fixCleanup3_1_3 fixCleanup3_2_0 fixCleanup3_3_0 +fixCleanup3_4_0 fixDirectoryLimit fixEmptyDID fixEnforceNFTokenTrustline @@ -175,13 +175,13 @@ BE1F90581635DBCEBFC4678C4B54FEDDC1A17B50FD02CFE765A4132A342126AC Sponsor C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge 93E516234E35E08CA689FA33A6D38E103881F8DCB53023F728C307AA89D515A7 XRPFees 5E9586DB3D765B4C5794658FB6BB385071E9838DF4016027E6E26820C8526724 fixAMMClawbackRounding -12523DF04B553A0B1AD74F42DDB741DE8DC06A03FC089A0EF197E2A87F1D8107 fixAMMOverflowOffer 35291ADD2D79EB6991343BDA0912269C817D0F094B02226C1C14AD2858962ED4 fixAMMv1_1 1E7ED950F2F13C4F8E2A54103B74D57D5D298FFDBD005936164EE9E6484C438C fixAMMv1_2 7CA70A7674A26FA517412858659EBC7EDEEF7D2D608824464E6FDEFD06854E14 fixAMMv1_3 303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC fixCleanup3_1_3 21B8D2F76F68E11E9C077A43BBBC394136E9987E99DDB73966DD68419467E431 fixCleanup3_2_0 3298D47E1F3A8A24FECAA30F699B8FE1DD234E072834BA099AD8180FFCE0FEC4 fixCleanup3_3_0 +98433DD001A5737F773D74F8CA2A25A065089C73B2E611C760BAF369E4FECA76 fixCleanup3_4_0 41765F664A8D67FF03DDB1C1A893DE6273690BA340A6C2B07C8D29D0DD013D3A fixDirectoryLimit 755C971C29971C9F20C6F080F2ED96F87884E40AD19554A5EBECDCEC8A1F77FE fixEmptyDID 763C37B352BE8C7A04E810F8E462644C45AFEAD624BF3894A08E5C917CF9FF39 fixEnforceNFTokenTrustline diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 55cd3f06..954df328 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -103,19 +103,23 @@ validators.txt # retired/unknown feature names at startup. AMM AMMClawback -Clawback +BatchV1_1 +ConfidentialTransfer Credentials DID DeepFreeze +DynamicMPT DynamicNFT LendingProtocol MPTokensV1 MPTokensV2 NFTokenMintOffer +PermissionDelegationV1_1 PermissionedDEX PermissionedDomains PriceOracle SingleAssetVault +Sponsor TokenEscrow XChainBridge XRPFees @@ -126,27 +130,24 @@ fixAMMv1_2 fixAMMv1_3 fixCleanup3_1_3 fixCleanup3_2_0 +fixCleanup3_3_0 fixDirectoryLimit -fixDisallowIncomingV1 fixEmptyDID fixEnforceNFTokenTrustline fixEnforceNFTokenTrustlineV2 fixFillOrKill fixFrozenLPTokenTransfer fixIncludeKeyletFields -fixInnerObjTemplate fixInnerObjTemplate2 fixInvalidTxFlags fixMPTDeliveredAmount fixNFTokenPageLinks -fixNFTokenReserve fixPayChanCancelAfter fixPreviousTxnID fixPriceOracleOrder fixReducedOffersV2 fixRemoveNFTokenAutoTrustLine fixTokenEscrowV1 -fixUniversalNumber fixXChainRewardRounding [network_id] @@ -162,18 +163,22 @@ fixXChainRewardRounding # Supported::No ones (e.g. MPTokensV2) live in [features] as Rules presets. 8CC0774A3BF66D1D22E76BBDA8E8A232E6B6313834301B3B23E8601196AE6455 AMM 726F944886BCDF7433203787E93DD9AA87FAB74DFE3AF4785BA03BEFC97ADA1F AMMClawback -56B241D7A43D40354D02A9DC4C8DF5C7A1F930D92A9035C4E12291B3CA3E1C2B Clawback +9F287AED3CDB50A7BD1ACEC24296A30C9B5230CCD136219317AC790E3B884377 BatchV1_1 +2110E4A19966E2EF517C0A8C56A5F35099D7665B0BB89D7B126B30D50B86AAD5 ConfidentialTransfer 1CB67D082CF7D9102412D34258CEDB400E659352D3B207348889297A6D90F5EF Credentials DB432C3A09D9D5DFC7859F39AE5FF767ABC59AED0A9FB441E83B814D8946C109 DID DAF3A6EB04FA5DC51E8E4F23E9B7022B693EFA636F23F22664746C77B5786B23 DeepFreeze +58E92F338758479C06084E1B6BA366BAD8F75E5329A7F0EEAFFFDA51E5106B7F DynamicMPT C1CE18F2A268E6A849C27B3DE485006771B4C01B2FCEC4F18356FE92ECD6BB74 DynamicNFT 565B90CA1AB2B9D42208ED10884188C64F9E19083DECB9634AAF06EB03299509 LendingProtocol 950AE2EA4654E47F04AA8739C0B214E242097E802FD372D24047A89AB1F5EC38 MPTokensV1 EE3CF852F0506782D05E65D49E5DCC3D16D50898CD1B646BAE274863401CC3CE NFTokenMintOffer +0F48FF561C709540328F31F1C97FD512ACC8B4E42138A161CB0E21ECA292540B PermissionDelegationV1_1 677E401A423E3708363A36BA8B3A7D019D21AC5ABD00387BDBEA6BDE4C91247E PermissionedDEX A730EB18A9D4BB52502C898589558B4CCEB4BE10044500EE5581137A2E80E849 PermissionedDomains 96FD2F293A519AE1DB6F8BED23E4AD9119342DA7CB6BAFD00953D16C54205D8B PriceOracle 81BD2619B6B3C8625AC5D0BC01DE17F06C3F0AB95C7C87C93715B87A4FD240D8 SingleAssetVault +BE1F90581635DBCEBFC4678C4B54FEDDC1A17B50FD02CFE765A4132A342126AC Sponsor 138B968F25822EFBF54C00F97031221C47B1EAB8321D93C7C2AEAF85F04EC5DF TokenEscrow C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge 93E516234E35E08CA689FA33A6D38E103881F8DCB53023F728C307AA89D515A7 XRPFees @@ -184,26 +189,23 @@ C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge 7CA70A7674A26FA517412858659EBC7EDEEF7D2D608824464E6FDEFD06854E14 fixAMMv1_3 303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC fixCleanup3_1_3 21B8D2F76F68E11E9C077A43BBBC394136E9987E99DDB73966DD68419467E431 fixCleanup3_2_0 +3298D47E1F3A8A24FECAA30F699B8FE1DD234E072834BA099AD8180FFCE0FEC4 fixCleanup3_3_0 41765F664A8D67FF03DDB1C1A893DE6273690BA340A6C2B07C8D29D0DD013D3A fixDirectoryLimit -15D61F0C6DB6A2F86BCF96F1E2444FEC54E705923339EC175BD3E517C8B3FF91 fixDisallowIncomingV1 755C971C29971C9F20C6F080F2ED96F87884E40AD19554A5EBECDCEC8A1F77FE fixEmptyDID 763C37B352BE8C7A04E810F8E462644C45AFEAD624BF3894A08E5C917CF9FF39 fixEnforceNFTokenTrustline B32752F7DCC41FB86534118FC4EEC8F56E7BD0A7DB60FD73F93F257233C08E3A fixEnforceNFTokenTrustlineV2 3318EA0CF0755AF15DAC19F2B5C5BCBFF4B78BDD57609ACCAABE2C41309B051A fixFillOrKill 83FD6594FF83C1D105BD2B41D7E242D86ECB4A8220BD9AF4DA35CB0F69E39B2A fixFrozenLPTokenTransfer 6143A27B71F7DAF9330ECA7C5EC3D54C8083A4FDEF7016737EEC06AB61E82EE0 fixIncludeKeyletFields -C393B3AEEBF575E475F0C60D5E4241B2070CC4D0EB6C4846B1A07508FAEFC485 fixInnerObjTemplate 9196110C23EA879B4229E51C286180C7D02166DA712559F634372F5264D0EC59 fixInnerObjTemplate2 8EC4304A06AF03BE953EA6EDA494864F6F3F30AA002BABA35869FBB8C6AE5D52 fixInvalidTxFlags AB8D932A5F338903FE5BCBD80B611FFED70839ABA3170E9CE01D947C0EDEDCF2 fixMPTDeliveredAmount C7981B764EC4439123A86CC7CCBA436E9B3FF73B3F10A0AE51882E404522FC41 fixNFTokenPageLinks -03BDC0099C4E14163ADA272C1B6F6FABB448CC3E51F522F978041E4B57D9158C fixNFTokenReserve D3456A862DC07E382827981CA02E21946E641877F19B8889031CC57FDCAC83E2 fixPayChanCancelAfter 7BB62DC13EC72B775091E9C71BF8CF97E122647693B50C5E87A80DFD6FCFAC50 fixPreviousTxnID FF2D1E13CF6D22427111B967BD504917F63A900CECD320D6FD3AC9FA90344631 fixPriceOracleOrder 31E0DA76FB8FB527CADCDF0E61CB9C94120966328EFA9DCA202135BAF319C0BA fixReducedOffersV2 DF8B4536989BDACE3F934F29423848B9F1D76D09BE6A1FCFE7E7F06AA26ABEAD fixRemoveNFTokenAutoTrustLine 32B8614321F7E070419115ABEAB1742EA20F3E3AF34432B5E2F474F8083260DC fixTokenEscrowV1 -2E2FB9CF8A44EB80F4694D38AADAE9B8B7ADAFD2F092E10068E61C98C4F092B0 fixUniversalNumber 2BF037D90E1B676B17592A8AF55E88DB465398B4B597AE46EECEE1399AB05699 fixXChainRewardRounding diff --git a/.github/workflows/definitions-watch.yml b/.github/workflows/definitions-watch.yml index a878138d..78de9448 100644 --- a/.github/workflows/definitions-watch.yml +++ b/.github/workflows/definitions-watch.yml @@ -7,6 +7,9 @@ name: Definitions Watch # # "develop" here = the pinned xrpld version in .ci-config/Dockerfile.nightly, not # the literal develop tip; the tip moving is covered separately by protocol-watch. +# +# The pin is therefore this check's horizon: against a stale pin the diff reports +# "in sync" about the past. nightly-pin-watch.yml keeps it moving. on: schedule: diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml new file mode 100644 index 00000000..8b8b8459 --- /dev/null +++ b/.github/workflows/nightly-pin-watch.yml @@ -0,0 +1,308 @@ +name: Nightly Pin Watch + +# Weekly check that the nightly stand is not pinned to a stale xrpld build. +# +# definitions-watch raises its stand from docker-compose.batchv11.yml, i.e. from +# ARG XRPLD_VERSION in .ci-config/Dockerfile.nightly. While that pin is stale the +# weekly definitions diff compares definitions.json against a build older than +# the one CI runs and reports "in sync" about the past — which is how rippled +# 3.3.0 renaming sfMutableFlags to sfImmutableFlags and moving SponsorshipSet +# onto delta fields reached the CI bump PR unnoticed. +# +# The pin cannot simply be dropped: the nightly apt channel switched its build +# timestamp from 14 to 12 digits mid-2026, so Debian version ordering ranks old +# builds above new ones and an unpinned install gets a stale binary. Hence a +# workflow that moves the pin deliberately instead. +# +# Bumping is deliberately NOT done every week: nightly publishes several builds +# a day and a weekly PR would be noise. MAX_PIN_AGE_DAYS is the staleness the +# monitor is allowed to carry. +# +# Credentials follow the same ladder as release-watch (a PR opened with +# GITHUB_TOKEN does not trigger CI): +# 1. GitHub App: RELEASE_WATCH_APP_ID variable + RELEASE_WATCH_APP_PRIVATE_KEY +# secret, app installed with contents:write + pull_requests:write +# 2. Fine-grained PAT: RELEASE_WATCH_PAT secret +# With neither — or when the stand fails to come up on the new pin — the run +# falls back to a comment on the tracking issue. + +on: + schedule: + - cron: '0 5 * * 1' # Mondays 05:00 UTC, ahead of the other watchers + workflow_dispatch: + inputs: + force: + description: 'Bump even if the pin is younger than MAX_PIN_AGE_DAYS' + type: boolean + default: false + +permissions: + contents: read + issues: write + pull-requests: read # gh pr list in the idempotency check runs on GITHUB_TOKEN + +concurrency: + group: nightly-pin-watch + cancel-in-progress: false + +env: + COMPOSE: .ci-config/docker-compose.batchv11.yml + MAX_PIN_AGE_DAYS: '21' + ISSUE_TITLE: 'Nightly pin watch: xrpld develop build' + ISSUE_LABEL: nightly-pin-watch + DOTNET_VERSION: '10.0.x' + HAS_APP: ${{ vars.RELEASE_WATCH_APP_ID != '' && secrets.RELEASE_WATCH_APP_PRIVATE_KEY != '' }} + CAN_PR: ${{ (vars.RELEASE_WATCH_APP_ID != '' && secrets.RELEASE_WATCH_APP_PRIVATE_KEY != '') || secrets.RELEASE_WATCH_PAT != '' }} + +jobs: + watch: + runs-on: ubuntu-latest + # Cold nightly image build (apt install of the pinned xrpld) plus a genesis + # start and the definitions diff; cap well under the 360-min default. + timeout-minutes: 30 + steps: + - name: Mint GitHub App token + id: app-token + if: env.HAS_APP == 'true' + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_WATCH_APP_ID }} + private-key: ${{ secrets.RELEASE_WATCH_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token || secrets.RELEASE_WATCH_PAT || github.token }} + + - name: Check the pin against the nightly channel + id: check + run: bash .ci-config/bump-nightly-pin.sh --check + + # A pin that is current, or stale by less than the allowance, is left + # alone: definitions-watch is still looking at a recent develop build. + - name: Decide whether to bump + id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OLD_VERSION: ${{ steps.check.outputs.old_version }} + NEW_VERSION: ${{ steps.check.outputs.new_version }} + AGE_DAYS: ${{ steps.check.outputs.age_days }} + FORCE: ${{ inputs.force }} + run: | + set -euo pipefail + if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then + echo "Pin is already the newest published build." + echo "bump=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$FORCE" != "true" ]; then + case "$AGE_DAYS" in + ''|*[!0-9]*) + echo "::warning::pin age could not be derived from '$OLD_VERSION'; bumping so an unparseable pin cannot sit forever" + ;; + *) + if [ "$AGE_DAYS" -lt "$MAX_PIN_AGE_DAYS" ]; then + echo "Pin is ${AGE_DAYS}d old, under the ${MAX_PIN_AGE_DAYS}d allowance; leaving it." + echo "bump=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + ;; + esac + fi + # git refs reject '~' (and a few other characters) — a raw upstream + # version like 3.4.0~b0+202608111815.26cc683e-1 cannot be a branch name. + # Sanitize by class rather than by the one character today's format + # happens to use, so a future version string cannot break the bump. + slug=$(printf '%s' "$NEW_VERSION" | sed -E 's/[^A-Za-z0-9._-]+/-/g') + branch="nightly-watch/$slug" + if ! git check-ref-format "refs/heads/$branch"; then + echo "::error::derived branch name is not a valid git ref: $branch" + exit 1 + fi + if [ -n "$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --state open --json number --jq '.[0].number // empty')" ]; then + echo "A bump PR for $NEW_VERSION is already open." + echo "bump=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "bump=true" >> "$GITHUB_OUTPUT" + + # Rewrites the pin and regenerates rippled.batchv11.cfg from the develop + # commit the new build was made from — the script derives the ref from the + # version string, so config and binary cannot drift apart. + - name: Bump the pin + if: steps.decide.outputs.bump == 'true' + id: bump + run: | + set -euo pipefail + old_names=$(sed -n -E 's/^[0-9A-F]{64} (.+)$/\1/p' .ci-config/rippled.batchv11.cfg | LC_ALL=C sort) + bash .ci-config/bump-nightly-pin.sh + new_names=$(sed -n -E 's/^[0-9A-F]{64} (.+)$/\1/p' .ci-config/rippled.batchv11.cfg | LC_ALL=C sort) + { + echo 'added<> "$GITHUB_OUTPUT" + git diff --stat + + - name: Start the stand on the new pin + if: steps.decide.outputs.bump == 'true' + id: smoke + continue-on-error: true + run: | + set -euo pipefail + docker compose -f "$COMPOSE" up -d --build + # AMM amendment id — the same sentinel the CI readiness wait uses. + # Genesis up-votes come from [amendments], so an enabled sentinel means + # the regenerated config was accepted rather than silently ignored. + SENTINEL="8CC0774A3BF66D1D22E76BBDA8E8A232E6B6313834301B3B23E8601196AE6455" + ok=false + for _ in $(seq 1 45); do + enabled=$(curl -sf --max-time 5 http://localhost:5005/ \ + -d "{\"method\":\"feature\",\"params\":[{\"feature\":\"$SENTINEL\"}]}" \ + | jq -r ".result.\"$SENTINEL\".enabled" 2>/dev/null || true) + if [ "$enabled" = "true" ]; then ok=true; break; fi + sleep 2 + done + if [ "$ok" != "true" ]; then + docker compose -f "$COMPOSE" logs --no-color xrpld | tail -80 || true + fi + [ "$ok" = "true" ] + + - name: Use .NET ${{ env.DOTNET_VERSION }} + if: steps.decide.outputs.bump == 'true' && steps.smoke.outcome == 'success' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + # Informational, never blocking: a definitions drift against the newer + # develop build is exactly what the bump exists to reveal, so it belongs in + # the PR body rather than stopping the PR from being opened. + - name: Diff definitions.json against the new build + if: steps.decide.outputs.bump == 'true' && steps.smoke.outcome == 'success' + id: definitions + continue-on-error: true + run: | + set -uo pipefail + out=$(dotnet run --project Tools/GenerateEnums -- diff http://localhost:5005 2>&1) + status=$? + echo "$out" + { + echo 'summary<> "$GITHUB_OUTPUT" + exit 0 + + - name: Stop the stand + if: always() + run: docker compose -f "$COMPOSE" down || true + + - name: Open bump PR + if: steps.decide.outputs.bump == 'true' && steps.smoke.outcome == 'success' && env.CAN_PR == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.RELEASE_WATCH_PAT }} + OLD_VERSION: ${{ steps.check.outputs.old_version }} + NEW_VERSION: ${{ steps.check.outputs.new_version }} + NEW_REF: ${{ steps.check.outputs.new_ref }} + AGE_DAYS: ${{ steps.check.outputs.age_days }} + BRANCH: ${{ steps.decide.outputs.branch }} + run: | + set -euo pipefail + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor }}@users.noreply.github.com" + git checkout -b "$BRANCH" + git add .ci-config/Dockerfile.nightly .ci-config/rippled.batchv11.cfg + git commit -m "ci: bump nightly stand pin to xrpld $NEW_VERSION" + git push -u origin "$BRANCH" --force + body_file=$(mktemp) + { + printf '## Summary\n\n' + printf 'Automated bump by the nightly-pin-watch workflow: the nightly stand was pinned to a build **%sd** old.\n\n' "$AGE_DAYS" + printf -- '- `.ci-config/Dockerfile.nightly`: `%s` -> `%s`\n' "$OLD_VERSION" "$NEW_VERSION" + printf -- '- `.ci-config/rippled.batchv11.cfg`: `[features]`/`[amendments]` regenerated from develop `%s`, the commit that build was made from\n\n' "$NEW_REF" + printf '## Amendment changes\n\n' + printf 'Added:\n```\n%s\n```\n\n' "${{ steps.bump.outputs.added }}" + printf 'Removed:\n```\n%s\n```\n\n' "${{ steps.bump.outputs.removed }}" + printf '## definitions.json vs the new build\n\n' + printf '```\n%s\n```\n\n' "${{ steps.definitions.outputs.summary }}" + printf 'A `node-only` field here means the SDK is behind develop and needs a follow-up; `local-only` entries are informational.\n\n' + printf '## Verification\n\n' + printf 'The stand was built and started from the new pin on the runner, and the AMM sentinel amendment came up enabled at genesis, so the regenerated config was accepted.\n\n' + printf 'Why this matters: definitions-watch raises its stand from this pin, so a stale pin makes the weekly develop-drift check compare against the past.\n' + } > "$body_file" + gh pr create --repo "$GITHUB_REPOSITORY" --base dev --head "$BRANCH" \ + --title "ci: bump nightly stand pin to xrpld $NEW_VERSION" \ + --body-file "$body_file" + + - name: Notify on tracking issue (no PR credentials or stand failure) + if: steps.decide.outputs.bump == 'true' && (steps.smoke.outcome != 'success' || env.CAN_PR != 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + OLD_VERSION: ${{ steps.check.outputs.old_version }} + NEW_VERSION: ${{ steps.check.outputs.new_version }} + NEW_REF: ${{ steps.check.outputs.new_ref }} + AGE_DAYS: ${{ steps.check.outputs.age_days }} + SMOKE: ${{ steps.smoke.outcome }} + run: | + set -euo pipefail + if [ "$SMOKE" != "success" ]; then + reason="the stand FAILED to come up on the new pin — the build, the regenerated config or the new binary needs a human look (see the run log)" + else + reason="no PR credentials are configured (RELEASE_WATCH_APP_ID + RELEASE_WATCH_APP_PRIVATE_KEY, or RELEASE_WATCH_PAT), so a CI-triggering PR cannot be opened automatically" + fi + number=$(gh issue list --repo "$REPO" --label "$ISSUE_LABEL" --state open \ + --json number --jq '.[0].number // empty') + if [ -z "$number" ]; then + gh label create "$ISSUE_LABEL" --repo "$REPO" \ + --description "nightly xrpld pin tracking" --color 1d76db 2>/dev/null || true + number=$(gh issue create --repo "$REPO" --title "$ISSUE_TITLE" --label "$ISSUE_LABEL" \ + --body "Automated notifications from the nightly-pin-watch workflow (.github/workflows/nightly-pin-watch.yml)." \ + | grep -oE '[0-9]+$') + fi + marker="" + existing_comments=$(gh api "repos/$REPO/issues/$number/comments" --paginate --jq '.[].body') + if grep -qF "$marker" <<< "$existing_comments"; then + echo "Notification for $NEW_VERSION already posted." + exit 0 + fi + comment=$(printf '%s\nThe nightly stand is pinned to **%s** (%sd old); the newest published build is **%s**, but %s.\n\nManual path: run `.ci-config/bump-nightly-pin.sh`, start `%s` to check the stand comes up, open a PR.\nThe regenerated config must come from develop `%s` — the commit that build was made from.\n' \ + "$marker" "$OLD_VERSION" "$AGE_DAYS" "$NEW_VERSION" "$reason" "$COMPOSE" "$NEW_REF") + gh issue comment "$number" --repo "$REPO" --body "$comment" + + - name: Notify on tracking issue (workflow failure) + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + number=$(gh issue list --repo "$REPO" --label "$ISSUE_LABEL" --state open \ + --json number --jq '.[0].number // empty') + if [ -z "$number" ]; then + gh label create "$ISSUE_LABEL" --repo "$REPO" \ + --description "nightly xrpld pin tracking" --color 1d76db 2>/dev/null || true + number=$(gh issue create --repo "$REPO" --title "$ISSUE_TITLE" --label "$ISSUE_LABEL" \ + --body "Automated notifications from the nightly-pin-watch workflow (.github/workflows/nightly-pin-watch.yml)." \ + | grep -oE '[0-9]+$') + fi + # One notification per failure streak, same rule as release-watch: + # skip only when the latest comment is already a failure notice. + marker='' + last_id=$(gh api "repos/$REPO/issues/$number/comments" --paginate --jq '.[].id' | tail -1) + if [ -n "$last_id" ]; then + last_body=$(gh api "repos/$REPO/issues/comments/$last_id" --jq .body) + if grep -qF "$marker" <<< "$last_body"; then + echo "Latest comment is already a failure notification; skipping." + exit 0 + fi + fi + comment=$(printf '%s\nNightly-pin-watch run **failed** before completing: %s/%s/actions/runs/%s\n\nThe pin was not changed; see the run log.\n' \ + "$marker" "$GITHUB_SERVER_URL" "$REPO" "${{ github.run_id }}") + gh issue comment "$number" --repo "$REPO" --body "$comment" diff --git a/.github/workflows/protocol-watch.yml b/.github/workflows/protocol-watch.yml index 2d46582c..f0295cde 100644 --- a/.github/workflows/protocol-watch.yml +++ b/.github/workflows/protocol-watch.yml @@ -7,9 +7,11 @@ name: Protocol Watch # # Coverage is an explicit list of full repo-relative paths (WATCH), so files # from any directory can be tracked: the five protocol .macro definition files -# plus TxFlags.h (transaction flags) and TER.h (result codes), which the SDK -# mirrors as flag enums and the EngineResult enum but which live outside the -# macros. +# plus three headers the SDK mirrors but which live outside the macros — +# TxFlags.h (transaction flags), TER.h (result codes) and LedgerFormats.h +# (lsf ledger-object flags, vendored as a test fixture and diffed by +# TestULedgerFlagsConformance, which compares against a pinned copy and so +# cannot notice upstream moving on its own). # # State lives in the tracking issue body (labeled protocol-watch), not in the # repo and not in actions cache: no commits through branch protection, no @@ -40,6 +42,7 @@ env: include/xrpl/protocol/detail/transactions.macro include/xrpl/protocol/TxFlags.h include/xrpl/protocol/TER.h + include/xrpl/protocol/LedgerFormats.h ISSUE_TITLE: 'Protocol watch: rippled develop' ISSUE_LABEL: protocol-watch diff --git a/.github/workflows/release-watch.yml b/.github/workflows/release-watch.yml index 7ebab21a..0e891647 100644 --- a/.github/workflows/release-watch.yml +++ b/.github/workflows/release-watch.yml @@ -18,9 +18,10 @@ name: Release Watch # # The nightly stand config (rippled.batchv11.cfg) is intentionally NOT touched # here: its amendment lists must match the pinned nightly xrpld build -# (ARG XRPLD_VERSION in Dockerfile.nightly), not the latest stable tag. -# Regenerate it manually when bumping the nightly pin: -# .ci-config/generate-amendments.sh .ci-config/rippled.batchv11.cfg +# (ARG XRPLD_VERSION in Dockerfile.nightly), not the latest stable tag. That pin +# has its own weekly watcher, nightly-pin-watch.yml, which bumps it once it is +# older than the allowance and regenerates the config from the matching develop +# commit. By hand: .ci-config/bump-nightly-pin.sh on: schedule: diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs index 3f00774d..e74ebc63 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs @@ -35,5 +35,6 @@ public partial class Field public static readonly AmountField LPTokenBalance = new AmountField(nameof(LPTokenBalance), 31); public static readonly AmountField FeeAmount = new AmountField(nameof(FeeAmount), 32); public static readonly AmountField MaxFee = new AmountField(nameof(MaxFee), 33); + public static readonly AmountField FeeAmountDelta = new AmountField(nameof(FeeAmountDelta), 34); } } diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs index 3bace273..8db8b6d9 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs @@ -7,5 +7,6 @@ namespace Xrpl.BinaryCodec.Enums public partial class Field { public static readonly Int32Field LoanScale = new Int32Field(nameof(LoanScale), 1); + public static readonly Int32Field RemainingOwnerCountDelta = new Int32Field(nameof(RemainingOwnerCountDelta), 2); } } diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs index c611d165..64e90439 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs @@ -56,7 +56,7 @@ public partial class Field public static readonly Uint32Field FirstNFTokenSequence = new Uint32Field(nameof(FirstNFTokenSequence), 50); public static readonly Uint32Field OracleDocumentID = new Uint32Field(nameof(OracleDocumentID), 51); public static readonly Uint32Field PermissionValue = new Uint32Field(nameof(PermissionValue), 52); - public static readonly Uint32Field MutableFlags = new Uint32Field(nameof(MutableFlags), 53); + public static readonly Uint32Field ImmutableFlags = new Uint32Field(nameof(ImmutableFlags), 53); public static readonly Uint32Field StartDate = new Uint32Field(nameof(StartDate), 54); public static readonly Uint32Field PaymentInterval = new Uint32Field(nameof(PaymentInterval), 55); public static readonly Uint32Field GracePeriod = new Uint32Field(nameof(GracePeriod), 56); diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs index 28287e34..01a4ca2f 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs @@ -10,6 +10,7 @@ public partial class Field public static readonly Uint8Field Method = new Uint8Field(nameof(Method), 2); public static readonly Uint8Field Scale = new Uint8Field(nameof(Scale), 4); public static readonly Uint8Field AssetScale = new Uint8Field(nameof(AssetScale), 5); + public static readonly Uint8Field LEVersion = new Uint8Field(nameof(LEVersion), 6); public static readonly Uint8Field TickSize = new Uint8Field(nameof(TickSize), 16); public static readonly Uint8Field UNLModifyDisabling = new Uint8Field(nameof(UNLModifyDisabling), 17); public static readonly Uint8Field HookResult = new Uint8Field(nameof(HookResult), 18); diff --git a/Base/Xrpl.BinaryCodec/Enums/definitions.json b/Base/Xrpl.BinaryCodec/Enums/definitions.json index 0943aa48..62446095 100644 --- a/Base/Xrpl.BinaryCodec/Enums/definitions.json +++ b/Base/Xrpl.BinaryCodec/Enums/definitions.json @@ -691,7 +691,7 @@ } ], [ - "MutableFlags", + "ImmutableFlags", { "isSerialized": true, "isSigningField": true, @@ -2490,6 +2490,16 @@ "type": "Int32" } ], + [ + "RemainingOwnerCountDelta", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 2, + "type": "Int32" + } + ], [ "TransactionMetaData", { @@ -3150,6 +3160,16 @@ "type": "UInt8" } ], + [ + "LEVersion", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 6, + "type": "UInt8" + } + ], [ "TickSize", { @@ -3480,6 +3500,16 @@ "type": "Amount" } ], + [ + "FeeAmountDelta", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 34, + "type": "Amount" + } + ], [ "RemainingOwnerCount", { diff --git a/Base/Xrpl.BinaryCodec/Types/PathSet.cs b/Base/Xrpl.BinaryCodec/Types/PathSet.cs index f4686e66..3108f78b 100644 --- a/Base/Xrpl.BinaryCodec/Types/PathSet.cs +++ b/Base/Xrpl.BinaryCodec/Types/PathSet.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; using Xrpl.BinaryCodec.Binary; @@ -8,7 +9,7 @@ namespace Xrpl.BinaryCodec.Types { - /// The object representation of a Hop, an issuer AccountID, an account AccountID, and a Currency + /// The object representation of a Hop, an issuer AccountID, an account AccountID, and a Currency or an MPTokenIssuanceID public class PathHop { #region Constant for masking types of a Hop @@ -19,6 +20,10 @@ public class PathHop public const byte TypeCurrency = 0x10; /// type issuer const byte public const byte TypeIssuer = 0x20; + /// MPTokenIssuanceID const byte (rippled 3.2.0+, MPTokensV2 amendment) + public const byte TypeMpt = 0x40; + /// Every bit a hop type byte is allowed to carry + public const byte TypeAll = TypeAccount | TypeCurrency | TypeIssuer | TypeMpt; #endregion /// account AccountID @@ -27,17 +32,34 @@ public class PathHop public readonly AccountId Issuer; /// Currency public readonly Currency Currency; - /// Hop type - public readonly int Type; + /// MPTokenIssuanceID, mutually exclusive with + public readonly Hash192 MptIssuanceId; + /// Hop type byte, synthesized from the fields present + public readonly byte Type; /// Create a Hop /// account AccountID /// issuer AccountID /// Currency public PathHop(AccountId account, AccountId issuer, Currency currency) + : this(account, issuer, currency, null) { + } + /// Create a Hop + /// account AccountID + /// issuer AccountID + /// Currency, mutually exclusive with mptIssuanceId + /// MPTokenIssuanceID, mutually exclusive with currency + public PathHop(AccountId account, AccountId issuer, Currency currency, Hash192 mptIssuanceId) + { + if (currency != null && mptIssuanceId != null) + { + throw new InvalidJsonException("Path step cannot hold both currency and mpt_issuance_id."); + } + Account = account; Issuer = issuer; Currency = currency; + MptIssuanceId = mptIssuanceId; Type = SynthesizeType(); } /// Deserialize Hot @@ -45,7 +67,18 @@ public PathHop(AccountId account, AccountId issuer, Currency currency) /// public static PathHop FromJson(JsonNode json) { - return new PathHop(json["account"], json["issuer"], json["currency"]); + JsonNode mptIssuanceId = json["mpt_issuance_id"]; + if (mptIssuanceId != null + && (!(mptIssuanceId is JsonValue mptJv) || mptJv.GetValueKind() != JsonValueKind.String)) + { + throw new InvalidJsonException("Path step property `mpt_issuance_id` must be a JSON string."); + } + + return new PathHop( + json["account"], + json["issuer"], + json["currency"], + mptIssuanceId == null ? null : Hash192.FromJson(mptIssuanceId)); } /// check that hop has issuer AccountID public bool HasIssuer() => Issuer != null; @@ -53,11 +86,13 @@ public static PathHop FromJson(JsonNode json) public bool HasCurrency() => Currency != null; /// check that hop has account AccountID public bool HasAccount() => Account != null; + /// check that hop has MPTokenIssuanceID + public bool HasMpt() => MptIssuanceId != null; /// /// generate type for current hop /// /// - public int SynthesizeType() + public byte SynthesizeType() { var type = 0; @@ -73,13 +108,18 @@ public int SynthesizeType() { type |= TypeIssuer; } - return type; + if (HasMpt()) + { + type |= TypeMpt; + } + return (byte)type; } /// Serialize Hop /// public JsonObject ToJson() { - JsonObject hop = new JsonObject { ["type"] = Type }; + // int, not byte: a JsonValue would refuse GetValue() for consumers of Decode + JsonObject hop = new JsonObject { ["type"] = (int)Type }; if (HasAccount()) { @@ -89,6 +129,10 @@ public JsonObject ToJson() { hop["currency"] = JsonValue.Create(Currency.ToString()); } + if (HasMpt()) + { + hop["mpt_issuance_id"] = JsonValue.Create(MptIssuanceId.ToString()); + } if (HasIssuer()) { hop["issuer"] = JsonValue.Create(Issuer.ToString()); @@ -160,17 +204,26 @@ public void ToBytes(IBytesSink buffer) var n = 0; foreach (var path in this) { + if (path.Count == 0) + { + throw new BinaryCodecException("Empty path in pathset"); + } if (n++ != 0) { buffer.Put(PathSeparatorByte); } foreach (var hop in path) { - buffer.Put((byte)hop.Type); + // Field order mirrors rippled STPathSet::add(): account, MPT, currency, issuer + buffer.Put(hop.Type); if (hop.HasAccount()) { buffer.Put(hop.Account.Buffer); } + if (hop.HasMpt()) + { + buffer.Put(hop.MptIssuanceId.Buffer); + } if (hop.HasCurrency()) { buffer.Put(hop.Currency.Buffer); @@ -207,6 +260,7 @@ public static PathSet FromJson(JsonNode token) /// Construct a PathSet from a BinaryParser /// /// A BinaryParser to read PathSet from + /// unused, kept for the ISerializedType parser signature /// public static PathSet FromParser(BinaryParser parser, int? hint=null) { @@ -214,9 +268,14 @@ public static PathSet FromParser(BinaryParser parser, int? hint=null) Path path = null; while (!parser.End()) { - byte type = parser.ReadOne(); - if (type == PathsetEndByte) + byte rawType = parser.ReadOne(); + if (rawType == PathsetEndByte) { + // a terminator right after a separator means a trailing empty path + if (path == null && pathSet.Count > 0) + { + throw new BinaryCodecException("Empty path in pathset"); + } break; } if (path == null) @@ -224,29 +283,46 @@ public static PathSet FromParser(BinaryParser parser, int? hint=null) path = new Path(); pathSet.Add(path); } - if (type == PathSeparatorByte) + if (rawType == PathSeparatorByte) { + if (path.Count == 0) + { + throw new BinaryCodecException("Empty path in pathset"); + } path = null; continue; } + if ((rawType & ~PathHop.TypeAll) != 0) + { + throw new BinaryCodecException("Bad path element in pathset: unknown type bits"); + } + if ((rawType & PathHop.TypeCurrency) != 0 && (rawType & PathHop.TypeMpt) != 0) + { + throw new BinaryCodecException("Bad path element in pathset: both currency and MPT"); + } AccountId account = null; AccountId issuer = null; Currency currency = null; + Hash192 mptIssuanceId = null; - if ((type & PathHop.TypeAccount) != 0) + if ((rawType & PathHop.TypeAccount) != 0) { account = AccountId.FromParser(parser); } - if ((type & PathHop.TypeCurrency) != 0) + if ((rawType & PathHop.TypeMpt) != 0) + { + mptIssuanceId = Hash192.FromParser(parser); + } + if ((rawType & PathHop.TypeCurrency) != 0) { currency = Currency.FromParser(parser); } - if ((type & PathHop.TypeIssuer) != 0) + if ((rawType & PathHop.TypeIssuer) != 0) { issuer = AccountId.FromParser(parser); } - var hop = new PathHop(account, issuer, currency); + var hop = new PathHop(account, issuer, currency, mptIssuanceId); path.Add(hop); } @@ -254,4 +330,4 @@ public static PathSet FromParser(BinaryParser parser, int? hint=null) } } -} \ No newline at end of file +} diff --git a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj index 770d5a8f..d38c8777 100644 --- a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj +++ b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj @@ -13,7 +13,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.9.0.0 + 10.11.0.0 diff --git a/CHANGES.md b/CHANGES.md index 19dab341..43133aa8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,106 @@ # Changes +## 10.11.0.0 08/04/2026 + +* **MPT path steps (`0x40`)** — `PathSet` only knew the three classic hop-type bits (`0x01` account, `0x10` currency, `0x20` issuer). rippled added `STPathElement::TypeMpt = 0x40` in **3.2.0**, so a hop can now carry a 24-byte `MPTokenIssuanceID` instead of a currency. The gap was silent in both directions: `FromParser` matched none of its masks on a `0x40` byte, produced an empty hop and left the 24 MPTID bytes unread — every following byte was then parsed at the wrong offset — while `SynthesizeType` had no way to emit the bit at all. Now handled end to end: + * `PathHop.MptIssuanceId` (`Hash192`) with a second constructor, `HasMpt()` and the `TypeMpt`/`TypeAll` byte constants; `currency` and `mpt_issuance_id` in one step throw `InvalidJsonException`, matching rippled, which throws `bad path element: MPT and Currency` + * serialization order mirrors `STPathSet::add()` — type byte, then account(20), MPTID(24), currency(20), issuer(20) + * `FromParser` now rejects what rippled rejects: a type byte carrying bits outside `TypeAll` (`0x71`), currency together with MPT, and an empty path — a leading or doubled `0xFF` separator, or a terminator that follows one. Previously any garbage byte was accepted and silently mis-parsed, and an empty path survived decoding but vanished on re-encoding, so the blob and the transaction hash no longer matched the bytes that were read + * `ToBytes` throws on an empty `Path` instead of writing it away silently — the encoding side of the same asymmetry + * a non-string `mpt_issuance_id` raises `InvalidJsonException` instead of a raw `InvalidOperationException` from the JSON node, matching how `Amount` and `Issue` report the same mistake + * `Payment.IsPathStep` accepts `mpt_issuance_id` as a valid step asset and now follows rippled's `toStrand()` rules instead of the looser `xrpl.js` port it was: `account` combined with `currency`, `issuer` or `mpt_issuance_id`, and `currency` combined with `mpt_issuance_id`, are all `temBAD_PATH` upstream and are rejected before the transaction is sent. `xrpl.js` `isPathStep` still accepts `account` + asset — that is a gap on their side, not a compatibility requirement + * `TestUPathSet` pins the layout of both the classic and the MPT hop against rippled's, plus the round trip and every rejection path; `TestUPathStep` pins the step-validation rules against `toStrand()` + * Note this is ahead of the network: `MPTokensV2` is not enabled on mainnet (and not currently in `Majorities`), so MPT hops cannot yet appear in a validated ledger. `xrpl.js` and `xrpl-py` do not handle `0x40` either +* **`Path.MPTokenIssuanceID`** — the `mpt_issuance_id` key of a path step was missing from the model, so a step read from `ripple_path_find`/`path_find` could not be represented, let alone sent back +* **`Path.TypeHex` removed** (**breaking**, no `[Obsolete]` grace period, consistent with the 10.11.0.0 removal of ledger-object properties that are not protocol fields) — rippled removed `type_hex` from `STPath::getJson` in **1.7.0** (commit `f0724694`); only the unused `JSS(type_hex)` declaration survives in `jss.h`. No server has emitted the field for five years, so the property could never be anything but `null` — there is nothing to deprecate, only dead surface to delete. Verified against mainnet: 19 transactions carrying `Paths` across three consecutive ledgers, 21 path steps, `type` present in all 21 and `type_hex` in none, plus `ripple_path_find` on `s1`/`s2.ripple.com`. A response from a pre-1.7.0 server still deserializes — the unmapped key is ignored, which `TestUPathStepIgnoresLegacyTypeHex` pins +* **`Path.Type` is a `[Flags]` enum now** (**breaking**) — the hop type is a bitmask, but the model spelled it as a bare `int?`, so callers compared against magic `48`. It is now `PathStepType` (`Xrpl.Models.Enums`), matching how ledger objects already type their flags (`AccountRootFlags` and eight more) and how `TransactionType`/`LedgerEntryType` already exist model-side next to their codec counterparts. The enum is deliberately **not** shared with `Xrpl.BinaryCodec`: the codec stays byte-level — `PathHop.Type` is a `byte` synthesized from the `TypeAccount`/`TypeCurrency`/`TypeIssuer`/`TypeMpt` constants — so the model does not drag a codec namespace into its public surface. The wire format is unchanged: `XrplJsonOptions` deliberately registers no global `JsonStringEnumConverter` because XRPL protocol enums are numeric, and a value carrying a bit the enum does not declare survives deserialization untouched, which `TestUPathStep` pins along with the numeric wire form. The one behavioural loss: `"type":"48"` sent as a *string* no longer parses, since `NumberHandling.AllowReadingFromString` does not apply to enums; rippled always sends it as a number +* **`Path.Type` documented as read-only** — the XRPL docs mark it deprecated, but every rippled version still emits it on every step, so it stays. What the doc comment now states is that it is ignored on the way out: rippled's `STParsedJSON` reads only `account`/`currency`/`mpt_issuance_id`/`issuer` from a submitted step, and the binary codec derives the byte from the fields actually present. Pinned by `TestUPathSetHopTypeIsSynthesizedNotReadFromJson` — dropping `type`, or setting a deliberately wrong one, must not change the blob +* **rippled 3.3.0 — the CI stand and two protocol surfaces the SDK had modelled from a stale `develop` snapshot** (**breaking**). The stand moves from 3.2.1 to the 3.3.0 release image, which activates `BatchV1_1`, `Sponsor`, `PermissionDelegationV1_1`, `DynamicMPT`, `ConfidentialTransfer` and `fixCleanup3_3_0` at genesis, so 44 previously `AmendmentGuard`-skipped integration tests run for real on every CI run. 17 of them failed there: both features had been implemented against the nightly build the stand is pinned to (`3.3.0~b1+202607110018`, 11 Jul 2026) and upstream changed their shape before the release. Neither change is visible in `definitions.json` field codes alone, which is why nothing caught it earlier — see the Definitions Watch note below: + * **DynamicMPT: `MutableFlags` is now `ImmutableFlags`, with the meaning inverted.** Same `UInt32` field, same nth 53, same bit values — but a set bit no longer means "this may be changed later", it means "this is frozen forever" (`rippled` `MPTokenIssuanceSet::preclaim`: `isImmutable(flag) => currentImmutableFlags & flag`). An issuance created without the field is therefore fully mutable, where before it was fully immutable — the exact opposite default. Because the field code did not change, the old models produced a blob the node accepted and then read backwards: mutations came back `tecNO_PERMISSION`, freezes silently succeeded, and `ledger_entry` returned an `ImmutableFlags` key the model did not bind. `MPTokenIssuanceCreateMutableFlags` and `MPTokenIssuanceSetMutableFlags` are replaced by a single `MPTokenIssuanceImmutableFlags` (`tif*`, aliasing the `lsif*` ledger constants) shared by both transactions and `LOMPTokenIssuance.ImmutableFlags` + * **DynamicMPT: enabling a capability moved from a field to transaction flags.** The old `MPTokenIssuanceSet.MutableFlags = tmfMPTSet*` no longer exists; a capability is now enabled through `Flags = tfMPTSetCanLock | tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance` (0x04–0x100), added to `MPTokenIssuanceSetFlags` next to the existing `tfMPTLock`/`tfMPTUnlock`. `ImmutableFlags` on the same transaction now does the opposite job — it freezes capabilities and fields, OR-ed into the ledger object, never cleared + * **Sponsor: `SponsorshipSet` takes deltas, not absolute values.** `FeeAmount` and `RemainingOwnerCount` are fields of the `Sponsorship` **ledger object** only; the transaction carries `FeeAmountDelta` (`Amount`, nth 34) and `RemainingOwnerCountDelta` (**`Int32`**, nth 2) — signed changes applied to what the object already holds. Sending the old fields is not a semantic mismatch but a hard parse error: `STObject::applyTemplate` rejects any field outside the format with `invalidTransaction — Field 'FeeAmount' found in disallowed location`, which is what 13 of the 17 failures were. `SponsorshipSet.FeeAmount`/`RemainingOwnerCount` become `FeeAmountDelta` (`Currency`) / `RemainingOwnerCountDelta` (`int?`, signed — a negative delta reclaims budget); `LOSponsorship` is unchanged, it already matched the object. Client-side validation follows `SponsorshipSet::preflight`: a delta must be non-zero, `FeeAmountDelta` must be XRP, and `tfDeleteObject` may not carry any of the three modification fields + * `definitions.json` + the three generated `Field.*` partials carry the renamed and the two new fields; `Common.TryGetInt32` was added for the signed delta, the codec already had `Int32Type` +* **The nightly pin now has a watcher** — `nightly-pin-watch.yml`, weekly. The pin is what `definitions-watch` sees as "develop", so leaving it in place quietly narrows that check to whatever rippled looked like when the pin was last touched; the two 3.3.0 renames above sat undetected behind a pin from 11 July. Dropping the pin is not an option — the nightly build timestamp shrank from 14 to 12 digits mid-2026, so Debian version ordering ranks old builds above new ones and an unpinned install gets a stale binary: + * `.ci-config/bump-nightly-pin.sh` does the move: newest `xrpld` build from the nightly apt channel, `ARG XRPLD_VERSION` rewritten, `rippled.batchv11.cfg` regenerated from the develop commit **encoded in that version string** — config and binary cannot drift apart, which is the failure mode the old manual two-step invited. `--check` reports the pin, the newest build and the pin's age without touching anything. Both timestamp formats are compared by their common `YYYYMMDDHHMM` prefix + * the workflow bumps only once the pin is older than `MAX_PIN_AGE_DAYS` (21) — nightly publishes several builds a day, and a weekly PR would be noise rather than signal; `workflow_dispatch` takes a `force` input for the exceptions. It then builds and starts the stand on the new pin and requires the AMM sentinel amendment to come up enabled at genesis, which is what proves the regenerated config was accepted rather than silently ignored, and attaches the definitions diff against the new build to the PR body — a `node-only` field there is the SDK being behind develop, reported instead of hidden + * credentials, idempotency and the tracking-issue fallback follow release-watch exactly, including the one-notification-per-failure-streak rule +* **Cancellation no longer disappears into the autofill fee fallbacks** — `FetchCounterpartySignerCount` and `FetchLoan` wrap their client call in a broad `catch`, which is right for the case they exist for (the counterparty account or the Loan object is not there yet, and preclaim will report it) but also swallowed an `OperationCanceledException` raised from the caller's own token. Autofill then carried on and wrote a fee derived from the fallback — one signer, no loan — for a request the caller had already abandoned. Both catches now carry `when (!cancellationToken.IsCancellationRequested)`, which lets a caller's cancellation through while a client-side timeout, which does not cancel that token, still falls back as before. Covered in both directions: a cancelled token must throw and leave no `Fee` behind, an unreadable Loan object must still fall back +* **`MPTokenIssuanceSet` validation reports a malformed `Flags` as `ValidationException`** — it went through `Convert.ToUInt32`, which throws `FormatException` or `InvalidCastException` on a non-numeric value, while the `ImmutableFlags` check two lines below reports `ValidationException` like the rest of the validators. Callers catching `ValidationException` did not catch the other two +* **The conformance fixtures are re-pinned to the 3.3.0 tag** — `transactions.macro` and `LedgerFormats.h` now come from the release commit (`00a178fb`) instead of a July `develop` sha and `3.3.0-rc1`; `ledger_entries.macro` stays on `develop` (`9859e5ce`) for the reason its `.ref` already gives — `sfLEVersion` exists only there. Both macro files are byte-identical to upstream and re-verifiable with the `curl … | diff` line in each `.ref`. This is what makes the guards test against the version CI actually runs: + * `RippledLedgerFlags.Parse` learned to read the `lsif*` values. In 3.3.0 they are no longer a `LEDGER_OBJECT(MPTokenIssuanceMutable, …)` block but plain `inline constexpr std::uint32_t` constants next to the macro list, so the flag guard would have quietly lost that enum entirely. They are reported under a synthetic `MPTokenIssuanceImmutable` object, and a parse that finds none of them now throws instead of returning a thinner table +* **Why the weekly Definitions Watch stayed green through all of this** — `definitions-watch.yml` raises a stand from `docker-compose.batchv11.yml`, i.e. the **pinned** nightly `XRPLD_VERSION`. While the pin is stale the monitor diffs `definitions.json` against a build older than the one CI runs, and reports "in sync" about the past. The pin needs to move with every stable bump, not only when a new amendment is wanted + +* **Autofill covers the three remaining transactors with a special base fee** — `Transactor::calculateBaseFee` is overridden by ten transactors upstream, and the fee sugar implemented only some of them. The three that were missing all **underpay**, which is the failing direction: a fee below the required minimum is rejected with `telINSUF_FEE_P` instead of being topped up. Each is verified against the rippled source rather than inferred from the field layout: + * **`LoanSet` no longer assumes a single counterparty signature.** The old formula was a flat `baseFee * 2`, correct only when the counterparty signs with its master key. `LoanSet::calculateBaseFee` charges one base fee per entry of `CounterpartySignature.Signers`, so a counterparty that multi-signs made the autofilled fee too low. When the signature is already attached — `LoanSigningHelper` ran first — its signers are counted directly; when it is not, which is the usual order during autofill, the counterparty's signer list size is fetched and used, matching what xrpl.js does for the same transaction. Absent signer list, or an account that does not exist yet, falls back to one signature + * **`LoanPay` charges per five payments processed.** `LoanPay::calculateBaseFee` multiplies the *whole* Transactor cost — signatures included — by one increment per `kLoanPaymentsPerFeeIncrement` (5) payments the transaction is expected to make, capped at `kLoanMaximumPaymentsPerTransaction / 5` (20). Paying off six or more scheduled payments in one transaction therefore costs at least twice the base fee, and nothing in the SDK accounted for it. The estimate reads the `Loan` object, derives the per-payment amount as `roundPeriodicPayment(PeriodicPayment, LoanScale) + LoanServiceFee` — rounding up to whole units for XRP and MPT, to a multiple of `10^LoanScale` for IOUs, as `roundToAsset` does — and divides the transaction `Amount` by it. Every path rippled short-circuits is mirrored: `tfLoanFullPayment` and `tfLoanLatePayment` do one set of calculations, `PaymentRemaining <= 5` needs no increments, and an unreadable `Loan` object falls back to the normal cost the same way rippled leaves the error to preclaim. The asset's integrality is taken from the transaction's own `Amount`, which rippled requires to match the vault asset, so no broker/vault lookups are needed + * **Confidential MPT transactions pay the confidential multiplier.** All five (`ConfidentialMPTSend`, `ConfidentialMPTConvert`, `ConfidentialMPTConvertBack`, `ConfidentialMPTMergeInbox`, `ConfidentialMPTClawback`) call `Transactor::calculateBaseFee` with `kConfidentialFeeMultiplier` = 9, i.e. ten base fees for a single-signed transaction, paying for the cryptographic proofs they carry. They were being autofilled at one base fee — a tenfold underpayment + +* **Repeated `OnConnected` handler failures now back off** — the give-up branch added earlier is bounded by `MaxReconnectAttempts`, but only when `StopAfterMaxAttempts` is set. With it turned off there is no give-up at all, and the delay between retries never grew: this path tears the reconnect loop down and starts it again on every failure, `StopReconnectLoop` zeroes `_reconnectAttempts`, a fresh sequence zeroes it again, and `CalcBackoff` derives the delay from that counter alone. The client therefore repeated connect → handler failure → teardown at a constant `ReconnectBaseDelay` forever — a sustained connection load on exactly the node that cannot serve requests yet. `StartReconnectLoop` now takes the value to seed the counter with, and the handler-failure path seeds it from its own consecutive-failure count so the sequence keeps growing across failures. `TestRepeatedOnConnectedFailuresBackOff` pins it; reverting the fix makes that test show ~100 reconnects in 20s at a flat ~200ms interval +* **`_reconnectCts` is `volatile`** — the reconnect loop compares it by reference to decide whether it still owns the reconnect state, while `StopReconnectLoop`, `StartReconnectLoop` and `RetireCurrentSessionAndReconnectAsync` write it from other threads. A stale read could let a retired loop run one more iteration or make the owning loop stand down early. The other cross-thread fields in that class were already `volatile` +* **Lending guide corrected** — the `Loan Fields` table in `LendingProtocol-Guide` (both languages) listed four names the ledger object does not have: `Account` (the borrower is in `Borrower`), plus `Counterparty`, `PrincipalRequested` and `PaymentTotal`, which are fields of the **`LoanSet` transaction**. After `PrincipalRequested` was removed from `LOLoan` in this release the guide would have promised a property that no longer exists. Fixed, with a note pointing the three transaction fields at `LoanSet` +* **JSON serialization: the derived converter options are cached, and unknown ledger-object types no longer read as AccountRoot** — every polymorphic converter (`LOConverter`, both transaction converters, `MetaBinaryConverter`, `LedgerBinaryConverter`, `LONFTokenConverter`, `GenericStringConverter`) re-enters the serializer with its own converter removed to break the recursion, and each call built that derived `JsonSerializerOptions` from scratch — an allocation, a copy of the whole converter list and a structural-equality lookup in System.Text.Json's caching-context pool, per converted value, so once per element of a page. Type metadata was not rebuilt each time: since .NET 8 System.Text.Json shares a caching context between structurally equal options instances, which is what kept the per-call copy from being far worse than it was — measured on 200 `account_objects` pages of 200 entries, 456 ms / 47 MB allocated before against 217 ms / 29 MB after. `JsonSerializerOptionsCache` builds the derived options once per (source options, converter type), keyed weakly on the source so caller-supplied options stay collectable — safe because System.Text.Json freezes an options instance on first use, so what a converter is handed can no longer change. It also drops the reliance on that context pool, which is capped at 64 entries: + * `LOConverter.DetermineType` resolved an unrecognized `LedgerEntryType` to **`LOAccountRoot`**. `Enum.TryParse` writes `default(TEnum)` into its `out` on failure and `AccountRoot` is the zero value, overwriting the `Unknown` the variable was initialized with. A ledger object type newer than the SDK was therefore deserialized as an account root with every field silently dropped, instead of falling back to `BaseLedgerEntry` the way `LedgerEntryTypeConverter` and `NodeConverterBase` already do. Pinned from both entry points — a bare `BaseLedgerEntry` and an `account_objects` page + * the `//todo change from class to interface and parse same as transactionResponse` on `AccountObjects.AccountObjectList` is dropped rather than implemented. The parsing half has been true since `LOConverter` was registered globally, and `BaseLedgerEntry` has to stay a concrete class precisely because it is the `Unknown` fallback — an interface would need a sentinel type, which is what `TransactionResponseUnknown` exists to be. Nothing pinned the polymorphism for the response model itself; `TestUAccountObjectsPolymorphism` now does + +* **Test-side fixes** — `TestUtils.GetFreePort` never handed out a port twice within the process (the OS is free to return a just-released port, and test classes run in parallel, so two callers could get the same one and the second mock would fail to bind on its background thread, surfacing as a timeout rather than an error); `TestUChangeServerFailure` checks the port is still free right before starting the second mock, so the remaining external race fails fast with a clear message; `RippledLedgerFlags.Parse` throws on a ledger object declared twice, matching `RippledLedgerEntryFormats.Parse`; the fixture entries in the test `.csproj` use `None Update` instead of `None Include`, since the SDK's default glob already includes them + +* **`TestULedgerEntryFieldsConformance` — the third conformance surface**, completing the set next to `TestUTxFormatConformance` (transaction fields) and `TestULedgerFlagsConformance` (ledger flags). `ledger_entries.macro` is the only place the protocol states which fields belong to which ledger object — `definitions.json` carries field codes and object types but not the per-object lists — and nothing checked it. A missing field produces no symptom: reading the object still succeeds and the value is silently dropped, which is how `LOAccountRoot` went without `WalletLocator`/`WalletSize` until a manual pass, and how `sfLEVersion` had to arrive through a protocol-watch notification instead of a red test: + * `Tests/Xrpl.Tests/Fixtures/ledger_entries.macro`, vendored byte-identical and pinned by sha in the `.ref`. Pinned to a **develop** commit rather than a tag, unlike `LedgerFormats.h`: the models track develop for fields, and `sfLEVersion` exists only after 07/30/2026, so a tag would report it as a field the SDK invented + * both directions are diffed — a field rippled declares and the model lacks, and a property the model exposes that is not a field of that object — and every ledger object must be registered against a model, so a newly added one fails the build instead of being skipped + * rippled's four **common fields** (`LedgerIndex`, `LedgerEntryType`, `Flags`, `Sponsor` from `LedgerFormats::getCommonFields()`) are excluded on both sides, mirroring how the TxFormat guard treats `commonFields`; `[JsonIgnore]` properties (computed helpers like `DataParsed`, `MPTokenMetadataRow`) never reach the wire and are excluded too + * verified by mutation: renaming a field's `JsonPropertyName` makes it report both halves (`Loan.Borrower … missing from LOLoan` and `LOLoan.BorrowerX … not a field of Loan`) + +* **Ledger-object properties that are not protocol fields — removed** (**breaking**, no `[Obsolete]` grace period, consistent with the 10.10.0.0 removal of the inert `ConnectionOptions`). None of them could ever hold a value: rippled builds each object from a fixed `SOTemplate`, so a field outside the template cannot appear in it. Confirmed against a live node (nightly stand, 3.3.0-b1) *and* across four rippled versions — 3.2.1, 3.3.0-b1, 3.3.0-rc1 and develop — none of these exists in any of them, including the unreleased one: + * `LOVault.DomainID` — proven with a positive control: a `VaultCreate` carrying `Data`, `AssetsMaximum` **and** `DomainID` succeeded, the first two came back on the object, `DomainID` did not, and it turned up on the linked share `MPTokenIssuance` instead — exactly what the macro comment (`no PermissionedDomainID ever (use MPTIssuance.sfDomainID)`) and `VaultCreate.cpp` (`.domainId = tx[~sfDomainID]`) describe + * `LOLoan.PrincipalRequested` — a field of the **LoanSet transaction**, not of the object: a real loan created with `PrincipalRequested = 10000000` stores it as `PrincipalOutstanding`, and the object carries no such field + * `LOCredential.OwnerNode` — Credential hangs in two directories and uses `IssuerNode`/`SubjectNode`. Zero-valued directory hints *are* serialized (a Loan object returns `"OwnerNode":"0"`), so its absence is real, not a default being omitted + * `LONFTokenPage.NFTokenPage`, `LOAmm.LedgerCurrentIndex`, `LOAmm.Validated` — the last two are fields of the `amm_info` **response envelope** (`ledger_current_index`, `validated`, snake_case), not of the AMM object; `LOAmm` is only ever deserialized as a ledger object, and `amm_info` has its own `AMMInfo` model + +* **`LOAmm` fixes** — two bugs the guard surfaced: + * **`AMMAccount` never deserialized**: the AMM object's field is `Account`, and the property had no `[JsonPropertyName]`, so it silently stayed null on every AMM object ever read. Now mapped to `Account`; the property name is unchanged, so no call site breaks + * the constructor set `LedgerEntryType = LedgerEntryType.AccountRoot` — an AMM object identified itself as an AccountRoot. Now `LedgerEntryType.AMM` + +* **Fields declared by the protocol but missing from the models** — `PreviousTxnID`/`PreviousTxnLgrSeq` on `LOAmm`, `LOAmendments`, `LODirectoryNode`, `LOFeeSettings` and `LONegativeUNL`. Both are `SoeOptional` on these objects upstream; without them the transaction that last touched the object could not be read through the typed API + +* **`sfLEVersion` — the Vault ledger entry's schema version** ([rippled #7817](https://github.com/XRPLF/rippled/pull/7817), merged into `develop` 07/30/2026, reported by protocol-watch). `UInt8` nth 6, `SoeDefault` on `ltVAULT`: it marks which accounting scheme a vault follows. Vaults created before cash-basis accounting was activated carry no `LEVersion` at all, and rippled resolves that absence as version 0 rather than an error — so an absent value is meaningful, not missing data: + * `definitions.json` + the generated `Field.Uint8` entry. **Both are required**: `definitions.json` is not read at runtime, it is the input to `Tools/GenerateEnums`, so a field added there alone travels nowhere. `TestULEVersion_BinaryRoundTrip` is what proves the round trip actually works rather than that the JSON was edited + * `LOVault.LEVersion` (`uint?`, matching the other UInt8 fields of that object) plus a `VaultVersion` enum naming the two values the protocol defines so far (`Legacy` = 0, `CashBasis` = 1) + * `TestULOVault_LEVersion_Deserialize` covers both shapes — the field present, and a legacy vault without it deserializing to `null` + * `Xrpl.BinaryCodec` bumped to **10.11.0.0**, aligned with `Xrpl` rather than to its own next minor (10.10.0.0): the codec ships the field, so the two move together and a consumer can read one version number off both. 10.10.x is simply skipped — the codec's last published version is 10.9.0, so no number is being reused. `Xrpl.AddressCodec` and `Xrpl.Keypairs` are untouched and keep 10.9.0.0 + +* **Ledger-object flags the protocol declares but the models never named** — an unnamed bit still arrives in the model as a number, so reading the object kept working and only the consumer's ability to test it by name was lost. That is why these went unnoticed; a field-by-field diff of rippled `LedgerFormats.h` (tag `3.3.0-rc1`) against every flag enum found four gaps: + * `MPTokenIssuanceFlags` + **`MPTCanHoldConfidentialBalance`** (0x80) — introduced by ConfidentialTransfer. The rest of the amendment was already complete (transactions 85–89, `IssuerEncryptionKey`/`AuditorEncryptionKey`, `ConfidentialOutstandingAmount`); only the flag had no name. Value confirmed against a live node: `MPTokenIssuanceSet` with `Flags = tfMPTSetCanHoldConfidentialBalance` moves the issuance from `Flags = 0` to `Flags = 128` + * `MPTokenFlags` + **`lsfMPTAMM`** (0x4) — a much older gap: the flag is present as far back as 3.2.1. `AMMCreate` sets it together with `lsfMPTAuthorized` to implicitly authorize an MPT asset for the AMM pseudo-account + * **`LOLoan.Flags`** — the Loan ledger object had no `Flags` property at all (and `BaseLedgerEntry` has none either), so `lsfLoanDefault`/`lsfLoanImpaired`/`lsfLoanOverpayment` were unreadable through the typed model: the default and impairment state of a loan could not be observed at all. Added as a typed `LoanFlags?` together with the enum + * new `SignerListFlags` (`lsfOneOwnerCount`) and `DirectoryNodeFlags` (`lsfNFTokenBuyOffers`/`lsfNFTokenSellOffers`) — both objects expose `Flags` as a raw `uint` and **keep doing so** (changing the property type would be breaking); the enums give consumers named constants to test bits against instead of magic numbers. The `LODirectoryNode.Flags` comment claiming "the protocol defines no flags for DirectoryNode objects" was false and is corrected + +* **`TestULedgerFlagsConformance` — the guard that would have caught all of the above** — `LedgerFormats.h` is the only place the protocol states which `lsf` flags belong to which ledger object (`definitions.json` carries field codes and entry types, but no flag values). Nothing checked it, which is how `lsfMPTAMM` survived several releases. The new test is the ledger-side counterpart of `TestUTxFormatConformance`: + * `Tests/Xrpl.Tests/Fixtures/LedgerFormats.h` is vendored byte-identical and pinned by sha in `LedgerFormats.h.ref`, verifiable with a plain `curl … | diff`. Pinned rather than live for the same reason as `transactions.macro`: upstream drift is protocol-watch's job, and a network-backed test would go red on Ripple's release schedule instead of ours + * `RippledLedgerFlags` parses the `LEDGER_OBJECT`/`LSF_FLAG` macro text and fails loudly — an unknown `LSF_FLAG*` variant or a parse yielding fewer than 10 objects / 50 flags throws rather than leaving the test green on an empty table + * the test diffs **both directions** (a flag rippled declares and the enum lacks, a flag the enum has and rippled does not) and requires every flagged object to be registered against a model enum, so a newly added ledger object fails the build instead of being skipped. `tf*` members sharing an enum with ledger flags (`OfferFlags.tfInnerBatchTxn`) and zero-valued members are excluded by rule + * name matching normalizes the `lsf`/`lsif`/`tif` prefixes, so `lsfMPTLocked` ≡ `MPTLocked` and rippled's `lsifMPTCanLock` ≡ the SDK's `tifMPTCanLock` (rippled itself aliases `tifX = lsifX` in `TxFlags.h`) + * verified by mutation, not just by passing: a wrong value, a removed flag and an unregistered object each make it fail with a readable message + * `protocol-watch` now watches `include/xrpl/protocol/LedgerFormats.h` as well. A pinned fixture cannot notice upstream moving — that signal is the watcher's job, and the header was missing from its list (which is the other half of why `lsfMPTAMM` went unnoticed for so long). The first run after this change reports the header as changed once, then carries it in the baseline like the rest + +* **DynamicMPT (XLS-94) integration coverage** — the immutability fields existed on the models but had never been exercised against a node. `AmendmentGuard` gains the `DynamicMPT` id (it matches what `generate-amendments.sh` already writes into the nightly stand's `[amendments]`), and `TestIDynamicMPT` covers the amendment end to end, each test reading the result back from the ledger object rather than trusting `EngineResult`: + * `ImmutableFlags` set at `MPTokenIssuanceCreate` reach `LOMPTokenIssuance` unchanged + * `MPTokenIssuanceSet` mutates `TransferFee` and `MPTokenMetadata` on an issuance that froze neither, and leaves `ImmutableFlags` unset (`doApply` only ORs that field when the transaction carries it) + * `tfMPTSetCanLock` raises `lsfMPTCanLock` on an issuance created **without** that capability + * a mutation of a frozen field is rejected with `tecNO_PERMISSION` and leaves the metadata untouched, whether the freeze came from the create or from a later set + * scenarios were derived from the transactor (`src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp` @ `3.3.0`), not from the docs — hence `tfMPTCanTransfer` at creation in the fee test: `preclaim` requires `lsfMPTCanTransfer` to be **already** set, and enabling it in the same transaction does not satisfy the rule + * amendment-gated, so it skips on the CI stand (rippled 3.2.x has `DynamicMPT` as `Supported::No`) and runs for real on the nightly stand + +* **An exception from an `OnConnected` handler no longer kills the client forever** — `Connection.OnceOpen` caught anything thrown by a consumer `OnConnected` handler and called `Disconnect()`, i.e. the *user* disconnect path: it set `_permanentlyDisconnected = true` and called `ClearReconnectState()`. After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened, `OnConnected` never fired again, and every later request threw `NotConnectedException("Client has been disconnected. Call Connect() to reconnect.")`. Nothing was logged and nothing was raised, so from the outside the client just went quiet: + * **The trigger is the most ordinary event there is — a node restart.** `OnConnected` is the natural place to restore subscriptions, because the SDK does not restore them after a reconnect. A restarting node accepts TCP seconds before it starts answering requests, so the first `subscribe` after the reconnect runs into `RequestTimeout` (40 s) and throws. A consumer that lets the exception out — the reasonable "fail loudly, let the SDK reconnect" reaction — got the opposite: a silent, permanent death. Observed in production on a fleet of bots, each wedged for four hours after a node upgrade, one of them dying 69 seconds before the node came back + * A failing handler is now treated as what it is — a **connection** failure, not a user disconnect. The socket is torn down and the regular reconnect loop takes over with its usual exponential backoff, exactly as for a transport failure. The permanent-disconnect flag is never set on this path + * **A permanently broken handler cannot spin forever.** `OnceOpen` clears the reconnect state before invoking the handler, so the loop's own attempt counter resets on every successful TCP connect and could never converge. Consecutive handler failures are therefore counted separately (`_connectHandlerFailures`, reset on a successful handler run, on `Connect()` and on `ChangeServer()`); once they reach `MaxReconnectAttempts` with `StopAfterMaxAttempts` set, the client gives up deliberately — an immediate, actionable `NotConnectedException` instead of a silent five-minute wait — and `Connect()` clears the counter so recovery stays possible. With `StopAfterMaxAttempts = false` it keeps retrying, which is what that option asks for + * **The cause is now observable.** The exception is surfaced through `OnError` with `errorMessage = "connectHandlerError"` (the same shape already used for stream-handler failures) and through `OnConnectionStatus` — previously the reason the client died was reported nowhere at all + * `TestUOnConnectedHandlerFailure` pins all four properties against the mock rippled: a transient failure recovers and the client is usable again, the failure is reported through `OnError`, and a permanently failing handler stops instead of looping +* **`ChangeServer` to a server that is not up leaves the client reconnecting instead of dead** — a second wedge of the same family, found while exercising the fix above through the Blazor demo (switch the network selector to a node that is down). `ChangeServer` set the *global* `_isIntentionalDisconnect` flag to filter late callbacks from the socket it was retiring, and that flag was only ever reset in `OnceOpen`. If the new server never came up, `OnceOpen` never ran: `OnConnectionFailed` then read the failure of the **new** connection as a user disconnect, reported `"Connection closed permanently."`, started no reconnect loop, and every later call — including `ChangeServer` itself — failed with the misleading `"No connection attempt in progress. Call Connect() first."` Starting the server afterwards changed nothing; the client was dead. Late callbacks are now filtered purely by the per-socket tracking that was already in place (`_userInitiatedSockets` plus the socket's own flag), exactly as the ping-timeout/network-drop path has always done — its code even carries a comment warning against setting the global flag for this reason. The flag is additionally cleared on entry, so a `ChangeServer` after a user `Disconnect()` is not suppressed by the leftover either. `TestUChangeServerFailure` pins both cases: the client reaches the new server once it appears, with and without a preceding `Disconnect()` +* **The reconnect loop no longer writes to a reconnect session it no longer owns** — `StopReconnectLoop()` cancels the loop's token without awaiting the loop, so a retired loop could still reach its body or its tail after a replacement had been installed and clear the *live* loop's `_reconnectMode`, reset its `_reconnectAttempts` or dispose its `_reconnectCts`. Pre-existing (`RetireCurrentSessionAndReconnectAsync` has always retired loops this way), but the handler-failure path above makes it far more reachable, so `ReconnectLoopAsync` now takes the `CancellationTokenSource` it owns and touches shared state only while that source is still the active one +* **`WaitForConnectionAsync` now rechecks the permanent-disconnect flag on every iteration**, not only once on entry. A caller already blocked there when the client is disconnected — by `Disconnect()` from another thread, or by the give-up path above — used to sit out the whole `ConnectionAcquisitionTimeout` (default five minutes) and then receive a generic `TimeoutException`. It now returns the actual reason immediately as a `NotConnectedException` +* **`WebSocketClient.SendMessageAsync` no longer swallows send failures silently** — it is `async void` and is invoked without `await` from `Connection.WebsocketSendAsync`, so a failed send could be reported to nobody: the pending request simply sat there until its 40-second `RequestTimeout` expired. The socket's error callback (previously dead code — nothing ever invoked or wired it) now carries the exception to `Connection.OnError` with `errorMessage = "socketSendError"`. Report-only: a failed send does not by itself mean the connection is gone, so this path never triggers a reconnect and the request is still bounded by `RequestTimeout` — but the cause is no longer invisible during diagnosis + ## 10.10.0.0 07/29/2026 * **`ConnectionOptions.authorization` did nothing — now it does** — the option was public on `XrplClient.ClientOptions` since the xrpl.js port, but `Connection.CreateWebSocket` was a block of commented-out JS pseudocode ending in `WebSocketClient.Create(url); // todo add options`, and `WebSocketClient` had no parameter to receive them. Nothing the caller set on `authorization`, `headers`, `proxy`, `trustedCertificates`, `key`, `passphrase` or `certificate` ever reached the socket: * `authorization` now produces `Authorization: Basic base64(value)` on the WebSocket upgrade handshake, matching xrpl.js `createWebSocket` — the value is the raw `user:password` pair, the SDK does the base64 diff --git a/CLAUDE.md b/CLAUDE.md index 3e4a6e4a..c14ddf33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ docker compose -f .ci-config/docker-compose.batchv11.yml down Amendment-dependent test classes use `Tests/Xrpl.Tests/Integration/AmendmentGuard.cs`: `ClassInitialize` checks the Amendments ledger object and marks tests inconclusive (skipped, exit 0) when the amendment is not active — so these tests are safe on the CI stand and run for real on the nightly stand. To gate a new test class, add the amendment id constant to `AmendmentGuard` and call `Assert.Inconclusive` from `TestInitialize` when inactive. Nightly stand specifics (see comments in `Dockerfile.nightly` / `rippled.batchv11.cfg`): -- rippled was renamed to **xrpld** on `develop`; the nightly apt channel publishes the `xrpld` package. The version must be **pinned**: the build-timestamp format shrank from 14 to 12 digits in mid-2026, so Debian version ordering ranks older builds above newer ones. To bump: pick the latest `xrpld` version from the `jammy nightly` Packages index at repos.ripple.com, update `ARG XRPLD_VERSION`, and regenerate the nightly amendment lists with `.ci-config/generate-amendments.sh .ci-config/rippled.batchv11.cfg` (the ref must match the pinned build — a newer ref can emit feature names unknown to the binary, which rejects them at startup). +- rippled was renamed to **xrpld** on `develop`; the nightly apt channel publishes the `xrpld` package. The version must be **pinned**: the build-timestamp format shrank from 14 to 12 digits in mid-2026, so Debian version ordering ranks older builds above newer ones. To bump, run `.ci-config/bump-nightly-pin.sh` (`--check` reports without changing anything): it picks the newest `xrpld` build from the `jammy nightly` Packages index at repos.ripple.com, rewrites `ARG XRPLD_VERSION` and regenerates the nightly amendment lists from the develop commit encoded in that version string. The ref must match the pinned build — a newer ref can emit feature names unknown to the binary, which rejects them at startup — which is why the script derives it from the version rather than taking it as an argument. `nightly-pin-watch.yml` runs the same script weekly and opens a PR once the pin is older than its allowance. - On xrpld (3.2.x and `develop`) the two amendment config sections do different jobs. `[amendments]` (` `, hash = sha512half of the name) registers genesis up-votes at `--start` — amendments become enabled **on-ledger** (Amendments object, `feature` RPC), which is what `AmendmentGuard`-gated tests check; only Supported::Yes amendments — unsupported ones (e.g. MPTokensV2 on 3.2.0) are skipped at genesis. `[features]` does NOT vote (startup logs say "will be down voted by default") but it DOES feed Rules presets: listed amendments are treated as active during **transaction processing** even when not enabled on-ledger — this is the only way to exercise Supported::No code paths (MPTokensV2 MPT-AMM tests run via this mechanism) and the reason `[features]` may be a superset of `[amendments]`. Without `[amendments]`, introspection reports everything disabled and guard-gated tests skip, even though preset-covered transactors work. Both sections are regenerated by `.ci-config/generate-amendments.sh ` — do not edit the lists by hand. ### Generate Documentation @@ -198,6 +198,8 @@ Output goes to `docs/` directory. Published to GitHub Pages. | `docs.yml` | Push to `release` | DocFx → GitHub Pages | | `protocol-watch.yml` | Weekly cron (Mon 06:00 UTC); manual | Diffs rippled develop `*.macro` protocol files vs the baseline in the `protocol-watch` tracking issue; comments there on changes | | `release-watch.yml` | Weekly cron (Mon 06:30 UTC); manual | Checks the CI stand against the latest stable rippled release; when a newer release has a Docker image, regenerates the stand config (`generate-amendments.sh`), smoke-tests it and opens a bump PR (via GitHub App `RELEASE_WATCH_APP_ID`/`RELEASE_WATCH_APP_PRIVATE_KEY` or `RELEASE_WATCH_PAT`; falls back to a `release-watch` issue comment) | +| `definitions-watch.yml` | Weekly cron (Mon 07:00 UTC); manual | Raises the nightly stand and diffs `definitions.json` against its `server_definitions`; red when the node has fields the SDK lacks. Its horizon is the nightly pin, so a stale pin makes it report on the past | +| `nightly-pin-watch.yml` | Weekly cron (Mon 05:00 UTC); manual | Checks the nightly `xrpld` pin against the nightly apt channel; once it is older than `MAX_PIN_AGE_DAYS` (21), runs `bump-nightly-pin.sh`, starts the stand on the new pin, and opens a bump PR (same credential ladder as release-watch; falls back to a `nightly-pin-watch` issue comment) | Integration tests do **not** run on PRs into `dev` — `dev` uses a GitHub merge queue, and the `integration` job runs on the `merge_group` event against the merge result before the merge lands ("Merge when ready" button). Promotion PRs into `release` still run the full suite directly. New pushes to a PR cancel its in-flight CI run (`concurrency`, PR events only). diff --git a/DocFx/ConfidentialMPT-Guide.md b/DocFx/ConfidentialMPT-Guide.md index 37c2bb7d..77542e3a 100644 --- a/DocFx/ConfidentialMPT-Guide.md +++ b/DocFx/ConfidentialMPT-Guide.md @@ -53,7 +53,7 @@ var set = new MPTokenIssuanceSet { Account = issuer.ClassicAddress, MPTokenIssuanceID = issuanceId, - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance, IssuerEncryptionKey = issuerElGamalPubKeyHex, }; ``` @@ -61,7 +61,7 @@ var set = new MPTokenIssuanceSet Rules enforced by rippled preflight (mirrored by SDK validation): - a non-zero `TransferFee` **cannot** be combined with enabling confidential balances (`temBAD_TRANSFER_FEE`); -- at issuance creation, `tmfMPTCannotEnableCanHoldConfidentialBalance` permanently forbids enabling privacy later; +- `tifMPTCanHoldConfidentialBalance` in `ImmutableFlags` — on the create or on any later set — permanently forbids enabling privacy; - an `AuditorEncryptionKey` requires an `IssuerEncryptionKey`. --- @@ -92,7 +92,7 @@ All amounts encrypted under holder/issuer/auditor keys are supplied by the **pro ## Ledger Objects -- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal string — a base-ten UInt64 field), `MutableFlags` +- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal string — a base-ten UInt64 field), `ImmutableFlags` - `LOMPToken`: confidential balance/inbox fields (encrypted blobs + counters) --- diff --git a/DocFx/ConfidentialMPT-Guide.ru.md b/DocFx/ConfidentialMPT-Guide.ru.md index 1476b3a5..298cf2ed 100644 --- a/DocFx/ConfidentialMPT-Guide.ru.md +++ b/DocFx/ConfidentialMPT-Guide.ru.md @@ -53,7 +53,7 @@ var set = new MPTokenIssuanceSet { Account = issuer.ClassicAddress, MPTokenIssuanceID = issuanceId, - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance, IssuerEncryptionKey = issuerElGamalPubKeyHex, }; ``` @@ -61,7 +61,7 @@ var set = new MPTokenIssuanceSet Правила preflight rippled (продублированы клиентской валидацией SDK): - ненулевой `TransferFee` **несовместим** со включением конфиденциальных балансов (`temBAD_TRANSFER_FEE`); -- при создании выпуска флаг `tmfMPTCannotEnableCanHoldConfidentialBalance` навсегда запрещает включение приватности в будущем; +- флаг `tifMPTCanHoldConfidentialBalance` в `ImmutableFlags` — при создании выпуска или в любой последующей транзакции — навсегда запрещает включение приватности; - `AuditorEncryptionKey` требует наличия `IssuerEncryptionKey`. --- @@ -92,7 +92,7 @@ var set = new MPTokenIssuanceSet ## Объекты леджера -- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal-строка — base-ten UInt64 поле), `MutableFlags` +- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal-строка — base-ten UInt64 поле), `ImmutableFlags` - `LOMPToken`: поля конфиденциального баланса/inbox (зашифрованные блобы + счётчики) --- diff --git a/DocFx/LendingProtocol-Guide.md b/DocFx/LendingProtocol-Guide.md index 9808c087..f3db8a8f 100644 --- a/DocFx/LendingProtocol-Guide.md +++ b/DocFx/LendingProtocol-Guide.md @@ -365,18 +365,20 @@ The lending protocol creates the following ledger objects: | Field | Type | Description | |-------|------|-------------| -| `Account` | AccountID | Borrower account | -| `Counterparty` | AccountID | Broker account | +| `Borrower` | AccountID | Borrower account | | `LoanBrokerID` | Hash256 | Reference to loan broker | -| `PrincipalRequested` | Number | Original loan amount | +| `LoanSequence` | UInt32 | Sequence number within the broker | | `PrincipalOutstanding` | Number | Remaining principal | | `TotalValueOutstanding` | Number | Total amount owed | +| `PeriodicPayment` | Number | Amount due per interval | | `InterestRate` | UInt32 | Annual interest rate | | `PaymentInterval` | UInt32 | Seconds between payments | -| `PaymentTotal` | UInt32 | Total number of payments | +| `GracePeriod` | UInt32 | Seconds before late fees apply | | `PaymentRemaining` | UInt32 | Remaining payments | | `StartDate` | UInt32 | Loan start (Ripple epoch) | +> `Counterparty`, `PrincipalRequested` and `PaymentTotal` are fields of the **`LoanSet` transaction**, not of the `Loan` object. rippled records the requested principal as `PrincipalOutstanding`, so the ledger entry carries no `PrincipalRequested` — and neither does `LOLoan`. + ### Querying Loan State Use `account_objects` to retrieve loans owned by an account: diff --git a/DocFx/LendingProtocol-Guide.ru.md b/DocFx/LendingProtocol-Guide.ru.md index 3d886c06..d808c471 100644 --- a/DocFx/LendingProtocol-Guide.ru.md +++ b/DocFx/LendingProtocol-Guide.ru.md @@ -365,18 +365,20 @@ await client.SubmitRequest(fullySigned.TxBlob); | Поле | Тип | Описание | |------|-----|----------| -| `Account` | AccountID | Аккаунт заёмщика | -| `Counterparty` | AccountID | Аккаунт брокера | +| `Borrower` | AccountID | Аккаунт заёмщика | | `LoanBrokerID` | Hash256 | Ссылка на кредитного брокера | -| `PrincipalRequested` | Number | Исходная сумма кредита | +| `LoanSequence` | UInt32 | Порядковый номер в рамках брокера | | `PrincipalOutstanding` | Number | Остаток основной суммы | | `TotalValueOutstanding` | Number | Общая задолженность | +| `PeriodicPayment` | Number | Сумма платежа за интервал | | `InterestRate` | UInt32 | Годовая процентная ставка | | `PaymentInterval` | UInt32 | Интервал между платежами (секунды) | -| `PaymentTotal` | UInt32 | Общее количество платежей | +| `GracePeriod` | UInt32 | Отсрочка до начисления пеней (секунды) | | `PaymentRemaining` | UInt32 | Оставшиеся платежи | | `StartDate` | UInt32 | Начало кредита (Ripple epoch) | +> `Counterparty`, `PrincipalRequested` и `PaymentTotal` — поля **транзакции `LoanSet`**, а не объекта `Loan`. Запрошенную сумму rippled записывает в `PrincipalOutstanding`, поэтому у ledger-объекта поля `PrincipalRequested` нет — как нет его и у `LOLoan`. + ### Запрос состояния кредита Используйте `account_objects` для получения кредитов аккаунта: diff --git a/DocFx/Sponsorship-Guide.md b/DocFx/Sponsorship-Guide.md index 2d150840..3163c67b 100644 --- a/DocFx/Sponsorship-Guide.md +++ b/DocFx/Sponsorship-Guide.md @@ -63,6 +63,11 @@ Created by `SponsorshipSet`, one per sponsor/sponsee pair: | `FeeAmount` | Remaining XRP budget for sponsored fees | | `RemainingOwnerCount` | How many more objects the sponsor will cover reserves for | +Both are ledger-object fields only. The transaction adjusts them with the signed +`FeeAmountDelta` / `RemainingOwnerCountDelta` fields — a positive delta tops the budget +up, a negative one returns it to the sponsor. Sending the absolute names in a +transaction is rejected outright (`Field 'FeeAmount' found in disallowed location`). + ### Require-signature mode By default a sponsee can spend the sponsorship budget without the sponsor's participation. `SponsorshipSet` flags flip that per dimension: @@ -91,14 +96,14 @@ var setup = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; setup = await client.Autofill(setup); await client.SubmitAndWait(setup, sponsor, true); // The sponsee deletes its own sponsorship (names the sponsor); -// deletion forbids the modification flags and FeeAmount/MaxFee/RemainingOwnerCount: +// deletion forbids the modification flags and FeeAmountDelta/MaxFee/RemainingOwnerCountDelta: var deletion = new SponsorshipSet { Account = sponsee.ClassicAddress, @@ -132,8 +137,8 @@ var tx = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; tx = await client.Autofill(tx); TransactionSummary result = await client.SubmitAndWait(tx, sponsor, true); diff --git a/DocFx/Sponsorship-Guide.ru.md b/DocFx/Sponsorship-Guide.ru.md index 434cbe0a..40fe6a3c 100644 --- a/DocFx/Sponsorship-Guide.ru.md +++ b/DocFx/Sponsorship-Guide.ru.md @@ -63,6 +63,11 @@ | `FeeAmount` | Остаток XRP-бюджета на спонсируемые комиссии | | `RemainingOwnerCount` | Сколько ещё объектов спонсор покроет резервами | +Оба поля существуют только у объекта леджера. Транзакция меняет их знаковыми полями +`FeeAmountDelta` / `RemainingOwnerCountDelta`: положительная дельта пополняет бюджет, +отрицательная возвращает его спонсору. Абсолютные имена в транзакции нода отвергает +(`Field 'FeeAmount' found in disallowed location`). + ### Режим обязательной подписи По умолчанию спонсируемый тратит бюджет без участия спонсора. Флаги `SponsorshipSet` переключают это по-измеренно: @@ -91,14 +96,14 @@ var setup = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; setup = await client.Autofill(setup); await client.SubmitAndWait(setup, sponsor, true); // Спонсируемый сам удаляет своё спонсорство (указывает спонсора); -// при удалении запрещены модификационные флаги и FeeAmount/MaxFee/RemainingOwnerCount: +// при удалении запрещены модификационные флаги и FeeAmountDelta/MaxFee/RemainingOwnerCountDelta: var deletion = new SponsorshipSet { Account = sponsee.ClassicAddress, @@ -132,8 +137,8 @@ var tx = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; tx = await client.Autofill(tx); TransactionSummary result = await client.SubmitAndWait(tx, sponsor, true); diff --git a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor index 81c4a731..0a213b84 100644 --- a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor +++ b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor @@ -397,7 +397,7 @@ await InvokeAsync(async () => { IsConnected = true; - CurrentServerUrl = client.connection.GetUrl(); + CurrentServerUrl = client.Url(); CurrentReconnectInfo = null; SessionStartTime = DateTime.Now; @@ -617,7 +617,7 @@ client.connection.OnTransaction += _onTransactionHandler; client.connection.OnLedgerClosed += _onLedgerClosedHandler; - CurrentServerUrl = client.connection.GetUrl(); + CurrentServerUrl = client.Url(); CurrentConnectionState = client.connection.CurrentConnectionState; await base.OnInitializedAsync(); @@ -627,7 +627,7 @@ if (client.connection.IsConnected()) { IsConnected = true; - CurrentServerUrl = client.connection.GetUrl(); + CurrentServerUrl = client.Url(); CurrentConnectionState = client.connection.CurrentConnectionState; StateHasChanged(); } @@ -719,8 +719,6 @@ AddStatusMessage($"Changing server to: {newServerUrl}...", MessageType.Info); await client.ChangeServer(newServerUrl); - - CurrentServerUrl = client.Url(); } catch (Exception ex) { @@ -730,6 +728,13 @@ } finally { + // Read the address back whether or not the connection succeeded. ChangeServer switches + // the client's target before it starts connecting, so a switch to a server that is down + // throws (the acquisition timeout) while the client is already reconnecting to the NEW + // address. Updating this only on success left the label showing the previous server, and + // the "Already connected to this server" guard above then compared against a stale value + // and refused a legitimate switch back. + CurrentServerUrl = client.Url(); IsChangingServer = false; StateHasChanged(); } diff --git a/Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs b/Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs new file mode 100644 index 00000000..a8bf99bb --- /dev/null +++ b/Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs @@ -0,0 +1,170 @@ +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Xrpl.BinaryCodec; + +namespace XrplTests.BinaryCodecLib.Types; + +/// +/// PathSet serialization tests. +/// Layout mirrors rippled STPathSet::add(): type byte, then account(20), MPTokenIssuanceID(24), +/// currency(20) and issuer(20) for whichever bits the type byte carries. +/// Type bits: 0x01 account, 0x10 currency, 0x20 issuer, 0x40 MPT (rippled 3.2.0+, MPTokensV2). +/// +[TestClass] +public class TestUPathSet +{ + private const string PathsFieldHeader = "0112"; + private const string PathSetEnd = "00"; + + // rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3 + private const string IssuerAccountHex = "7720BA5CE66725906C2D74C7E8ADB1557556691A"; + private const string CurrencyHex = "4249547800000000000000000000000000000000"; + private const string MptIssuanceId = "00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"; + + private static string PaymentWithPaths(string pathStepsJson) => @"{ + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""TransactionType"": ""Payment"", + ""Destination"": ""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"", + ""Amount"": { ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""value"": ""1"" }, + ""SendMax"": ""100000000"", + ""Fee"": ""12"", + ""Sequence"": 1, + ""Paths"": [[" + pathStepsJson + @"]] + }"; + + private static string Encode(string pathStepsJson) => + XrplBinaryCodec.Encode(JsonNode.Parse(PaymentWithPaths(pathStepsJson))); + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetCurrencyIssuerHopMatchesRippledLayout() + { + // Same shape as mainnet tx 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""type"": 48 }"); + + string expected = PathsFieldHeader + "30" + CurrencyHex + IssuerAccountHex + PathSetEnd; + StringAssert.Contains(encoded, expected, $"PathSet bytes should match rippled layout. Got: {encoded}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetHopTypeIsSynthesizedNotReadFromJson() + { + // rippled derives the type byte from the fields present and ignores the JSON "type"/"type_hex" + // keys on input, so neither a missing nor a wrong value may change the produced blob. + string withCorrectType = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""type"": 48 }"); + string withoutType = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + string withWrongType = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""type"": 1, ""type_hex"": ""0000000000000001"" }"); + + Assert.AreEqual(withCorrectType, withoutType, "Removing the type key must not change the blob"); + Assert.AreEqual(withCorrectType, withWrongType, "A wrong type/type_hex must not change the blob"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetMptHopMatchesRippledLayout() + { + string encoded = Encode(@"{ ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"" }"); + + string expected = PathsFieldHeader + "40" + MptIssuanceId + PathSetEnd; + StringAssert.Contains(encoded, expected, $"MPT hop should serialize as 0x40 + 24-byte MPTokenIssuanceID. Got: {encoded}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetMptWithIssuerHopMatchesRippledLayout() + { + string encoded = Encode(@"{ ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // rippled STPathSet::add() writes MPT before issuer + string expected = PathsFieldHeader + "60" + MptIssuanceId + IssuerAccountHex + PathSetEnd; + StringAssert.Contains(encoded, expected, $"MPT+issuer hop should serialize as 0x60 + MPTID + issuer. Got: {encoded}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetMptHopRoundTrips() + { + string encoded = Encode(@"{ ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + JsonNode decoded = XrplBinaryCodec.Decode(encoded); + JsonNode hop = decoded["Paths"][0][0]; + + Assert.AreEqual(MptIssuanceId, hop["mpt_issuance_id"]?.ToString(), "MPTokenIssuanceID should round-trip"); + Assert.AreEqual("rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3", hop["issuer"]?.ToString(), "Issuer should round-trip"); + Assert.AreEqual(0x60, hop["type"]?.GetValue(), "Decoded hop should report the synthesized type byte"); + Assert.IsNull(hop["currency"], "MPT hop must not carry a currency"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetCurrencyAndMptTogetherThrows() + { + Assert.ThrowsExactly( + () => Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"" }"), + "A path step holding both currency and mpt_issuance_id must be rejected, as rippled does"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetNonStringMptIssuanceIdThrows() + { + Assert.ThrowsExactly( + () => Encode(@"{ ""mpt_issuance_id"": 42 }"), + "A non-string mpt_issuance_id must be reported as invalid JSON, like Amount and Issue do"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetEmptyPathThrowsOnDecode() + { + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // insert a leading path separator, which makes the first path empty + string corrupted = encoded.Replace(PathsFieldHeader + "30", PathsFieldHeader + "FF30"); + Assert.AreNotEqual(encoded, corrupted, "Test setup should have inserted the separator"); + + Assert.ThrowsExactly( + () => XrplBinaryCodec.Decode(corrupted), + "An empty path must be rejected, as rippled does"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetEmptyPathThrowsOnEncode() + { + Assert.ThrowsExactly( + () => XrplBinaryCodec.Encode(JsonNode.Parse(PaymentWithPaths(string.Empty))), + "An empty path must not be encoded away silently"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetTrailingSeparatorThrowsOnDecode() + { + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // append a separator right before the terminator, i.e. a trailing empty path + string corrupted = encoded.Replace(IssuerAccountHex + "00", IssuerAccountHex + "FF00"); + Assert.AreNotEqual(encoded, corrupted, "Test setup should have inserted the trailing separator"); + + Assert.ThrowsExactly( + () => XrplBinaryCodec.Decode(corrupted), + "A terminator following a separator must be rejected, as rippled does"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetUnknownTypeBitsThrowOnDecode() + { + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // flip the hop type byte to a value carrying a bit outside TypeAll (0x71) + string corrupted = encoded.Replace(PathsFieldHeader + "30" + CurrencyHex, PathsFieldHeader + "02" + CurrencyHex); + Assert.AreNotEqual(encoded, corrupted, "Test setup should have patched the hop type byte"); + + Assert.ThrowsExactly( + () => XrplBinaryCodec.Decode(corrupted), + "A hop type byte with unknown bits must be rejected, as rippled does"); + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs new file mode 100644 index 00000000..a7026935 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client.Json; +using Xrpl.Models; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace XrplTests.Client.Json.Converters; + +/// +/// account_objects returns a heterogeneous array — every element carries its own LedgerEntryType. +/// LOConverter is registered globally, so the elements of List<BaseLedgerEntry> are resolved to the +/// concrete LO* types; nothing but these tests pins that for the response model itself. +/// +[TestClass] +public class TestUAccountObjectsPolymorphism +{ + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + private const string MixedResponse = @"{ + ""account"": ""r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59"", + ""account_objects"": [ + { + ""LedgerEntryType"": ""RippleState"", + ""Balance"": {""currency"": ""USD"", ""issuer"": ""rrrrrrrrrrrrrrrrrrrrBZbvji"", ""value"": ""-16.005""}, + ""Flags"": 131072, + ""HighLimit"": {""currency"": ""USD"", ""issuer"": ""r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59"", ""value"": ""5000""}, + ""LowLimit"": {""currency"": ""USD"", ""issuer"": ""rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B"", ""value"": ""0""}, + ""PreviousTxnID"": ""CFFF5CFE623C9543308C6529782B6A6532207D819795AAFE85555DB8BF390FE7"", + ""PreviousTxnLgrSeq"": 14365854, + ""index"": ""826CF5BFD28F3934B518D0BDF3231259CBD3FD0946E3C3CA0C97D2C75D2D1A09"" + }, + { + ""LedgerEntryType"": ""Check"", + ""Account"": ""rUn84CJZe1swmzfnRMHPBmTGVsQFhLtLTb"", + ""Destination"": ""rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy"", + ""SendMax"": ""100000000"", + ""Sequence"": 2, + ""PreviousTxnLgrSeq"": 8010340, + ""index"": ""49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0"" + }, + { + ""LedgerEntryType"": ""Escrow"", + ""Account"": ""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"", + ""Destination"": ""ra5nK24KXen9AHvsdFTKHSANinZseWnPcX"", + ""Amount"": ""10000"", + ""PreviousTxnLgrSeq"": 14328672, + ""index"": ""DC5F3851D8A1AB622F957761E5963BC5BD439D5C24AC6AD7AC4523F0640244AC"" + }, + { + ""LedgerEntryType"": ""SignerList"", + ""Flags"": 0, + ""OwnerNode"": ""0000000000000000"", + ""SignerQuorum"": 3, + ""index"": ""A9C28A28B85CD533217F5C0A0C7767666B093FA58A0F2D80026FCC4CD932DDC7"" + } + ], + ""ledger_hash"": ""053DF17D2289D1C4971C22F235BC1FCA7D4B3AE966F842E5819D0749E0B8ECD3"", + ""ledger_index"": 14378733, + ""validated"": true + }"; + + [TestMethod] + public void Deserialize_MixedAccountObjects_ResolvesEachElementToItsConcreteType() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + Assert.IsNotNull(response); + Assert.HasCount(4, response.AccountObjectList); + + Assert.IsInstanceOfType(response.AccountObjectList[0], typeof(LORippleState)); + Assert.IsInstanceOfType(response.AccountObjectList[1], typeof(LOCheck)); + Assert.IsInstanceOfType(response.AccountObjectList[2], typeof(LOEscrow)); + Assert.IsInstanceOfType(response.AccountObjectList[3], typeof(LOSignerList)); + } + + [TestMethod] + public void Deserialize_MixedAccountObjects_KeepsSubtypeFields() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + LORippleState state = (LORippleState)response.AccountObjectList[0]; + Assert.AreEqual("CFFF5CFE623C9543308C6529782B6A6532207D819795AAFE85555DB8BF390FE7", state.PreviousTxnID); + Assert.AreEqual("USD", state.Balance.CurrencyCode); + Assert.AreEqual("5000", state.HighLimit.Value); + + LOCheck check = (LOCheck)response.AccountObjectList[1]; + Assert.AreEqual("rUn84CJZe1swmzfnRMHPBmTGVsQFhLtLTb", check.Account); + Assert.AreEqual("rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy", check.Destination); + + LOEscrow escrow = (LOEscrow)response.AccountObjectList[2]; + Assert.AreEqual("rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", escrow.Account); + + LOSignerList signerList = (LOSignerList)response.AccountObjectList[3]; + Assert.AreEqual(3u, signerList.SignerQuorum); + } + + [TestMethod] + public void Deserialize_MixedAccountObjects_SetsLedgerEntryTypeAndIndex() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + Assert.AreEqual(LedgerEntryType.RippleState, response.AccountObjectList[0].LedgerEntryType); + Assert.AreEqual(LedgerEntryType.Check, response.AccountObjectList[1].LedgerEntryType); + Assert.AreEqual(LedgerEntryType.Escrow, response.AccountObjectList[2].LedgerEntryType); + Assert.AreEqual(LedgerEntryType.SignerList, response.AccountObjectList[3].LedgerEntryType); + + Assert.AreEqual( + "826CF5BFD28F3934B518D0BDF3231259CBD3FD0946E3C3CA0C97D2C75D2D1A09", + response.AccountObjectList[0].Index); + } + + /// + /// A ledger object type the SDK does not know must not throw — it falls back to the base entry. + /// This is why BaseLedgerEntry stays a concrete class. + /// + [TestMethod] + public void Deserialize_UnknownLedgerEntryType_FallsBackToBaseLedgerEntry() + { + string json = @"{ + ""account"": ""rTest"", + ""account_objects"": [ + { ""LedgerEntryType"": ""SomethingRippledAddedLater"", ""Whatever"": 1, ""index"": ""AABB"" } + ] + }"; + + AccountObjects response = JsonSerializer.Deserialize(json, Options); + + Assert.HasCount(1, response.AccountObjectList); + BaseLedgerEntry entry = response.AccountObjectList[0]; + Assert.AreEqual(typeof(BaseLedgerEntry), entry.GetType()); + Assert.AreEqual(LedgerEntryType.Unknown, entry.LedgerEntryType); + Assert.AreEqual("AABB", entry.Index); + } + + [TestMethod] + public void Serialize_MixedAccountObjects_WritesConcreteTypeFields() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + string json = JsonSerializer.Serialize(response, Options); + + Assert.Contains("\"LedgerEntryType\":\"RippleState\"", json); + Assert.Contains("\"LedgerEntryType\":\"Check\"", json); + Assert.Contains("rUn84CJZe1swmzfnRMHPBmTGVsQFhLtLTb", json); + + AccountObjects roundTrip = JsonSerializer.Deserialize(json, Options); + Assert.IsInstanceOfType(roundTrip.AccountObjectList[0], typeof(LORippleState)); + Assert.IsInstanceOfType(roundTrip.AccountObjectList[1], typeof(LOCheck)); + } + + /// + /// The list path is the one that used to build a fresh JsonSerializerOptions per element: + /// a full page must resolve every entry, not just the first. + /// + [TestMethod] + public void Deserialize_LargeAccountObjectsPage_ResolvesEveryElement() + { + System.Text.StringBuilder builder = new System.Text.StringBuilder(); + builder.Append(@"{""account"":""rTest"",""account_objects"":["); + for (int i = 0; i < 200; i++) + { + if (i > 0) builder.Append(','); + builder.Append(@"{""LedgerEntryType"":""Offer"",""Account"":""rTest"",""Sequence"":") + .Append(i) + .Append(@",""index"":""") + .Append(i.ToString("X64")) + .Append(@"""}"); + } + builder.Append("]}"); + + AccountObjects response = JsonSerializer.Deserialize(builder.ToString(), Options); + + Assert.HasCount(200, response.AccountObjectList); + for (int i = 0; i < 200; i++) + { + Assert.IsInstanceOfType(response.AccountObjectList[i], typeof(LOOffer)); + Assert.AreEqual((uint)i, ((LOOffer)response.AccountObjectList[i]).Sequence); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs index 8639201a..82396630 100644 --- a/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs +++ b/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs @@ -201,4 +201,32 @@ public void Read_PermissionedDomain_ReturnsLOPermissionedDomain() Assert.IsNotNull(result); Assert.IsInstanceOfType(result, typeof(LOPermissionedDomain)); } + + /// + /// A ledger object type this SDK does not know must fall back to the base entry. Enum.TryParse + /// writes default(LedgerEntryType) — AccountRoot — when it fails, so the fallback is easy to lose. + /// + [TestMethod] + public void Read_UnknownLedgerEntryType_ReturnsBaseLedgerEntry() + { + string json = @"{ + ""LedgerEntryType"": ""SomethingRippledAddedLater"", + ""Owner"": ""rOwner"", + ""index"": ""AABB"" + }"; + BaseLedgerEntry result = JsonSerializer.Deserialize(json, Options); + Assert.IsNotNull(result); + Assert.AreEqual(typeof(BaseLedgerEntry), result.GetType()); + Assert.AreEqual(LedgerEntryType.Unknown, result.LedgerEntryType); + Assert.AreEqual("AABB", result.Index); + } + + [TestMethod] + public void Read_MissingLedgerEntryType_ReturnsBaseLedgerEntry() + { + string json = @"{ ""Owner"": ""rOwner"" }"; + BaseLedgerEntry result = JsonSerializer.Deserialize(json, Options); + Assert.IsNotNull(result); + Assert.AreEqual(typeof(BaseLedgerEntry), result.GetType()); + } } diff --git a/Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs b/Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs new file mode 100644 index 00000000..d32bce47 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client.Json; +using Xrpl.Client.Json.Converters; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace XrplTests.Client.Json; + +/// +/// Polymorphic converters re-enter the serializer with their own converter removed. Building those +/// options per call allocated an options instance, copied the whole converter list and ran its own +/// structural-equality lookup in System.Text.Json's caching-context pool — for every value converted. +/// Type metadata was not rebuilt: since .NET 8 that pool shares one caching context between +/// structurally equal options instances. The cache does the work once per (source options, converter type). +/// +[TestClass] +public class TestUJsonSerializerOptionsCache +{ + [TestMethod] + public void WithoutConverter_SameSourceAndConverter_ReturnsSameInstance() + { + JsonSerializerOptions source = XrplJsonOptions.Default; + + JsonSerializerOptions first = JsonSerializerOptionsCache.WithoutConverter(source); + JsonSerializerOptions second = JsonSerializerOptionsCache.WithoutConverter(source); + + Assert.AreSame(first, second); + Assert.AreNotSame(source, first); + } + + [TestMethod] + public void WithoutConverter_RemovesOnlyTheRequestedConverter() + { + JsonSerializerOptions derived = JsonSerializerOptionsCache.WithoutConverter(XrplJsonOptions.Default); + + foreach (JsonConverter converter in derived.Converters) + Assert.IsNotInstanceOfType(converter, typeof(LOConverter)); + + bool keptOthers = false; + foreach (JsonConverter converter in derived.Converters) + { + if (converter is TransactionResponseConverter) + keptOthers = true; + } + + Assert.IsTrue(keptOthers, "Converters unrelated to the requested type must survive"); + } + + /// + /// The source must be copied, never stripped in place: mutating it would remove LOConverter from the + /// process-wide default options and every ledger object would silently degrade to a bare entry. + /// + [TestMethod] + public void WithoutConverter_LeavesTheSourceOptionsIntact() + { + JsonSerializerOptionsCache.WithoutConverter(XrplJsonOptions.Default); + + bool sourceStillHasIt = false; + foreach (JsonConverter converter in XrplJsonOptions.Default.Converters) + { + if (converter is LOConverter) + sourceStillHasIt = true; + } + + Assert.IsTrue(sourceStillHasIt, "XrplJsonOptions.Default must keep its LOConverter"); + } + + [TestMethod] + public void WithoutConverter_PreservesSourceSettings() + { + JsonSerializerOptions source = XrplJsonOptions.Default; + JsonSerializerOptions derived = JsonSerializerOptionsCache.WithoutConverter(source); + + Assert.AreEqual(source.DefaultIgnoreCondition, derived.DefaultIgnoreCondition); + Assert.AreEqual(source.PropertyNameCaseInsensitive, derived.PropertyNameCaseInsensitive); + Assert.AreEqual(source.NumberHandling, derived.NumberHandling); + } + + [TestMethod] + public void WithoutConverter_DifferentConverterTypes_ReturnDifferentInstances() + { + JsonSerializerOptions source = XrplJsonOptions.Default; + + JsonSerializerOptions withoutLo = JsonSerializerOptionsCache.WithoutConverter(source); + JsonSerializerOptions withoutTx = JsonSerializerOptionsCache.WithoutConverter(source); + + Assert.AreNotSame(withoutLo, withoutTx); + } + + [TestMethod] + public void WithoutConverter_DifferentSourceOptions_AreCachedSeparately() + { + JsonSerializerOptions otherSource = new JsonSerializerOptions(); + otherSource.Converters.Add(new LOConverter()); + + JsonSerializerOptions fromDefault = JsonSerializerOptionsCache.WithoutConverter(XrplJsonOptions.Default); + JsonSerializerOptions fromOther = JsonSerializerOptionsCache.WithoutConverter(otherSource); + + Assert.AreNotSame(fromDefault, fromOther); + Assert.AreSame(fromOther, JsonSerializerOptionsCache.WithoutConverter(otherSource)); + } + + /// + /// The regression this cache needs guarded is a converter quietly going back to building its own copy + /// per call. Options identity cannot detect that — System.Text.Json hands converters the options of the + /// pooled caching context, so a per-call copy still shows up as one shared instance. What does detect it + /// is the cache entry itself: the run below uses a private options instance no other test touches, so an + /// entry for it can only have been created by a converter asking the cache during this deserialization. + /// + [TestMethod] + public void Deserialize_GoesThroughTheCache() + { + JsonSerializerOptions privateOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + privateOptions.Converters.Add(new LOConverter()); + privateOptions.Converters.Add(new LedgerEntryTypeConverter()); + + Assert.IsFalse( + JsonSerializerOptionsCache.HasCachedEntry(privateOptions), + "Fresh options must start with no cache entry"); + + string json = @"{""account"":""rTest"",""account_objects"":[ + {""LedgerEntryType"":""Offer"",""Account"":""rTest"",""Sequence"":1,""index"":""AA""}, + {""LedgerEntryType"":""Offer"",""Account"":""rTest"",""Sequence"":2,""index"":""BB""}]}"; + + AccountObjects response = JsonSerializer.Deserialize(json, privateOptions); + + Assert.HasCount(2, response.AccountObjectList); + Assert.IsInstanceOfType(response.AccountObjectList[0], typeof(LOOffer)); + Assert.IsTrue( + JsonSerializerOptionsCache.HasCachedEntry(privateOptions), + "LOConverter must resolve its inner options through the cache, not build a copy per element"); + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs new file mode 100644 index 00000000..916ea34c --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs @@ -0,0 +1,190 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Regression tests for ChangeServer pointed at a server that is not up yet. + /// + /// ChangeServer used to set the global _isIntentionalDisconnect flag, which was only ever + /// reset in OnceOpen. When the new server never came up, the flag stayed set, the failure of the + /// new connection was read as a user disconnect ("Connection closed permanently."), no reconnect loop + /// was started, and every later call failed with "No connection attempt in progress. Call Connect() + /// first." - the client was dead even after the server came up. + /// + /// + [TestClass] + public class TestUChangeServerFailure + { + private CreateMockRippled _mockedRippled; + private CreateMockRippled _secondRippled; + private XrplClient _client; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + private static CreateMockRippled StartMock(int port) + { + CreateMockRippled mock = new CreateMockRippled(port) { suppressOutput = true }; + mock.AddResponse("server_info", ServerInfoResponse()); + + Thread listenerThread = new Thread(() => mock.Start()) { IsBackground = true }; + listenerThread.Start(); + return mock; + } + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = StartMock(_port); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + await _client.Disconnect(); + _client = null; + } + + _mockedRippled?.Stop(); + _secondRippled?.Stop(); + } + + /// + /// Switching to a server that is not listening yet must leave the client reconnecting, so it comes + /// up on its own once that server appears - not stranded in a permanent disconnect. + /// + [TestMethod] + public async Task TestChangeServerToUnreachableServerRecoversWhenItComesUp() + { + _client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(200), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + MaxReconnectAttempts = 50, + StopAfterMaxAttempts = false, + // Short on purpose: ChangeServer gives up waiting quickly, but the reconnect loop it left + // behind is what this test is about. + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: client must be connected to the first server."); + + int secondPort = TestUtils.GetFreePort(); // nothing is listening there yet + + try + { + await _client.connection.ChangeServer($"ws://127.0.0.1:{secondPort}"); + } + catch (Exception) + { + // Expected - the target is not up yet. What matters is the state it leaves behind. + } + + Assert.AreNotEqual( + XrpConnectionState.Disconnected, + _client.connection.CurrentConnectionState, + "A ChangeServer target that is down is a connection failure, not a permanent disconnect."); + + // The server appears afterwards - exactly the "start the node later" case. + // The mock binds on a background thread, so a port taken in the meantime would + // surface as a 30s timeout below rather than as a bind error; check first. + Assert.IsTrue( + TestUtils.IsPortStillFree(secondPort), + $"Port {secondPort} was taken by another process while the test held it — rerun."); + _secondRippled = StartMock(secondPort); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + $"Client never reached the new server after it came up (state: {_client.connection.CurrentConnectionState})."); + Assert.AreEqual($"ws://127.0.0.1:{secondPort}", _client.connection.GetUrl()); + + Dictionary response = + await _client.Request(new Dictionary { { "command", "server_info" } }); + Assert.IsNotNull(response, "Client must be usable on the new server."); + } + + /// + /// The same, after an explicit user Disconnect(): the global intentional-disconnect flag left + /// behind by it must not suppress reconnection for the server ChangeServer switches to. + /// + [TestMethod] + public async Task TestChangeServerAfterUserDisconnectStillReconnects() + { + _client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(200), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + MaxReconnectAttempts = 50, + StopAfterMaxAttempts = false, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + await _client.Connect(); + await _client.Disconnect(); + + int secondPort = TestUtils.GetFreePort(); + + try + { + await _client.connection.ChangeServer($"ws://127.0.0.1:{secondPort}"); + } + catch (Exception) + { + // Expected - the target is not up yet. + } + + Assert.IsTrue( + TestUtils.IsPortStillFree(secondPort), + $"Port {secondPort} was taken by another process while the test held it — rerun."); + _secondRippled = StartMock(secondPort); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + $"Client never reached the new server after a user disconnect (state: {_client.connection.CurrentConnectionState})."); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs b/Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs new file mode 100644 index 00000000..f8f18fd3 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs @@ -0,0 +1,95 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Threading.Tasks; + +using Xrpl.Client; + +using XrplTests; + +namespace Xrpl.Tests +{ + /// + /// Boundary checks for the health-check timing options. Both feed a timer directly — + /// HealthCheckInterval is cast to an int of milliseconds on the WASM path, where zero + /// fires once and never repeats and out-of-range values are rejected by the timer itself — so a + /// bad value has to fail on the way in, naming the option, rather than quietly disabling the + /// check that recovers dead connections. + /// + [TestClass] + public class TestUHealthCheckOptions + { + private static XrplClient CreateClient(TimeSpan? healthCheckInterval = null, TimeSpan? inactivityTimeout = null) => + new XrplClient("ws://127.0.0.1:1", new XrplClient.ClientOptions + { + UseCustomPing = true, + UseCheckHealth = true, + HealthCheckInterval = healthCheckInterval ?? TimeSpan.FromSeconds(20), + InactivityTimeout = inactivityTimeout ?? TimeSpan.FromSeconds(60), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(1), + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(1), + }); + + [TestMethod] + public void TestZeroHealthCheckIntervalIsRejected() + { + // Validation runs while the connection is being constructed, so the bad value is rejected + // before a client exists to connect with. + ArgumentException error = Helper.ThrowsException( + () => CreateClient(healthCheckInterval: TimeSpan.Zero)); + StringAssert.Contains(error.Message, "HealthCheckInterval"); + } + + [TestMethod] + public void TestNegativeHealthCheckIntervalIsRejected() + { + ArgumentException error = Helper.ThrowsException( + () => CreateClient(healthCheckInterval: TimeSpan.FromMilliseconds(-1))); + StringAssert.Contains(error.Message, "HealthCheckInterval"); + } + + [TestMethod] + public void TestOutOfRangeHealthCheckIntervalIsRejected() + { + // Past int.MaxValue milliseconds - the WASM timer cannot represent it + ArgumentException error = Helper.ThrowsException( + () => CreateClient(healthCheckInterval: TimeSpan.FromDays(30))); + StringAssert.Contains(error.Message, "HealthCheckInterval"); + } + + [TestMethod] + public void TestNonPositiveInactivityTimeoutIsRejected() + { + ArgumentException error = Helper.ThrowsException( + () => CreateClient(inactivityTimeout: TimeSpan.Zero)); + StringAssert.Contains(error.Message, "InactivityTimeout"); + } + + /// + /// The lower bound is 1ms, and the defaults must keep working — otherwise every existing + /// consumer would start failing on connect. + /// + [TestMethod] + public async Task TestBoundaryAndDefaultValuesAreAccepted() + { + // 1ms is the documented minimum: validation must let it through. Nothing is listening on + // port 1, so the connect attempt fails on the transport - not on config validation. + XrplClient atMinimum = CreateClient( + healthCheckInterval: TimeSpan.FromMilliseconds(1), + inactivityTimeout: TimeSpan.FromMilliseconds(1)); + + Exception minimumError = await Helper.ThrowsExceptionAsync(() => atMinimum.Connect()); + Assert.IsNotInstanceOfType( + minimumError, + typeof(ArgumentException), + $"1ms should pass validation, but connect failed with: {minimumError.Message}"); + + XrplClient atDefaults = CreateClient(); + Exception defaultError = await Helper.ThrowsExceptionAsync(() => atDefaults.Connect()); + Assert.IsNotInstanceOfType( + defaultError, + typeof(ArgumentException), + $"The default options should pass validation, but connect failed with: {defaultError.Message}"); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs b/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs new file mode 100644 index 00000000..8cabf8f2 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs @@ -0,0 +1,194 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Guards the mock server's accept loop against a single bad connection taking it down. + /// + /// + /// The mock re-arms BeginAccept as the last statement of its accept callback, so any + /// throw earlier in that callback — a peer that resets the connection before or during the + /// WebSocket handshake — used to end the loop for good. The listen socket stayed bound, so the + /// port still looked taken and connects still completed at the TCP level, but nothing was ever + /// accepted again: every later client hung until its own connect timeout. + /// + /// That is not a hypothetical. It is what made flaky + /// on CI: concurrent ChangeServer/Disconnect calls abort half-open connections, one of those + /// aborts silenced the mock, and the assertion at the end of the test then blamed the client + /// for not reaching "a server that is up" — while the server was in fact deaf. + /// + [TestClass] + public class TestUMockRippledAcceptLoop + { + private CreateMockRippled _mock; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mock = new CreateMockRippled(_port) { suppressOutput = true }; + _mock.AddResponse("server_info", ServerInfoResponse()); + _mock.Start(); + } + + [TestCleanup] + public void MyTestCleanup() => _mock?.Stop(); + + /// + /// Resets a connection while the mock is in its accept callback, then requires the mock to + /// still serve the next client. + /// + [TestMethod] + public async Task TestAbortedHandshakeLeavesTheMockAccepting() + { + // A zero linger time makes Close() send RST rather than FIN, so the mock's blocking + // Receive of the handshake fails with "connection reset by peer" — the exact throw + // seen in the CI log of the flaky run. + using (Socket rude = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + rude.LingerState = new LingerOption(true, 0); + await rude.ConnectAsync(IPAddress.Loopback, _port); + rude.Close(); + } + + // Give the mock a moment to run its callback and (before the fix) fall out of it. + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + XrplClient client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + try + { + await client.Connect(); + + Assert.IsTrue( + client.connection.IsConnected(), + "The mock stopped accepting after one aborted connection — its accept loop was not re-armed."); + } + finally + { + try + { + await client.Disconnect(); + } + catch (Exception) + { + // Cleanup must not mask the assertion above. + } + } + } + + /// + /// The handshake probe the reconnect tests use to attribute a failure must actually tell + /// a serving mock from a deaf one — a probe that always says "alive" would be worse than + /// none, since it would confirm the wrong suspect. + /// + [TestMethod] + public void TestHandshakeProbeTellsAServingMockFromADeafOne() + { + TimeSpan timeout = TimeSpan.FromSeconds(2); + + Assert.IsTrue( + TestUtils.MockCompletesHandshake(_port, timeout), + "A running mock must answer the probe with a 101 upgrade."); + + Assert.IsFalse( + TestUtils.MockCompletesHandshake(TestUtils.GetFreePort(), timeout), + "Nothing listens on that port, so the probe must report it as not serving."); + + // The case the probe exists for: a socket that is bound and listening but never + // accepts. The TCP connect still succeeds — which is why a plain connect check proves + // nothing — and only the missing handshake reveals that the server is deaf. + TcpListener deaf = new TcpListener(IPAddress.Loopback, 0); + deaf.Start(); + try + { + int deafPort = ((IPEndPoint)deaf.LocalEndpoint).Port; + Assert.IsFalse( + TestUtils.MockCompletesHandshake(deafPort, timeout), + "A listening socket that never accepts must be reported as not serving."); + } + finally + { + deaf.Stop(); + } + } + + /// + /// The same guarantee under repetition: a run of aborted connections must not degrade the + /// mock, since the reconnect tests abort several in a row. + /// + [TestMethod] + public async Task TestRepeatedAbortedHandshakesLeaveTheMockAccepting() + { + for (int i = 0; i < 10; i++) + { + using Socket rude = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + rude.LingerState = new LingerOption(true, 0); + await rude.ConnectAsync(IPAddress.Loopback, _port); + rude.Close(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + XrplClient client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + try + { + await client.Connect(); + + Assert.IsTrue( + client.connection.IsConnected(), + "The mock stopped accepting after a run of aborted connections."); + } + finally + { + try + { + await client.Disconnect(); + } + catch (Exception) + { + // Cleanup must not mask the assertion above. + } + } + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs new file mode 100644 index 00000000..a47f8a46 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs @@ -0,0 +1,318 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; + +namespace Xrpl.Tests +{ + /// + /// Regression tests for the "silent wedge": an exception thrown by a consumer OnConnected + /// handler used to trigger the user-disconnect path (_permanentlyDisconnected = true), + /// which killed the client forever instead of reconnecting. + /// + [TestClass] + public class TestUOnConnectedHandlerFailure + { + private CreateMockRippled _mockedRippled; + private XrplClient _client; + private int _port; + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = new CreateMockRippled(_port) { suppressOutput = true }; + _mockedRippled.AddResponse("server_info", new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }); + + Thread tcpListenerThread = new Thread(() => _mockedRippled.Start()) { IsBackground = true }; + tcpListenerThread.Start(); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + await _client.Disconnect(); + _client = null; + } + + _mockedRippled?.Stop(); + } + + private XrplClient CreateClient(int maxReconnectAttempts, bool stopAfterMaxAttempts) => + new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(100), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + MaxReconnectAttempts = maxReconnectAttempts, + StopAfterMaxAttempts = stopAfterMaxAttempts, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(20), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(10), + UseCustomPing = false, + }); + + /// + /// A transient failure inside OnConnected (e.g. a subscribe that timed out because the + /// node accepts TCP before it serves requests) must not strand the client: the socket is torn + /// down and the regular reconnect loop must bring it back. + /// + [TestMethod] + public async Task TestTransientOnConnectedFailureRecovers() + { + int invocations = 0; + TaskCompletionSource reconnected = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 10, stopAfterMaxAttempts: false); + _client.connection.OnConnected += () => + { + if (Interlocked.Increment(ref invocations) == 1) + { + throw new InvalidOperationException("subscribe failed after connect"); + } + + reconnected.TrySetResult(true); + return Task.CompletedTask; + }; + + Exception connectError = null; + try + { + await _client.Connect(); + } + catch (Exception error) + { + connectError = error; + } + + Task completed = await Task.WhenAny(reconnected.Task, Task.Delay(TimeSpan.FromSeconds(30))); + + Assert.AreSame( + reconnected.Task, + completed, + $"Client never reconnected after OnConnected threw (invocations: {Volatile.Read(ref invocations)}, connect error: {connectError?.Message ?? "none"})"); + Assert.IsTrue(_client.connection.IsConnected(), "Client must be connected again after recovery."); + } + + /// + /// After recovery the client must still be usable — the permanent-disconnect flag must not be set. + /// + [TestMethod] + public async Task TestClientIsUsableAfterOnConnectedFailure() + { + int invocations = 0; + TaskCompletionSource reconnected = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 10, stopAfterMaxAttempts: false); + _client.connection.OnConnected += () => + { + if (Interlocked.Increment(ref invocations) == 1) + { + throw new InvalidOperationException("subscribe failed after connect"); + } + + reconnected.TrySetResult(true); + return Task.CompletedTask; + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // Recovery is asserted below - the initial Connect() may observe the failed attempt. + } + + Task completed = await Task.WhenAny(reconnected.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(reconnected.Task, completed, "Client never reconnected after OnConnected threw."); + + Dictionary request = new Dictionary + { + { "command", "server_info" }, + }; + + Dictionary response = await _client.Request(request); + Assert.IsNotNull(response, "Request after recovery must succeed."); + } + + /// + /// A permanently broken handler must not spin forever: with StopAfterMaxAttempts the client + /// gives up after MaxReconnectAttempts consecutive handler failures. + /// + [TestMethod] + public async Task TestPermanentlyFailingOnConnectedHandlerStops() + { + const int maxAttempts = 3; + int invocations = 0; + + _client = CreateClient(maxReconnectAttempts: maxAttempts, stopAfterMaxAttempts: true); + _client.connection.OnConnected += () => + { + Interlocked.Increment(ref invocations); + throw new InvalidOperationException("handler is permanently broken"); + }; + + Exception connectError = null; + try + { + await _client.Connect(); + } + catch (Exception error) + { + connectError = error; + } + + Assert.IsInstanceOfType( + connectError, + $"Giving up must unblock the waiting caller with NotConnectedException, got: {connectError?.GetType().Name ?? "no exception"}."); + + await Task.Delay(TimeSpan.FromSeconds(10)); + int settled = Volatile.Read(ref invocations); + await Task.Delay(TimeSpan.FromSeconds(5)); + + Assert.AreEqual( + settled, + Volatile.Read(ref invocations), + "Client kept retrying a permanently failing OnConnected handler instead of giving up."); + Assert.IsTrue( + settled <= maxAttempts + 1, + $"Handler was retried {settled} times, expected at most {maxAttempts + 1}."); + Assert.IsFalse(_client.connection.IsConnected(), "Client must not report a live connection."); + } + + /// + /// The reason the connection was torn down must be observable through OnError. + /// + [TestMethod] + public async Task TestOnConnectedFailureIsReportedThroughOnError() + { + int invocations = 0; + TaskCompletionSource reported = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 10, stopAfterMaxAttempts: false); + _client.connection.OnError += (error, errorMessage, message, data) => + { + if (errorMessage == "connectHandlerError") + { + reported.TrySetResult(message); + } + + return Task.CompletedTask; + }; + _client.connection.OnConnected += () => + { + if (Interlocked.Increment(ref invocations) == 1) + { + throw new InvalidOperationException("subscribe failed after connect"); + } + + return Task.CompletedTask; + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // The failure itself is asserted through OnError below. + } + + Task completed = await Task.WhenAny(reported.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(completed, reported.Task, "OnConnected failure was never reported through OnError."); + StringAssert.Contains(reported.Task.Result, "subscribe failed after connect"); + } + + /// + /// With StopAfterMaxAttempts = false there is no give-up branch, so a permanently + /// failing handler reconnects forever. The delay between attempts must still grow: this + /// path tears the reconnect loop down and starts it again on every failure, and the loop + /// derives its delay from the attempt counter alone — seeded from zero it would hammer a + /// node that accepts TCP but cannot serve requests at a constant ReconnectBaseDelay. + /// + [TestMethod] + public async Task TestRepeatedOnConnectedFailuresBackOff() + { + List attempts = new List(); + TaskCompletionSource enough = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 50, stopAfterMaxAttempts: false); + _client.connection.OnConnected += () => + { + lock (attempts) + { + attempts.Add(DateTime.UtcNow); + if (attempts.Count >= 4) + { + enough.TrySetResult(true); + } + } + + throw new InvalidOperationException("subscribe failed after connect"); + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // Expected: the first handler invocation throws. + } + + Task completed = await Task.WhenAny(enough.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(completed, enough.Task, "The client stopped retrying a failing handler."); + + List gaps = new List(); + lock (attempts) + { + for (int i = 1; i < attempts.Count; i++) + { + gaps.Add(attempts[i] - attempts[i - 1]); + } + } + + // CalcBackoff doubles per attempt off ReconnectBaseDelay (100ms), capped at + // ReconnectMaxDelay (1s), with 25% jitter. The handler-failure path seeds the counter + // with its consecutive-failure count, so the delays run 400ms, 800ms, then 1s (capped) + // — first to last is ~2.5x nominally, and still grows at the jitter extremes. + // This holds only while the configured cap stays above the earlier backoff values: with + // a cap at or below 400ms every gap would sit on the cap, and the comparison would come + // down to which way the jitter fell — a coin flip, not a stable result. + // Comparing first vs last rather than each consecutive pair keeps the assertion + // robust: what regressed before was a flat sequence, not the exact multiplier. + Assert.IsTrue( + gaps.Count >= 3, + $"Expected at least 3 gaps between handler invocations, got {gaps.Count}."); + Assert.IsTrue( + gaps[gaps.Count - 1] > gaps[0], + $"Backoff did not grow across consecutive handler failures: {string.Join(", ", gaps)}"); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs new file mode 100644 index 00000000..56a8e4ed --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs @@ -0,0 +1,310 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Concurrency smoke tests for the reconnect session — the _reconnectCts / + /// _reconnectLoop / _reconnectAttempts triple, which is now updated under a + /// shared lock. + /// + /// + /// + /// These do not reproduce the race the lock fixes. That window is a few instructions + /// wide — a start landing between the stop path's cancel, dispose and null — and driving it + /// from public API calls, which are separated by whole awaits, does not hit it: with the lock + /// removed again these tests still pass. Claiming them as regression coverage would be false. + /// + /// + /// What they do earn their place for is the other direction. Introducing a lock around the + /// session creates a deadlock risk of its own: the loop is now started while the lock is held, + /// and anything that called back into consumer code from there could re-enter a path that takes + /// the same lock. These tests hammer ChangeServer and Disconnect concurrently and require the + /// client to still reach a live server afterwards, so a deadlock or a lost session shows up as + /// a hang or a failure here rather than in production. + /// + /// + [TestClass] + public class TestUReconnectSessionRaces + { + private CreateMockRippled _mockedRippled; + private CreateMockRippled _secondRippled; + private XrplClient _client; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + private static CreateMockRippled StartMock(int port) + { + CreateMockRippled mock = new CreateMockRippled(port) { suppressOutput = true }; + mock.AddResponse("server_info", ServerInfoResponse()); + + // Called directly rather than on a background thread: Start() binds, listens and hands + // off to BeginAccept without blocking, so returning from it means the port is already + // accepting. Handing it to a thread only opened a window where a test could connect + // before the mock was up. + mock.Start(); + return mock; + } + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = StartMock(_port); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + try + { + await _client.Disconnect(); + } + catch (Exception) + { + // The client may already be down; cleanup must not mask the test result. + } + + _client = null; + } + + _mockedRippled?.Stop(); + _secondRippled?.Stop(); + } + + private XrplClient CreateClient(string url) => + new XrplClient(url, new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(50), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(400), + MaxReconnectAttempts = 100, + StopAfterMaxAttempts = false, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(20), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(5), + UseCustomPing = false, + }); + + /// + /// Concurrent ChangeServer calls tear down and install reconnect sessions from several + /// threads at once. Whatever interleaving wins, the client must end up able to connect to the + /// live server — not stranded with a disposed or orphaned session. + /// + [TestMethod] + public async Task TestConcurrentChangeServerKeepsClientRecoverable() + { + int deadPortA = TestUtils.GetFreePort(); + int deadPortB = TestUtils.GetFreePort(); + + _client = CreateClient($"ws://127.0.0.1:{_port}"); + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: connected to the live mock."); + + // Writers of the reconnect session running at once: two pointed at ports where nothing + // listens (each starts a reconnect sequence), one pointed back at the live server, plus a + // Disconnect taking the session down underneath them. + for (int round = 0; round < 5; round++) + { + Task[] racers = + { + SwitchTo($"ws://127.0.0.1:{deadPortA}"), + SwitchTo($"ws://127.0.0.1:{deadPortB}"), + SwitchTo($"ws://127.0.0.1:{_port}"), + Task.Run(async () => + { + // Disconnect takes the same session down while the switches install new + // ones — the stop-vs-start interleaving the lock has to make safe. + try + { + await _client.Disconnect(); + } + catch (Exception) + { + } + }), + }; + + await Task.WhenAll(racers); + } + + // Whoever won, point the client at the live server and require it to get there. + await SwitchTo($"ws://127.0.0.1:{_port}"); + try { await _client.Connect(); } catch (Exception) { } + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + if (!_client.connection.IsConnected()) + { + // Say which side failed. A mock whose accept loop died still holds the port, so + // "the server is up" is an assumption worth checking before blaming the client — + // this test spent a CI run being read as a client bug for exactly that reason. + bool mockServing = TestUtils.MockCompletesHandshake(_port, TimeSpan.FromSeconds(2)); + Assert.Fail( + "After concurrent ChangeServer calls the client could not reach a server that is up — " + + "the reconnect session was left disposed or orphaned. " + + $"(mock still completes a handshake: {mockServing})"); + } + } + + /// + /// Disconnect racing a reconnect sequence must leave the client cleanly stopped and + /// still able to reconnect afterwards — a stop that tore down someone else's session would + /// either strand a live loop or leave a stale one running. + /// + [TestMethod] + public async Task TestDisconnectRacingReconnectLeavesClientReconnectable() + { + int deadPort = TestUtils.GetFreePort(); + + _client = CreateClient($"ws://127.0.0.1:{_port}"); + await _client.Connect(); + + for (int round = 0; round < 5; round++) + { + // Start a reconnect sequence against a dead port and disconnect while it runs. + Task switching = SwitchTo($"ws://127.0.0.1:{deadPort}"); + Task disconnecting = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(20)); + await _client.Disconnect(); + }); + + await Task.WhenAll(switching, disconnecting); + } + + // The client must still be usable: point it back at the live server and connect. + // Both calls are tolerated so the assertion below reports the failure, rather than the + // test dying on a raw exception from a switch that lost a race. + await SwitchTo($"ws://127.0.0.1:{_port}"); + try + { + await _client.Connect(); + } + catch (Exception) + { + } + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + "Disconnect racing a reconnect sequence left the client unable to connect again."); + } + + /// + /// A failed Connect issued while a reconnect loop is already running must leave a + /// live loop behind, so the client still comes back on its own once the server returns. + /// + /// + /// Covers the functional path end to end. It does not pin the narrow race that made + /// StopReconnectLoop drop the loop reference: that needs the retired task to still be + /// running when the restart checks IsCompleted, and by the time a failed Connect gets + /// there the task has normally already exited, so the loop is restarted either way — with + /// the fix reverted this test still passes. Kept because the path itself (Connect while + /// reconnecting, server appears later) is worth guarding. + /// + [TestMethod] + public async Task TestFailedConnectDuringReconnectLeavesLoopRunning() + { + int laterPort = TestUtils.GetFreePort(); + + // Short acquisition timeout: the Connect below is expected to fail, and waiting out the + // class default would add 20s of nothing to the run. + _client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(50), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(400), + MaxReconnectAttempts = 100, + StopAfterMaxAttempts = false, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(2), + UseCustomPing = false, + }); + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: connected to the live mock."); + + // Point the client at a port where nothing listens: a reconnect loop starts and retries. + await SwitchTo($"ws://127.0.0.1:{laterPort}"); + Assert.IsFalse(_client.connection.IsConnected(), "Precondition: the target port is closed."); + + // A Connect while that loop is running: it stops the loop, then fails because nothing + // is listening yet. Something must still be reconnecting afterwards. + try + { + await _client.Connect(); + } + catch (Exception) + { + // Expected - nothing is listening on that port yet. + } + + // The server appears. Nobody touches the client from here on. + // The port was handed out before the awaits above, so check it is still free. StartMock + // binds on this thread, so a port taken meanwhile would come out as a raw SocketException + // from the line below; this turns it into a statement of the actual cause. Diagnostics, + // not a fix: the check itself binds and releases, so the port can still be lost between + // here and StartMock. + Assert.IsTrue( + TestUtils.IsPortStillFree(laterPort), + $"Port {laterPort} was taken by another process while the test held it — rerun."); + _secondRippled = StartMock(laterPort); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(40); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + "The client never reconnected after the server returned - a failed Connect during " + + "an active reconnect sequence left no loop running."); + } + + private async Task SwitchTo(string url) + { + try + { + await _client.connection.ChangeServer(url); + } + catch (Exception) + { + // Failing to reach a dead port is the point of the race; the invariant is asserted + // by the caller once the dust settles. + } + } + } +} diff --git a/Tests/Xrpl.Tests/CreateMockRippled.cs b/Tests/Xrpl.Tests/CreateMockRippled.cs index 37cd57e1..fddc44a4 100644 --- a/Tests/Xrpl.Tests/CreateMockRippled.cs +++ b/Tests/Xrpl.Tests/CreateMockRippled.cs @@ -68,12 +68,51 @@ public class CreateMockRippled private Dictionary _responses = new Dictionary(); public bool suppressOutput = false; private Thread tcpListenerThread; + private readonly object _serverLock = new object(); + private Server _server; + private bool _stopped; public CreateMockRippled(int port) { this._port = port; } + /// + /// Stops the listen socket. Without this the server keeps accepting for the lifetime of the test + /// process, so every test that starts a mock leaks a listener. + /// Start() runs on a background thread, so shutdown is recorded here: a startup that finishes + /// afterwards stops its listener instead of leaving it behind. + /// + public void Stop() + { + Server server; + lock (_serverLock) + { + _stopped = true; + server = _server; + _server = null; + } + + StopServer(server); + } + + private static void StopServer(Server server) + { + if (server == null) + { + return; + } + + try + { + server.Stop(); + } + catch (Exception ex) + { + Debug.WriteLine($"MockRippled stop error: {ex.Message}"); + } + } + string CreateResponse(Dictionary request, Dictionary response) { var cloneResp = new Dictionary(response); @@ -218,6 +257,18 @@ public void Start() Server server = new Server(new IPEndPoint(IPAddress.Parse("127.0.0.1"), this._port)); + lock (_serverLock) + { + if (_stopped) + { + // Stop() already ran - do not leave this listener accepting behind the test's back. + StopServer(server); + return; + } + + _server = server; + } + // Bind the event for when a client connected server.OnClientConnected += (object sender, OnClientConnectedHandler e) => { diff --git a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h new file mode 100644 index 00000000..68205e27 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h @@ -0,0 +1,322 @@ +#pragma once + +// NOLINTBEGIN(readability-identifier-naming) + +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl { +/** + * Identifiers for on-ledger objects. + * + * Each ledger object requires a unique type identifier, which is stored within the object itself; + * this makes it possible to iterate the entire ledger and determine each object's type and verify + * that the object you retrieved from a given hash matches the expected type. + * + * @warning Since these values are stored inside objects stored on the ledger they are part of the + * protocol. + * **Changing them should be avoided because without special handling, this will result in a hard + * fork.** + * + * @note Values outside this range may be used internally by the code for various purposes, but + * attempting to use such values to identify on-ledger objects will result in an invariant failure. + * + * @note When retiring types, the specific values should not be removed but should be marked as + * [[deprecated]]. This is to avoid accidental reuse of identifiers. + * + * @todo The C++ language does not enable checking for duplicate values here. + * If it becomes possible then we should do this. + * + * @ingroup protocol + */ +// Protocol-critical, hundreds of usages +// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) +enum LedgerEntryType : std::uint16_t { + +#pragma push_macro("LEDGER_ENTRY") +#undef LEDGER_ENTRY + +#define LEDGER_ENTRY(tag, value, ...) tag = value, + +#include + +#undef LEDGER_ENTRY +#pragma pop_macro("LEDGER_ENTRY") + + //--------------------------------------------------------------------------- + /** + * A special type, matching any ledger entry type. + * + * The value does not represent a concrete type, but rather is used in contexts where the + * specific type of a ledger object is unimportant, unknown or unavailable. + * + * Objects with this special type cannot be created or stored on the ledger. + * + * @see keylet::unchecked + */ + ltANY = 0, + + /** + * A special type, matching any ledger type except directory nodes. + * + * The value does not represent a concrete type, but rather is used in contexts where the + * ledger object must not be a directory node but its specific type is otherwise unimportant, + * unknown or unavailable. + * + * Objects with this special type cannot be created or stored on the ledger. + * + * @see keylet::child + */ + ltCHILD = 0x1CD2, + + //--------------------------------------------------------------------------- + /** + * A legacy, deprecated type. + * + * @deprecated **This object type is not supported and should not be used.** + * Support for this type of object was never implemented. + * No objects of this type were ever created. + */ + ltNICKNAME [[deprecated("This object type is not supported and should not be used.")]] = 0x006e, + + /** + * A legacy, deprecated type. + * + * @deprecated **This object type is not supported and should not be used.** + * Support for this type of object was never implemented. + * No objects of this type were ever created. + */ + ltCONTRACT [[deprecated("This object type is not supported and should not be used.")]] = 0x0063, + + /** + * A legacy, deprecated type. + * + * @deprecated **This object type is not supported and should not be used.** + * Support for this type of object was never implemented. + * No objects of this type were ever created. + */ + ltGENERATOR_MAP [[deprecated("This object type is not supported and should not be used.")]] = + 0x0067, +}; + +/** + * Ledger object flags. + * + * These flags are specified in ledger objects and modify their behavior. + * + * @warning Ledger object flags form part of the protocol. + * **Changing them should be avoided because without special handling, this will result in a hard + * fork.** + * + * @ingroup protocol + */ +#pragma push_macro("XMACRO") +#pragma push_macro("TO_VALUE") +#pragma push_macro("VALUE_TO_MAP") +#pragma push_macro("NULL_NAME") +#pragma push_macro("TO_MAP") +#pragma push_macro("ALL_LEDGER_FLAGS") + +#undef XMACRO +#undef TO_VALUE +#undef VALUE_TO_MAP +#undef NULL_NAME +#undef TO_MAP + +#undef ALL_LEDGER_FLAGS + +// clang-format off + +#define XMACRO(LEDGER_OBJECT, LSF_FLAG, LSF_FLAG2) \ + LEDGER_OBJECT(AccountRoot, \ + LSF_FLAG(lsfPasswordSpent, 0x00010000) /* True, if password set fee is spent. */ \ + LSF_FLAG(lsfRequireDestTag, 0x00020000) /* True, to require a DestinationTag for payments. */ \ + LSF_FLAG(lsfRequireAuth, 0x00040000) /* True, to require a authorization to hold IOUs. */ \ + LSF_FLAG(lsfDisallowXRP, 0x00080000) /* True, to disallow sending XRP. */ \ + LSF_FLAG(lsfDisableMaster, 0x00100000) /* True, force regular key */ \ + LSF_FLAG(lsfNoFreeze, 0x00200000) /* True, cannot freeze ripple states */ \ + LSF_FLAG(lsfGlobalFreeze, 0x00400000) /* True, all assets frozen */ \ + LSF_FLAG(lsfDefaultRipple, 0x00800000) /* True, incoming trust lines allow rippling by default */ \ + LSF_FLAG(lsfDepositAuth, 0x01000000) /* True, all deposits require authorization */ \ + LSF_FLAG(lsfDisallowIncomingNFTokenOffer, 0x04000000) /* True, reject new incoming NFT offers */ \ + LSF_FLAG(lsfDisallowIncomingCheck, 0x08000000) /* True, reject new checks */ \ + LSF_FLAG(lsfDisallowIncomingPayChan, 0x10000000) /* True, reject new paychans */ \ + LSF_FLAG(lsfDisallowIncomingTrustline, 0x20000000) /* True, reject new trustlines (only if no issued assets) */ \ + LSF_FLAG(lsfAllowTrustLineLocking, 0x40000000) /* True, enable trustline locking */ \ + LSF_FLAG(lsfAllowTrustLineClawback, 0x80000000)) /* True, enable clawback */ \ + \ + LEDGER_OBJECT(Offer, \ + LSF_FLAG(lsfPassive, 0x00010000) \ + LSF_FLAG(lsfSell, 0x00020000) /* True, offer was placed as a sell. */ \ + LSF_FLAG(lsfHybrid, 0x00040000)) /* True, offer is hybrid. */ \ + \ + LEDGER_OBJECT(RippleState, \ + LSF_FLAG(lsfLowReserve, 0x00010000) /* True, if entry counts toward reserve. */ \ + LSF_FLAG(lsfHighReserve, 0x00020000) \ + LSF_FLAG(lsfLowAuth, 0x00040000) \ + LSF_FLAG(lsfHighAuth, 0x00080000) \ + LSF_FLAG(lsfLowNoRipple, 0x00100000) \ + LSF_FLAG(lsfHighNoRipple, 0x00200000) \ + LSF_FLAG(lsfLowFreeze, 0x00400000) /* True, low side has set freeze flag */ \ + LSF_FLAG(lsfHighFreeze, 0x00800000) /* True, high side has set freeze flag */ \ + LSF_FLAG(lsfAMMNode, 0x01000000) /* True, trust line to AMM. */ \ + /* Used by client apps to identify payments via AMM. */ \ + LSF_FLAG(lsfLowDeepFreeze, 0x02000000) /* True, low side has set deep freeze flag */ \ + LSF_FLAG(lsfHighDeepFreeze, 0x04000000)) /* True, high side has set deep freeze flag */ \ + \ + LEDGER_OBJECT(SignerList, \ + LSF_FLAG(lsfOneOwnerCount, 0x00010000)) /* True, uses only one OwnerCount */ \ + \ + LEDGER_OBJECT(DirNode, \ + LSF_FLAG(lsfNFTokenBuyOffers, 0x00000001) \ + LSF_FLAG(lsfNFTokenSellOffers, 0x00000002)) \ + \ + LEDGER_OBJECT(NFTokenOffer, \ + LSF_FLAG(lsfSellNFToken, 0x00000001)) \ + \ + LEDGER_OBJECT(MPTokenIssuance, \ + LSF_FLAG(lsfMPTLocked, 0x00000001) /* Also used in ltMPTOKEN */ \ + LSF_FLAG(lsfMPTCanLock, 0x00000002) \ + LSF_FLAG(lsfMPTRequireAuth, 0x00000004) \ + LSF_FLAG(lsfMPTCanEscrow, 0x00000008) \ + LSF_FLAG(lsfMPTCanTrade, 0x00000010) \ + LSF_FLAG(lsfMPTCanTransfer, 0x00000020) \ + LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ + LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ + \ + LEDGER_OBJECT(MPToken, \ + LSF_FLAG2(lsfMPTLocked, 0x00000001) \ + LSF_FLAG(lsfMPTAuthorized, 0x00000002) \ + LSF_FLAG(lsfMPTAMM, 0x00000004)) \ + \ + LEDGER_OBJECT(Credential, \ + LSF_FLAG(lsfAccepted, 0x00010000)) \ + \ + LEDGER_OBJECT(Vault, \ + LSF_FLAG(lsfVaultPrivate, 0x00010000)) \ + \ + LEDGER_OBJECT(Loan, \ + LSF_FLAG(lsfLoanDefault, 0x00010000) \ + LSF_FLAG(lsfLoanImpaired, 0x00020000) \ + LSF_FLAG(lsfLoanOverpayment, 0x00040000)) /* True, loan allows overpayments */ \ + \ + LEDGER_OBJECT(Sponsorship, \ + LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \ + LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) + +// clang-format on + +// Create all the flag values as an enum. +// +// example: +// enum LedgerSpecificFlags { +// lsfPasswordSpent = 0x00010000, +// lsfRequireDestTag = 0x00020000, +// ... +// }; +#define TO_VALUE(name, value) name = (value), +#define NULL_NAME(name, values) values +#define NULL_OUTPUT(name, value) +// Bitwise flag enum +// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) +enum LedgerSpecificFlags : std::uint32_t { XMACRO(NULL_NAME, TO_VALUE, NULL_OUTPUT) }; + +// Create getter functions for each set of flags using Meyer's singleton pattern. +// This avoids static initialization order fiasco while still providing efficient access. +// This is used below in `getAllLedgerFlags()` to generate the server_definitions RPC output. +// +// example: +// inline LedgerFlagMap const& getAccountRootFlags() { +// static LedgerFlagMap const flags = { +// {"lsfPasswordSpent", 0x00010000}, +// {"lsfRequireDestTag", 0x00020000}, +// ...}; +// return flags; +// } +using LedgerFlagMap = std::map; +#define VALUE_TO_MAP(name, value) {#name, value}, +#define TO_MAP(name, values) \ + inline LedgerFlagMap const& get##name##Flags() \ + { \ + static LedgerFlagMap const flags = {values}; \ + return flags; \ + } +XMACRO(TO_MAP, VALUE_TO_MAP, VALUE_TO_MAP) + +// Create a getter function for all ledger flag maps using Meyer's singleton pattern. +// This is used to generate the server_definitions RPC output. +// +// example: +// inline std::vector> const& getAllLedgerFlags() { +// static std::vector> const flags = { +// {"AccountRoot", getAccountRootFlags()}, +// ...}; +// return flags; +// } +#define ALL_LEDGER_FLAGS(name, values) {#name, get##name##Flags()}, +inline std::vector> const& +getAllLedgerFlags() +{ + static std::vector> const flags = { + XMACRO(ALL_LEDGER_FLAGS, NULL_OUTPUT, NULL_OUTPUT)}; + return flags; +} + +#undef XMACRO +#undef TO_VALUE +#undef VALUE_TO_MAP +#undef NULL_NAME +#undef NULL_OUTPUT +#undef TO_MAP +#undef ALL_LEDGER_FLAGS + +#pragma pop_macro("XMACRO") +#pragma pop_macro("TO_VALUE") +#pragma pop_macro("VALUE_TO_MAP") +#pragma pop_macro("NULL_NAME") +#pragma pop_macro("TO_MAP") +#pragma pop_macro("ALL_LEDGER_FLAGS") + +// MPTokenIssuance ImmutableFlags (sfImmutableFlags) +inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002; +inline constexpr std::uint32_t lsifMPTRequireAuth = 0x00000004; +inline constexpr std::uint32_t lsifMPTCanEscrow = 0x00000008; +inline constexpr std::uint32_t lsifMPTCanTrade = 0x00000010; +inline constexpr std::uint32_t lsifMPTCanTransfer = 0x00000020; +inline constexpr std::uint32_t lsifMPTCanClawback = 0x00000040; +inline constexpr std::uint32_t lsifMPTCanHoldConfidentialBalance = 0x00000080; +inline constexpr std::uint32_t lsifMPTMetadata = 0x00010000; +inline constexpr std::uint32_t lsifMPTTransferFee = 0x00020000; + +//------------------------------------------------------------------------------ + +/** + * Holds the list of known ledger entry formats. + */ +class LedgerFormats : public KnownFormats +{ +private: + /** + * Create the object. + * This will load the object with all the known ledger formats. + */ + LedgerFormats(); + +public: + static LedgerFormats const& + getInstance(); + + // Fields shared by all ledger entry formats: + static std::vector const& + getCommonFields(); +}; + +} // namespace xrpl + +// NOLINTEND(readability-identifier-naming) diff --git a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref new file mode 100644 index 00000000..42e1d291 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref @@ -0,0 +1,22 @@ +https://github.com/XRPLF/rippled/blob/3.3.0/include/xrpl/protocol/LedgerFormats.h +sha 00a178fb92ca49521b937ae1a99d863765ea8a90 +date 2026-08-06T16:34:39Z +tag 3.3.0 + +LedgerFormats.h is vendored byte-identical to the ref above so that it can be +re-verified with a plain diff: + + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/00a178fb92ca49521b937ae1a99d863765ea8a90/include/xrpl/protocol/LedgerFormats.h \ + | diff - Tests/Xrpl.Tests/Fixtures/LedgerFormats.h + +This is the only place the protocol states which lsf flags belong to which ledger +object: definitions.json carries field codes and object types but no flag values, +so it cannot answer this question. + +Pinned to a tag rather than to develop on purpose — the same reason +transactions.macro is pinned: tracking upstream drift is protocol-watch's job, and +a network-backed test would go red on Ripple's release schedule instead of ours. + +Do not hand-edit it. When protocol-watch reports a change to this file upstream, +replace it wholesale, update the sha above, and let TestULedgerFlagsConformance +show which model enums have to follow. diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro new file mode 100644 index 00000000..ffcd025f --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro @@ -0,0 +1,641 @@ +#if !defined(LEDGER_ENTRY) +#error "undefined macro: LEDGER_ENTRY" +#endif + +#ifndef LEDGER_ENTRY_DUPLICATE +// The EXPAND macro is needed for Windows +// https://stackoverflow.com/questions/5134523/msvc-doesnt-expand-va-args-correctly +#define EXPAND(x) x + +// The `LEDGER_ENTRY_DUPLICATE macro is needed to avoid JSS conflicts +// Since some transactions and ledger entries have the same name (like `DepositPreauth`) +// The compiler won't accept two instances of `JSS(DepositPreauth)` +#define LEDGER_ENTRY_DUPLICATE(...) EXPAND(LEDGER_ENTRY(__VA_ARGS__)) +#endif + +/** + * These objects are listed in order of increasing ledger type ID. + * There are many gaps between these IDs. + * You are welcome to fill them with new object types. + */ + +/** A ledger object which identifies an offer to buy or sell an NFT. + + \sa keylet::nftokenOffer + */ +LEDGER_ENTRY(ltNFTOKEN_OFFER, 0x0037, NFTokenOffer, nft_offer, ({ + {sfOwner, SoeRequired}, + {sfNFTokenID, SoeRequired}, + {sfAmount, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfNFTokenOfferNode, SoeRequired}, + {sfDestination, SoeOptional}, + {sfExpiration, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes a check. + + \sa keylet::check + */ +LEDGER_ENTRY(ltCHECK, 0x0043, Check, check, ({ + {sfAccount, SoeRequired}, + {sfDestination, SoeRequired}, + {sfSendMax, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfDestinationNode, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfInvoiceID, SoeOptional}, + {sfSourceTag, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** The ledger object which tracks the DID. + + \sa keylet::did +*/ +LEDGER_ENTRY(ltDID, 0x0049, DID, did, ({ + {sfAccount, SoeRequired}, + {sfDIDDocument, SoeOptional}, + {sfURI, SoeOptional}, + {sfData, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** The ledger object which tracks the current negative UNL state. + + \note This is a singleton: only one such object exists in the ledger. + + \sa keylet::negativeUNL + */ +LEDGER_ENTRY(ltNEGATIVE_UNL, 0x004e, NegativeUNL, nunl, ({ + {sfDisabledValidators, SoeOptional}, + {sfValidatorToDisable, SoeOptional}, + {sfValidatorToReEnable, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A ledger object which contains a list of NFTs + + \sa keylet::nftokenPageMin, keylet::nftokenPageMax, keylet::nftokenPage + */ +LEDGER_ENTRY(ltNFTOKEN_PAGE, 0x0050, NFTokenPage, nft_page, ({ + {sfPreviousPageMin, SoeOptional}, + {sfNextPageMin, SoeOptional}, + {sfNFTokens, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which contains a signer list for an account. + + \sa keylet::signerList + */ +// All fields are SoeRequired because there is always a SignerEntries. +// If there are no SignerEntries the node is deleted. +LEDGER_ENTRY(ltSIGNER_LIST, 0x0053, SignerList, signer_list, ({ + {sfOwner, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfSignerQuorum, SoeRequired}, + {sfSignerEntries, SoeRequired}, + {sfSignerListID, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes a ticket. + + \sa keylet::ticket + */ +LEDGER_ENTRY(ltTICKET, 0x0054, Ticket, ticket, ({ + {sfAccount, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfTicketSequence, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes an account. + + \sa keylet::account + */ +LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({ + {sfAccount, SoeRequired}, + {sfSequence, SoeRequired}, + {sfBalance, SoeRequired}, + {sfOwnerCount, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfAccountTxnID, SoeOptional}, + {sfRegularKey, SoeOptional}, + {sfEmailHash, SoeOptional}, + {sfWalletLocator, SoeOptional}, + {sfWalletSize, SoeOptional}, + {sfMessageKey, SoeOptional}, + {sfTransferRate, SoeOptional}, + {sfDomain, SoeOptional}, + {sfTickSize, SoeOptional}, + {sfTicketCount, SoeOptional}, + {sfNFTokenMinter, SoeOptional}, + {sfMintedNFTokens, SoeDefault}, + {sfBurnedNFTokens, SoeDefault}, + {sfFirstNFTokenSequence, SoeOptional}, + {sfSponsoredOwnerCount, SoeDefault}, + {sfSponsoringOwnerCount, SoeDefault}, + {sfSponsoringAccountCount, SoeDefault}, + {sfAMMID, SoeOptional}, // pseudo-account designator + {sfVaultID, SoeOptional}, // pseudo-account designator + {sfLoanBrokerID, SoeOptional}, // pseudo-account designator +})) + +/** A ledger object which contains a list of object identifiers. + + \sa keylet::page, keylet::quality, keylet::book, keylet::next and + keylet::ownerDir + */ +LEDGER_ENTRY(ltDIR_NODE, 0x0064, DirectoryNode, directory, ({ + {sfOwner, SoeOptional}, // for owner directories + {sfTakerPaysCurrency, SoeOptional}, // order book directories + {sfTakerPaysIssuer, SoeOptional}, // order book directories + {sfTakerPaysMPT, SoeOptional}, // order book directories + {sfTakerGetsCurrency, SoeOptional}, // order book directories + {sfTakerGetsIssuer, SoeOptional}, // order book directories + {sfTakerGetsMPT, SoeOptional}, // order book directories + {sfExchangeRate, SoeOptional}, // order book directories + {sfIndexes, SoeRequired}, + {sfRootIndex, SoeRequired}, + {sfIndexNext, SoeOptional}, + {sfIndexPrevious, SoeOptional}, + {sfNFTokenID, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, + {sfDomainID, SoeOptional} // order book directories +})) + +/** The ledger object which lists details about amendments on the network. + + \note This is a singleton: only one such object exists in the ledger. + + \sa keylet::amendments + */ +LEDGER_ENTRY(ltAMENDMENTS, 0x0066, Amendments, amendments, ({ + {sfAmendments, SoeOptional}, // Enabled + {sfMajorities, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A ledger object that contains a list of ledger hashes. + + This type is used to store the ledger hashes which the protocol uses + to implement skip lists that allow for efficient backwards (and, in + theory, forward) forward iteration across large ledger ranges. + + \sa keylet::skip + */ +LEDGER_ENTRY(ltLEDGER_HASHES, 0x0068, LedgerHashes, hashes, ({ + {sfFirstLedgerSequence, SoeOptional}, + {sfLastLedgerSequence, SoeOptional}, + {sfHashes, SoeRequired}, +})) + +/** The ledger object which lists details about sidechains. + + \sa keylet::bridge +*/ +LEDGER_ENTRY(ltBRIDGE, 0x0069, Bridge, bridge, ({ + {sfAccount, SoeRequired}, + {sfSignatureReward, SoeRequired}, + {sfMinAccountCreateAmount, SoeOptional}, + {sfXChainBridge, SoeRequired}, + {sfXChainClaimID, SoeRequired}, + {sfXChainAccountCreateCount, SoeRequired}, + {sfXChainAccountClaimCount, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes an offer on the DEX. + + \sa keylet::offer + */ +LEDGER_ENTRY(ltOFFER, 0x006f, Offer, offer, ({ + {sfAccount, SoeRequired}, + {sfSequence, SoeRequired}, + {sfTakerPays, SoeRequired}, + {sfTakerGets, SoeRequired}, + {sfBookDirectory, SoeRequired}, + {sfBookNode, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfAdditionalBooks, SoeOptional}, +})) + +/** A ledger object which describes a deposit pre-authorization. + + \sa keylet::depositPreauth + */ +LEDGER_ENTRY_DUPLICATE(ltDEPOSIT_PREAUTH, 0x0070, DepositPreauth, deposit_preauth, ({ + {sfAccount, SoeRequired}, + {sfAuthorize, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfAuthorizeCredentials, SoeOptional}, +})) + +/** A claim id for a cross chain transaction. + + \sa keylet::xChainClaimID +*/ +LEDGER_ENTRY(ltXCHAIN_OWNED_CLAIM_ID, 0x0071, XChainOwnedClaimID, xchain_owned_claim_id, ({ + {sfAccount, SoeRequired}, + {sfXChainBridge, SoeRequired}, + {sfXChainClaimID, SoeRequired}, + {sfOtherChainSource, SoeRequired}, + {sfXChainClaimAttestations, SoeRequired}, + {sfSignatureReward, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes a bidirectional trust line. + + @note Per Vinnie Falco this should be renamed to ltTRUST_LINE + + \sa keylet::trustLine + */ +LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({ + {sfBalance, SoeRequired}, + {sfLowLimit, SoeRequired}, + {sfHighLimit, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfLowNode, SoeOptional}, + {sfLowQualityIn, SoeOptional}, + {sfLowQualityOut, SoeOptional}, + {sfHighNode, SoeOptional}, + {sfHighQualityIn, SoeOptional}, + {sfHighQualityOut, SoeOptional}, + {sfHighSponsor, SoeOptional}, + {sfLowSponsor, SoeOptional}, +})) + +/** The ledger object which lists the network's fee settings. + + \note This is a singleton: only one such object exists in the ledger. + + \sa keylet::feeSettings + */ +LEDGER_ENTRY(ltFEE_SETTINGS, 0x0073, FeeSettings, fee, ({ + // Old version uses raw numbers + {sfBaseFee, SoeOptional}, + {sfReferenceFeeUnits, SoeOptional}, + {sfReserveBase, SoeOptional}, + {sfReserveIncrement, SoeOptional}, + // New version uses Amounts + {sfBaseFeeDrops, SoeOptional}, + {sfReserveBaseDrops, SoeOptional}, + {sfReserveIncrementDrops, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A claim id for a cross chain create account transaction. + + \sa keylet::xChainCreateAccountClaimID +*/ +LEDGER_ENTRY(ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID, 0x0074, XChainOwnedCreateAccountClaimID, xchain_owned_create_account_claim_id, ({ + {sfAccount, SoeRequired}, + {sfXChainBridge, SoeRequired}, + {sfXChainAccountCreateCount, SoeRequired}, + {sfXChainCreateAccountAttestations, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object describing a single escrow. + + \sa keylet::escrow + */ +LEDGER_ENTRY(ltESCROW, 0x0075, Escrow, escrow, ({ + {sfAccount, SoeRequired}, + {sfSequence, SoeOptional}, + {sfDestination, SoeRequired}, + {sfAmount, SoeRequired}, + {sfCondition, SoeOptional}, + {sfCancelAfter, SoeOptional}, + {sfFinishAfter, SoeOptional}, + {sfSourceTag, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfDestinationNode, SoeOptional}, + {sfTransferRate, SoeOptional}, + {sfIssuerNode, SoeOptional}, +})) + +/** A ledger object describing a single unidirectional XRP payment channel. + + \sa keylet::payChannel + */ +LEDGER_ENTRY(ltPAYCHAN, 0x0078, PayChannel, payment_channel, ({ + {sfAccount, SoeRequired}, + {sfDestination, SoeRequired}, + {sfSequence, SoeOptional}, + {sfAmount, SoeRequired}, + {sfBalance, SoeRequired}, + {sfPublicKey, SoeRequired}, + {sfSettleDelay, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfCancelAfter, SoeOptional}, + {sfSourceTag, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfDestinationNode, SoeOptional}, +})) + +/** The ledger object which tracks the AMM. + + \sa keylet::amm +*/ +LEDGER_ENTRY(ltAMM, 0x0079, AMM, amm, ({ + {sfAccount, SoeRequired}, + {sfTradingFee, SoeDefault}, + {sfVoteSlots, SoeOptional}, + {sfAuctionSlot, SoeOptional}, + {sfLPTokenBalance, SoeRequired}, + {sfAsset, SoeRequired}, + {sfAsset2, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A ledger object which tracks MPTokenIssuance + \sa keylet::mptokenIssuance + */ +LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ + {sfIssuer, SoeRequired}, + {sfSequence, SoeRequired}, + {sfTransferFee, SoeDefault}, + {sfOwnerNode, SoeRequired}, + {sfAssetScale, SoeDefault}, + {sfMaximumAmount, SoeOptional}, + {sfOutstandingAmount, SoeRequired}, + {sfLockedAmount, SoeOptional}, + {sfMPTokenMetadata, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfDomainID, SoeOptional}, + {sfImmutableFlags, SoeDefault}, + {sfReferenceHolding, SoeOptional}, + {sfIssuerEncryptionKey, SoeOptional}, + {sfAuditorEncryptionKey, SoeOptional}, + {sfConfidentialOutstandingAmount, SoeDefault}, +})) + +/** A ledger object which tracks MPToken + \sa keylet::mptoken + */ +LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ + {sfAccount, SoeRequired}, + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeDefault}, + {sfLockedAmount, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfConfidentialBalanceInbox, SoeOptional}, + {sfConfidentialBalanceSpending, SoeOptional}, + {sfConfidentialBalanceVersion, SoeDefault}, + {sfIssuerEncryptedBalance, SoeOptional}, + {sfAuditorEncryptedBalance, SoeOptional}, + {sfHolderEncryptionKey, SoeOptional}, +})) + +/** A ledger object which tracks Oracle + \sa keylet::oracle + */ +LEDGER_ENTRY(ltORACLE, 0x0080, Oracle, oracle, ({ + {sfOwner, SoeRequired}, + {sfOracleDocumentID, SoeOptional}, + {sfProvider, SoeRequired}, + {sfPriceDataSeries, SoeRequired}, + {sfAssetClass, SoeRequired}, + {sfLastUpdateTime, SoeRequired}, + {sfURI, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which tracks Credential + \sa keylet::credential + */ +LEDGER_ENTRY(ltCREDENTIAL, 0x0081, Credential, credential, ({ + {sfSubject, SoeRequired}, + {sfIssuer, SoeRequired}, + {sfCredentialType, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfURI, SoeOptional}, + {sfIssuerNode, SoeRequired}, + {sfSubjectNode, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which tracks PermissionedDomain + \sa keylet::permissionedDomain + */ +LEDGER_ENTRY(ltPERMISSIONED_DOMAIN, 0x0082, PermissionedDomain, permissioned_domain, ({ + {sfOwner, SoeRequired}, + {sfSequence, SoeRequired}, + {sfAcceptedCredentials, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object representing permissions an account has delegated to another account. + \sa keylet::delegate + */ +LEDGER_ENTRY(ltDELEGATE, 0x0083, Delegate, delegate, ({ + {sfAccount, SoeRequired}, + {sfAuthorize, SoeRequired}, + {sfPermissions, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfDestinationNode, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object representing a single asset vault. + \sa keylet::vault + */ +LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfOwner, SoeRequired}, + {sfAccount, SoeRequired}, + {sfData, SoeOptional}, + {sfAsset, SoeRequired}, + {sfAssetsTotal, SoeDefault}, + {sfAssetsAvailable, SoeDefault}, + {sfAssetsMaximum, SoeDefault}, + {sfLossUnrealized, SoeDefault}, + {sfShareMPTID, SoeRequired}, + {sfWithdrawalPolicy, SoeRequired}, + {sfScale, SoeDefault}, + {sfLEVersion, SoeDefault}, + // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) + // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) +})) + +/** Reserve 0x0084-0x0087 for future Vault-related objects. */ + +/** A ledger object representing a loan broker + + \sa keylet::loanBroker + */ +LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfVaultNode, SoeRequired}, + {sfVaultID, SoeRequired}, + {sfAccount, SoeRequired}, + {sfOwner, SoeRequired}, + {sfLoanSequence, SoeRequired}, + {sfData, SoeDefault}, + {sfManagementFeeRate, SoeDefault}, + {sfOwnerCount, SoeDefault}, + {sfDebtTotal, SoeDefault}, + {sfDebtMaximum, SoeDefault}, + {sfCoverAvailable, SoeDefault}, + {sfCoverRateMinimum, SoeDefault}, + {sfCoverRateLiquidation, SoeDefault}, +})) + +/** A ledger object representing a loan between a Borrower and a Loan Broker + + \sa keylet::loan + */ +LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfLoanBrokerNode, SoeRequired}, + {sfLoanBrokerID, SoeRequired}, + {sfLoanSequence, SoeRequired}, + {sfBorrower, SoeRequired}, + {sfLoanOriginationFee, SoeDefault}, + {sfLoanServiceFee, SoeDefault}, + {sfLatePaymentFee, SoeDefault}, + {sfClosePaymentFee, SoeDefault}, + {sfOverpaymentFee, SoeDefault}, + {sfInterestRate, SoeDefault}, + {sfLateInterestRate, SoeDefault}, + {sfCloseInterestRate, SoeDefault}, + {sfOverpaymentInterestRate, SoeDefault}, + {sfStartDate, SoeRequired}, + {sfPaymentInterval, SoeRequired}, + {sfGracePeriod, SoeDefault}, + {sfPreviousPaymentDueDate, SoeDefault}, + {sfNextPaymentDueDate, SoeDefault}, + // The loan object tracks these values: + // + // - PaymentRemaining: The number of payments left in the loan. When it + // reaches 0, the loan is paid off, and all other relevant values + // must also be 0. + // + // - PeriodicPayment: The fixed, unrounded amount to be paid each + // interval. Stored with as much precision as possible. + // Payment transactions must round this value *UP*. + // + // - TotalValueOutstanding: The rounded total amount owed by the + // borrower to the lender / vault. + // + // - PrincipalOutstanding: The rounded portion of the + // TotalValueOutstanding that is from the principal borrowed. + // + // - ManagementFeeOutstanding: The rounded portion of the + // TotalValueOutstanding that represents management fees + // specifically owed to the broker based on the initial + // loan parameters. + // + // There are additional values that can be computed from these: + // + // - InterestOutstanding = TotalValueOutstanding - PrincipalOutstanding + // The total amount of interest still pending on the loan, + // independent of management fees. + // + // - InterestOwedToVault = InterestOutstanding - ManagementFeeOutstanding + // The amount of the total interest that is owed to the vault, and + // will be sent to it as part of a payment. + // + // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment + // The unrounded true total value of the loan. + // + // - TrueTotalPrincipalOutstanding can be computed using the algorithm + // in the xrpl::detail::loanPrincipalFromPeriodicPayment function. + // + // - TrueTotalInterestOutstanding = TrueTotalLoanValue - + // TrueTotalPrincipalOutstanding + // The unrounded true total interest remaining. + // + // - TrueTotalManagementFeeOutstanding = TrueTotalInterestOutstanding * + // LoanBroker.ManagementFeeRate + // The unrounded true total fee still owed to the broker. + // + // Note the "True" values may differ significantly from the tracked + // rounded values. + {sfPaymentRemaining, SoeDefault}, + {sfPeriodicPayment, SoeRequired}, + {sfPrincipalOutstanding, SoeDefault}, + {sfTotalValueOutstanding, SoeDefault}, + {sfManagementFeeOutstanding, SoeDefault}, + // Based on the computed total value at creation, used for + // rounding calculated values so they are all on a + // consistent scale - that is, they all have the same + // number of digits after the decimal point (excluding + // trailing zeros). + {sfLoanScale, SoeDefault}, +})) + +/** A ledger object representing a sponsorship. + \sa keylet::sponsorship + */ +LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwner, SoeRequired}, + {sfSponsee, SoeRequired}, + {sfFeeAmount, SoeOptional}, + {sfMaxFee, SoeOptional}, + {sfRemainingOwnerCount, SoeDefault}, + {sfOwnerNode, SoeRequired}, + {sfSponseeNode, SoeRequired}, +})) + +#undef EXPAND +#undef LEDGER_ENTRY_DUPLICATE diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref new file mode 100644 index 00000000..c9d8c4fa --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref @@ -0,0 +1,23 @@ +https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/ledger_entries.macro +sha 9859e5cedaffce2f9544d7e2b6aa0e041c3a6f75 +date 2026-08-07T15:00:25Z + +ledger_entries.macro is vendored byte-identical to the ref above so that it can be +re-verified with a plain diff: + + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/9859e5cedaffce2f9544d7e2b6aa0e041c3a6f75/include/xrpl/protocol/detail/ledger_entries.macro \ + | diff - Tests/Xrpl.Tests/Fixtures/ledger_entries.macro + +This is the only place the protocol states which fields belong to which ledger +object: definitions.json carries field codes and object types, but not the +per-object field lists. + +Pinned to a develop commit rather than to a release tag — unlike LedgerFormats.h, +which is pinned to the 3.3.0 tag. The models track +develop for fields: sfLEVersion (Vault) exists only after 07/30/2026 and is absent +from 3.3.0-rc1, so a tag would report it as a field the models invented. This sha +is the one protocol-watch recorded when it reported the change. + +Do not hand-edit it. When protocol-watch reports a change to this file upstream, +replace it wholesale, update the sha above, and let TestULedgerEntryFieldsConformance +show which models have to follow. diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro b/Tests/Xrpl.Tests/Fixtures/transactions.macro index e805596c..1f9603db 100644 --- a/Tests/Xrpl.Tests/Fixtures/transactions.macro +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro @@ -705,7 +705,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, {sfMaximumAmount, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, })) /** This transaction type destroys a MPTokensIssuance instance */ @@ -734,7 +734,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfDomainID, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfTransferFee, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, })) @@ -1085,7 +1085,7 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::Delegable, + Delegation::NotDelegable, featureConfidentialTransfer, NoPriv, ({ @@ -1189,9 +1189,9 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, - {sfFeeAmount, SoeOptional}, + {sfFeeAmountDelta, SoeOptional}, {sfMaxFee, SoeOptional}, - {sfRemainingOwnerCount, SoeOptional}, + {sfRemainingOwnerCountDelta, SoeOptional}, })) /** This system-generated transaction type is used to update the status of the various amendments. diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref index ff6ce6ed..83256926 100644 --- a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref @@ -1,11 +1,12 @@ -https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/transactions.macro -sha fd2cc6dcb308fb811960380eba7bf4309934d05d -date 2026-07-10T21:58:19Z +https://github.com/XRPLF/rippled/blob/3.3.0/include/xrpl/protocol/detail/transactions.macro +sha 00a178fb92ca49521b937ae1a99d863765ea8a90 +date 2026-08-06T16:34:39Z +tag 3.3.0 transactions.macro is vendored byte-identical to the ref above so that it can be re-verified with a plain diff: - curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/fd2cc6dcb308fb811960380eba7bf4309934d05d/include/xrpl/protocol/detail/transactions.macro \ + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/00a178fb92ca49521b937ae1a99d863765ea8a90/include/xrpl/protocol/detail/transactions.macro \ | diff - Tests/Xrpl.Tests/Fixtures/transactions.macro Do not hand-edit it. When protocol-watch reports a change to this file upstream, diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index 671f7c5f..e8135c9a 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -29,6 +29,9 @@ public static class AmendmentGuard /// Amendment id of ConfidentialTransfer (sha512half of the name). public const string ConfidentialTransfer = "2110E4A19966E2EF517C0A8C56A5F35099D7665B0BB89D7B126B30D50B86AAD5"; + /// Amendment id of DynamicMPT / XLS-94 (sha512half of the name). + public const string DynamicMPT = "58E92F338758479C06084E1B6BA366BAD8F75E5329A7F0EEAFFFDA51E5106B7F"; + /// Amendment id of PriceOracle / XLS-47 (sha512half of the name). public const string PriceOracle = "96FD2F293A519AE1DB6F8BED23E4AD9119342DA7CB6BAFD00953D16C54205D8B"; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 2870e6e9..50bbbcf7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -71,8 +71,8 @@ private static async Task SponsorshipSetAsync(XrplWallet sponsor, XrplWallet spo { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 10m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 10m }, + RemainingOwnerCountDelta = 3, }; setup = await client.Autofill(setup); ValidateResult(await client.SubmitAndWait(setup, sponsor, autofill: false)); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs new file mode 100644 index 00000000..e0e1c888 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs @@ -0,0 +1,248 @@ +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// DynamicMPT (XLS-94) end-to-end coverage: capabilities and fields of an +/// issuance stay mutable unless the issuer freezes them via ImmutableFlags, +/// and a later MPTokenIssuanceSet either performs the mutation, enables a +/// capability through a tfMPTSet* flag, or freezes more of the issuance. Both +/// directions are checked against the ledger object, plus the permission rule +/// that makes the feature meaningful — mutating a frozen field is rejected. +/// +/// Amendment-gated: DynamicMPT is Supported::No on rippled 3.2.x, so these +/// tests skip on the CI stand and run for real on the nightly stand, where +/// generate-amendments.sh puts DynamicMPT into [amendments]. +/// +[TestClass] +[TestCategory("DynamicMPT")] +public class TestIDynamicMPT : TestIMPTokenBase +{ + private static IXrplClient client; + private static bool dynamicMptActive; + + protected override IXrplClient GetClient() => client; + + /// "MPT-METADATA" in hex — the value the issuance is created with. + private const string InitialMetadata = "4D50542D4D45544144415441"; + + /// "MPT-UPDATED" in hex — the value a mutation writes over it. + private const string UpdatedMetadata = "4D50542D55504441544544"; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + dynamicMptActive = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.DynamicMPT); + } + + [TestInitialize] + public void CheckAmendment() + { + if (!dynamicMptActive) + { + Assert.Inconclusive("DynamicMPT amendment is not enabled on the test node; run the nightly stand (.ci-config/docker-compose.batchv11.yml)."); + } + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + [TestMethod] + public async Task TestDynamicMPT_ImmutableFlagsOnCreate_ReachTheLedgerObject() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + MPTokenIssuanceImmutableFlags immutable = + MPTokenIssuanceImmutableFlags.tifMPTMetadata | + MPTokenIssuanceImmutableFlags.tifMPTTransferFee | + MPTokenIssuanceImmutableFlags.tifMPTCanLock; + + string issuanceId = await CreateIssuance(issuer, MPTokenIssuanceCreateFlags.tfMPTCanTransfer, immutable, InitialMetadata); + + LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); + + Assert.IsNotNull(issuance.ImmutableFlags, "ImmutableFlags should be present on the issuance"); + Assert.AreEqual((uint)immutable, issuance.ImmutableFlags.Value, "ImmutableFlags should round-trip unchanged"); + Assert.AreEqual(InitialMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should round-trip unchanged"); + } + + [TestMethod] + public async Task TestDynamicMPT_MutateTransferFeeAndMetadata() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // No ImmutableFlags: with DynamicMPT everything the amendment covers stays + // mutable by default. A TransferFee above zero still requires lsfMPTCanTransfer + // on the issuance (rippled MPTokenIssuanceSet::preclaim), hence tfMPTCanTransfer. + string issuanceId = await CreateIssuance( + issuer, + MPTokenIssuanceCreateFlags.tfMPTCanTransfer, + null, + InitialMetadata); + + MPTokenIssuanceSet mutation = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + TransferFee = 500, + MPTokenMetadata = UpdatedMetadata, + }; + mutation = await client.Autofill(mutation); + TransactionSummary result = await client.SubmitAndWait(mutation, issuer, true); + ValidateResult(result); + + LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); + + Assert.AreEqual((ushort)500, issuance.TransferFee, "TransferFee should be the mutated value"); + Assert.AreEqual(UpdatedMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should be the mutated value"); + + // sfImmutableFlags is soeDEFAULT and this mutation never writes it, so the + // issuance stays fully mutable + Assert.IsTrue( + issuance.ImmutableFlags is null or 0u, + "ImmutableFlags should stay unset when the mutation does not freeze anything"); + } + + [TestMethod] + public async Task TestDynamicMPT_SetCapabilityFlag_EnablesCapabilityOnTheIssuance() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // Created WITHOUT tfMPTCanLock and without freezing it, so it may be enabled later + string issuanceId = await CreateIssuance(issuer, null, null, null); + + LOMPTokenIssuance before = await ReadIssuance(issuanceId); + Assert.IsTrue( + (before.Flags.GetValueOrDefault() & MPTokenIssuanceFlags.MPTCanLock) == 0, + "MPTCanLock should not be set before the mutation"); + + MPTokenIssuanceSet enable = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanLock, + }; + enable = await client.Autofill(enable); + TransactionSummary result = await client.SubmitAndWait(enable, issuer, true); + ValidateResult(result); + + LOMPTokenIssuance after = await ReadIssuance(issuanceId); + Assert.IsTrue( + (after.Flags.GetValueOrDefault() & MPTokenIssuanceFlags.MPTCanLock) != 0, + "MPTCanLock should be set after tfMPTSetCanLock"); + } + + [TestMethod] + public async Task TestDynamicMPT_MutationOfFrozenField_IsRejected() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // Metadata frozen at creation: no later transaction may rewrite it + string issuanceId = await CreateIssuance( + issuer, + null, + MPTokenIssuanceImmutableFlags.tifMPTMetadata, + InitialMetadata); + + MPTokenIssuanceSet mutation = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + MPTokenMetadata = UpdatedMetadata, + }; + mutation = await client.Autofill(mutation); + + await Helper.ThrowsExceptionAsync( + () => client.SubmitAndWait(mutation, issuer, true), + "Final tx result is not success: tecNO_PERMISSION"); + + LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); + Assert.AreEqual(InitialMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should be untouched by the rejected mutation"); + } + + [TestMethod] + public async Task TestDynamicMPT_FreezeViaSet_BlocksTheNextMutation() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // Created fully mutable, then frozen by a separate MPTokenIssuanceSet: + // doApply ORs ImmutableFlags into the ledger object, so a freeze is one-way + string issuanceId = await CreateIssuance(issuer, null, null, InitialMetadata); + + MPTokenIssuanceSet freeze = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + ImmutableFlags = MPTokenIssuanceImmutableFlags.tifMPTMetadata, + }; + freeze = await client.Autofill(freeze); + ValidateResult(await client.SubmitAndWait(freeze, issuer, true)); + + LOMPTokenIssuance frozen = await ReadIssuance(issuanceId); + Assert.IsNotNull(frozen.ImmutableFlags, "ImmutableFlags should be present after the freeze"); + Assert.AreEqual( + (uint)MPTokenIssuanceImmutableFlags.tifMPTMetadata, + frozen.ImmutableFlags.Value, + "ImmutableFlags should carry the freshly frozen bit"); + + MPTokenIssuanceSet mutation = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + MPTokenMetadata = UpdatedMetadata, + }; + mutation = await client.Autofill(mutation); + + await Helper.ThrowsExceptionAsync( + () => client.SubmitAndWait(mutation, issuer, true), + "Final tx result is not success: tecNO_PERMISSION"); + } + + private static async Task CreateIssuance( + XrplWallet issuer, + MPTokenIssuanceCreateFlags? flags, + MPTokenIssuanceImmutableFlags? immutableFlags, + string metadata) + { + MPTokenIssuanceCreate create = new MPTokenIssuanceCreate + { + Account = issuer.ClassicAddress, + Flags = flags, + ImmutableFlags = immutableFlags, + MPTokenMetadata = metadata, + }; + create = await client.Autofill(create); + TransactionSummary created = await client.SubmitAndWait(create, issuer, true); + ValidateResult(created); + + string issuanceId = GetMPTokenIssuanceIdFromMeta(created); + Assert.IsNotNull(issuanceId, "MPTokenIssuanceID should be present in the metadata"); + return issuanceId; + } + + private static async Task ReadIssuance(string issuanceId) + { + LedgerEntryRequest request = new LedgerEntryRequest { MptIssuance = issuanceId }; + LedgerEntryResponse response = await client.LedgerEntry(request); + + Assert.IsNotNull(response?.Node, "ledger_entry should return the MPTokenIssuance node"); + Assert.IsInstanceOfType(response.Node, typeof(LOMPTokenIssuance), "Node should deserialize to LOMPTokenIssuance"); + return (LOMPTokenIssuance)response.Node; + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs index 8544dc8d..63f57c37 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs @@ -336,11 +336,9 @@ public async Task TestLoanLedgerEntry_VerifyFields() Assert.IsNotNull(loan.LoanBrokerID, "LoanBrokerID should be set"); Assert.IsNotNull(loan.LoanSequence, "LoanSequence should be set"); - // Number fields — PrincipalRequested was explicitly set to "10000000" in LoanSet, - // but rippled may omit zero-value Number fields. - // PrincipalOutstanding may be null if no payments have been made yet (depends on rippled behavior). - if (loan.PrincipalRequested != null) - Assert.IsTrue(loan.PrincipalRequested.Length > 0, "PrincipalRequested should be non-empty if present"); + // PrincipalRequested is a field of the LoanSet TRANSACTION, not of the Loan object: + // rippled records the amount as PrincipalOutstanding, so the object never carries it + // (confirmed against a live node — the created object holds PrincipalOutstanding only). if (loan.PrincipalOutstanding != null) Assert.IsTrue(loan.PrincipalOutstanding.Length > 0, "PrincipalOutstanding should be non-empty if present"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs index 4f359004..fb4ec55e 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -74,8 +74,8 @@ public async Task TestSponsorshipSet_BySponsor_CreatesLedgerObject() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; tx = await client.Autofill(tx); @@ -89,6 +89,42 @@ public async Task TestSponsorshipSet_BySponsor_CreatesLedgerObject() Assert.AreEqual((uint)3, sponsorship.RemainingOwnerCount); } + [TestMethod] + public async Task TestSponsorshipSet_NegativeDeltas_ReduceTheBudget() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet sponsee = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, sponsee); + + SponsorshipSet create = new SponsorshipSet + { + Account = sponsor.ClassicAddress, + Sponsee = sponsee.ClassicAddress, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, + }; + create = await client.Autofill(create); + ValidateResult(await client.SubmitAndWait(create, sponsor, true)); + + // Since 3.3.0 the transaction carries signed deltas rather than absolute values: + // rippled adds them to what the Sponsorship object already holds, moving the XRP + // back to the sponsor balance for a negative FeeAmountDelta + SponsorshipSet reduce = new SponsorshipSet + { + Account = sponsor.ClassicAddress, + Sponsee = sponsee.ClassicAddress, + FeeAmountDelta = new Currency { ValueAsXrp = -2m }, + RemainingOwnerCountDelta = -1, + }; + reduce = await client.Autofill(reduce); + ValidateResult(await client.SubmitAndWait(reduce, sponsor, true)); + + LOSponsorship sponsorship = await GetSponsorshipObject(sponsor.ClassicAddress); + Assert.IsNotNull(sponsorship, "Sponsorship ledger object should still exist after the reduction"); + Assert.AreEqual(3m, sponsorship.FeeAmount.ValueAsXrp, "FeeAmount should be 5 XRP + (-2 XRP)"); + Assert.AreEqual((uint)2, sponsorship.RemainingOwnerCount, "RemainingOwnerCount should be 3 + (-1)"); + } + [TestMethod] public async Task TestSponsoredPayment_SponsorPaysFee() { @@ -102,7 +138,7 @@ public async Task TestSponsoredPayment_SponsorPaysFee() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, }; setup = await client.Autofill(setup); ValidateResult(await client.SubmitAndWait(setup, sponsor, true)); @@ -139,7 +175,7 @@ public async Task TestSponsoredPayment_SponsorPaysFee() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, Flags = flags, }; setup = await client.Autofill(setup); @@ -256,7 +292,7 @@ public async Task TestSponsorshipSet_DeleteObject() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 2m }, + FeeAmountDelta = new Currency { ValueAsXrp = 2m }, }; create = await client.Autofill(create); ValidateResult(await client.SubmitAndWait(create, sponsor, true)); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs index 6a90ad5f..54127acc 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs @@ -75,7 +75,7 @@ await SubmitTesAsync(new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 10m }, + FeeAmountDelta = new Currency { ValueAsXrp = 10m }, }, sponsor); return (sponsor, sponsee, destination); } diff --git a/Tests/Xrpl.Tests/MockRippled/Server.cs b/Tests/Xrpl.Tests/MockRippled/Server.cs index 4f8fdef5..9af307f5 100644 --- a/Tests/Xrpl.Tests/MockRippled/Server.cs +++ b/Tests/Xrpl.Tests/MockRippled/Server.cs @@ -254,10 +254,14 @@ public void Stop() /// The async operation state private void connectionCallback(IAsyncResult AsyncResult) { + // Held until ownership passes to the MockClient, so a handshake that throws + // half-way closes the socket instead of leaking it for the process lifetime. + Socket clientSocket = null; + try { // Gets the client thats trying to connect to the server - Socket clientSocket = GetSocket().EndAccept(AsyncResult); + clientSocket = GetSocket().EndAccept(AsyncResult); // Read the handshake updgrade request byte[] handshakeBuffer = new byte[1024]; @@ -267,25 +271,55 @@ private void connectionCallback(IAsyncResult AsyncResult) string requestKey = Helpers.GetHandshakeRequestKey(Encoding.Default.GetString(handshakeBuffer)); string hanshakeResponse = Helpers.GetHandshakeResponse(Helpers.HashKey(requestKey)); - // Send the handshake updgrade response to the connecting client + // Send the handshake updgrade response to the connecting client clientSocket.Send(Encoding.Default.GetBytes(hanshakeResponse)); - // Create a new client object and add + // Create a new client object and add // it to the list of connected clients MockClient client = new MockClient(this, clientSocket); + clientSocket = null; _clients.Add(client); - // Call the event when a client has connected to the listen server + // Call the event when a client has connected to the listen server if (OnClientConnected == null) throw new Exception("Server error: event OnClientConnected is not bound!"); OnClientConnected(this, new OnClientConnectedHandler(client)); + } + catch (ObjectDisposedException) + { + // Stop() closed the listen socket: nothing left to accept on, and re-arming + // below would only throw again. + return; + } + catch (Exception Exception) + { + Debug.WriteLine("An error has occured while trying to accept a connecting client.\n\n{0}", Exception.Message); - // Start to accept incomming connections again - GetSocket().BeginAccept(connectionCallback, null); + try + { + clientSocket?.Close(); + } + catch (Exception) + { + // The peer is already gone; nothing to salvage. + } + } + // Re-arm unconditionally. This call used to be the last statement of the try block, + // so a peer that reset the connection during the handshake ended the accept loop for + // good: the listen socket stayed bound — the port still looked taken and TCP connects + // still completed — while nothing was ever accepted again, and every later client hung + // until its own connect timeout. One bad connection must not deafen the mock. + try + { + GetSocket().BeginAccept(connectionCallback, null); + } + catch (ObjectDisposedException) + { + // Stop() ran while this callback was in flight. } catch (Exception Exception) { - Debug.WriteLine("An error has occured while trying to accept a connecting client.\n\n{0}", Exception.Message); + Debug.WriteLine("An error has occured while re-arming the accept loop.\n\n{0}", Exception.Message); } } diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs b/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs new file mode 100644 index 00000000..ec4b9ee8 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Reads the vendored rippled ledger_entries.macro — the only place the protocol + /// states which fields belong to which ledger object. definitions.json carries field + /// codes and object types, but not the per-object field lists, so it cannot answer this. + /// + /// + /// The counterpart of for ledger objects. Same + /// contract: the source is C++ macro text, so every parse step fails loudly rather than + /// yielding a thin or empty table — a silently empty result would turn the conformance + /// test green on nothing. + /// + internal static class RippledLedgerEntryFormats + { + /// + /// LEDGER_ENTRY(ltTAG, 0x00NN, Name, rpcName, ({ {sfField, SoeX}, ... })) + /// LEDGER_ENTRY_DUPLICATE(...) has the same shape and declares an object that shares + /// a type code with another one, so it is parsed identically. + /// + private static readonly Regex EntryBlock = new Regex( + @"LEDGER_ENTRY(?:_DUPLICATE)?\(\s*lt\w+\s*,\s*0x[0-9a-fA-F]+\s*,\s*(?\w+)\s*,(?.*?)\}\)\)", + RegexOptions.Singleline | RegexOptions.Compiled); + + /// {sfField, SoeRequired} / {sfField, SoeOptional} / {sfField, SoeDefault} + private static readonly Regex FieldEntry = new Regex( + @"\{\s*sf(?\w+)\s*,\s*Soe(?Required|Optional|Default)\b", + RegexOptions.Compiled); + + /// Catches a requirement keyword the mapping below does not know yet. + private static readonly Regex AnyFieldEntry = new Regex( + @"\{\s*sf(?\w+)\s*,\s*Soe(?\w+)", + RegexOptions.Compiled); + + /// + /// Lower bounds on a healthy parse, asserted by the guard test as well so the two + /// cannot disagree about what "parsed enough" means. + /// + internal const int MinimumExpectedEntries = 25; + + /// + internal const int MinimumExpectedFields = 250; + + internal static string FixturePath => + Path.Combine(AppContext.BaseDirectory, "Fixtures", "ledger_entries.macro"); + + /// + /// Fields every ledger object carries, declared once in rippled's + /// LedgerFormats::getCommonFields() (src/libxrpl/protocol/LedgerFormats.cpp) + /// rather than per object in the macro — the ledger-side counterpart of + /// TxFormats' commonFields. Both directions of the conformance diff + /// exclude them: the macro never lists them, so requiring them of a model would be + /// wrong, and a model that does expose them is not inventing anything. + /// + /// + /// Hand-maintained: the list lives in a .cpp, which protocol-watch does not track + /// (it watches the headers and macros). It has held these four for releases — + /// sfSponsor was the last addition, with XLS-68 — so drift here is slow and + /// visible: a new common field would surface as the same name reported missing from + /// every single model at once. + /// + internal static HashSet CommonFields() => new(StringComparer.Ordinal) + { + "LedgerIndex", + "LedgerEntryType", + "Flags", + "Sponsor", + }; + + /// How rippled declares a field of a ledger object. + internal enum Requirement + { + /// Always present. + Required, + + /// May be absent. + Optional, + + /// Absent means the type's default value, not missing data. + Default, + } + + /// + /// Ledger object name -> field name -> requirement, exactly as rippled declares it. + /// + internal static Dictionary> Parse() + { + if (!File.Exists(FixturePath)) + throw new InvalidOperationException($"Vendored ledger_entries.macro not found at {FixturePath}"); + + string macro = File.ReadAllText(FixturePath); + if (string.IsNullOrWhiteSpace(macro)) + throw new InvalidOperationException("Vendored ledger_entries.macro is empty"); + + Dictionary> entries = new(); + int fieldCount = 0; + + foreach (Match block in EntryBlock.Matches(macro)) + { + string name = block.Groups["name"].Value; + string body = block.Groups["body"].Value; + + foreach (Match raw in AnyFieldEntry.Matches(body)) + { + string keyword = raw.Groups["requirement"].Value; + if (keyword is not ("Required" or "Optional" or "Default")) + { + throw new InvalidOperationException( + $"{name}.{raw.Groups["field"].Value}: unknown requirement keyword 'Soe{keyword}' — " + + "the macro format changed, update the parser before trusting this test"); + } + } + + Dictionary fields = new(); + foreach (Match field in FieldEntry.Matches(body)) + { + fields[field.Groups["field"].Value] = field.Groups["requirement"].Value switch + { + "Required" => Requirement.Required, + "Optional" => Requirement.Optional, + "Default" => Requirement.Default, + _ => throw new InvalidOperationException("unreachable"), + }; + } + + // Indexer assignment would let a second declaration of the same name replace + // the first, dropping that object from the conformance table while the field + // count below still grew — the minimum-count guard would not notice + if (entries.ContainsKey(name)) + { + throw new InvalidOperationException( + $"{name}: declared twice in ledger_entries.macro — the parser would drop one " + + "definition, update it before trusting this test"); + } + + entries.Add(name, fields); + fieldCount += fields.Count; + } + + if (entries.Count < MinimumExpectedEntries || fieldCount < MinimumExpectedFields) + { + throw new InvalidOperationException( + $"Parsed only {entries.Count} ledger entries / {fieldCount} fields from " + + $"ledger_entries.macro (expected at least {MinimumExpectedEntries} / " + + $"{MinimumExpectedFields}) — the macro layout changed and the parser " + + "silently stopped matching"); + } + + return entries; + } + } +} diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs new file mode 100644 index 00000000..f3cada13 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Reads the vendored rippled LedgerFormats.h — the only place the protocol states + /// which lsf flags belong to which ledger object. definitions.json and the + /// server_definitions RPC carry field codes and ledger entry types but no flag + /// values, so they cannot answer this question. + /// + /// + /// The source is C++ macro text, not a stability-guaranteed contract. Every parse step + /// fails loudly rather than yielding a thin or empty table — a silently empty result + /// would turn the conformance test green on nothing. + /// + internal static class RippledLedgerFlags + { + /// + /// LEDGER_ENTRY(ltNAME, 0x00, Name, ...) blocks are irrelevant here; the flags live in + /// LEDGER_OBJECT(Name, LSF_FLAG(lsfX, 0x…) …) blocks of the LEDGER_OBJECT_FLAGS list. + /// + private static readonly Regex ObjectBlock = new Regex( + @"LEDGER_OBJECT\(\s*(?\w+)\s*,(?(?:[^()]|\((?:[^()])*\))*)\)", + RegexOptions.Singleline | RegexOptions.Compiled); + + /// LSF_FLAG(lsfX, 0x00010000) / LSF_FLAG2(lsfX, 0x00000001) + private static readonly Regex FlagEntry = new Regex( + @"LSF_FLAG2?\(\s*(?ls[fm]\w+)\s*,\s*(?0x[0-9a-fA-F]+)\s*\)", + RegexOptions.Compiled); + + /// + /// MPTokenIssuance ImmutableFlags do not live in a LEDGER_OBJECT block: since 3.3.0 + /// rippled declares them as plain constants next to the macro list + /// (inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002;). They are the + /// values of sfImmutableFlags, so the models still have to conform to them. + /// + private static readonly Regex ImmutableFlagConstant = new Regex( + @"inline\s+constexpr\s+std::uint32_t\s+(?lsif\w+)\s*=\s*(?0x[0-9a-fA-F]+)\s*;", + RegexOptions.Compiled); + + /// + /// The synthetic object name the lsif* constants are reported under, so they take part + /// in the same conformance check as the LEDGER_OBJECT flags. + /// + internal const string ImmutableFlagsObject = "MPTokenIssuanceImmutable"; + + /// + /// Catches an LSF_FLAG variant the parser does not know yet — a new macro name would + /// otherwise drop its flags silently and leave the conformance test passing. + /// + private static readonly Regex AnyFlagMacro = new Regex( + @"(?LSF_FLAG\w*)\(", RegexOptions.Compiled); + + /// + /// Lower bounds on a healthy parse. Exposed so the guard test asserts against the same + /// numbers the parser enforces, instead of literals that would drift on re-pinning. + /// + internal const int MinimumExpectedObjects = 10; + + /// + internal const int MinimumExpectedFlags = 50; + + internal static string FixturePath => + Path.Combine(AppContext.BaseDirectory, "Fixtures", "LedgerFormats.h"); + + /// + /// Ledger object name -> flag name -> value, exactly as rippled declares it. + /// + internal static Dictionary> Parse() + { + if (!File.Exists(FixturePath)) + throw new InvalidOperationException($"Vendored LedgerFormats.h not found at {FixturePath}"); + + string header = File.ReadAllText(FixturePath); + if (string.IsNullOrWhiteSpace(header)) + throw new InvalidOperationException("Vendored LedgerFormats.h is empty"); + + foreach (Match macro in AnyFlagMacro.Matches(header)) + { + string name = macro.Groups["macro"].Value; + if (name is not ("LSF_FLAG" or "LSF_FLAG2")) + { + throw new InvalidOperationException( + $"Unknown flag macro '{name}' in LedgerFormats.h — the header layout changed, " + + "update the parser before trusting this test"); + } + } + + Dictionary> objects = new(); + int flagCount = 0; + + // Tracked separately from `objects`, which only holds flagged entries: a name declared + // twice must be caught even when one of the two declarations parses to no flags at all, + // otherwise the flagless-skip below would let the duplicate through unnoticed. + HashSet seenNames = new(StringComparer.Ordinal); + + foreach (Match block in ObjectBlock.Matches(header)) + { + string name = block.Groups["name"].Value; + + // Same rule as RippledLedgerEntryFormats.Parse, so the two parsers stay consistent + if (!seenNames.Add(name)) + { + throw new InvalidOperationException( + $"{name}: declared twice in LedgerFormats.h — the parser would drop one " + + "definition, update it before trusting this test"); + } + + Dictionary flags = new(); + + foreach (Match flag in FlagEntry.Matches(block.Groups["body"].Value)) + { + flags[flag.Groups["flag"].Value] = + Convert.ToUInt32(flag.Groups["value"].Value.Substring(2), 16); + } + + // LEDGER_OBJECT is also used for objects that declare no flags at all; + // those carry nothing to conform to. + if (flags.Count == 0) + continue; + + objects.Add(name, flags); + flagCount += flags.Count; + } + + Dictionary immutableFlags = new(); + foreach (Match constant in ImmutableFlagConstant.Matches(header)) + { + immutableFlags[constant.Groups["flag"].Value] = + Convert.ToUInt32(constant.Groups["value"].Value.Substring(2), 16); + } + + // Fail closed: the constants moved out of LEDGER_OBJECT in 3.3.0 and could move + // again, which would drop MPTokenIssuanceImmutable from the guard without a word. + if (immutableFlags.Count == 0) + { + throw new InvalidOperationException( + "No lsif* constants found in LedgerFormats.h — sfImmutableFlags values are no " + + "longer declared the way the parser expects, update it before trusting this test"); + } + + objects.Add(ImmutableFlagsObject, immutableFlags); + flagCount += immutableFlags.Count; + + if (objects.Count < MinimumExpectedObjects || flagCount < MinimumExpectedFlags) + { + throw new InvalidOperationException( + $"Parsed only {objects.Count} flagged ledger objects / {flagCount} flags from " + + $"LedgerFormats.h (expected at least {MinimumExpectedObjects} / {MinimumExpectedFlags}) — " + + "the header layout changed and the parser silently stopped matching"); + } + + return objects; + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs b/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs index ed8762ad..7ef287a6 100644 --- a/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs +++ b/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs @@ -142,14 +142,14 @@ public void TestUSponsorship_BinaryRoundTrip() { Account = Account1, Sponsee = Account2, - FeeAmount = new global::Xrpl.Models.Common.Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new global::Xrpl.Models.Common.Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, Flags = SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee, }; JsonObject decoded = RoundTrip(tx); Assert.AreEqual("SponsorshipSet", decoded["TransactionType"]!.GetValue()); Assert.AreEqual(Account2, decoded["Sponsee"]!.GetValue()); - Assert.AreEqual(3u, decoded["RemainingOwnerCount"]!.GetValue()); + Assert.AreEqual(3, decoded["RemainingOwnerCountDelta"]!.GetValue()); Assert.AreEqual((uint)SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee, decoded["Flags"]!.GetValue()); } diff --git a/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs new file mode 100644 index 00000000..74e675d0 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Models.Ledger; + +using LONFTokenOffer = Xrpl.Models.Methods.LONFTokenOffer; +using LONFTokenPage = Xrpl.Models.Methods.LONFTokenPage; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds the ledger-object models to the field sets rippled declares in the vendored + /// ledger_entries.macro. + /// + /// + /// The third conformance surface, next to (transaction + /// fields) and (ledger flags). A field the protocol + /// declares and the model lacks produces no symptom: reading the object still succeeds and the + /// value is simply dropped, so it stays invisible until someone needs it. That is how + /// LOAccountRoot went without WalletLocator/WalletSize until a manual completeness pass, and + /// how sfLEVersion had to be spotted through a protocol-watch notification instead of a red test. + /// + [TestClass] + public class TestULedgerEntryFieldsConformance + { + /// + /// rippled LEDGER_ENTRY name -> the model that carries its fields. Every entry in the + /// fixture must appear here; a newly added ledger object fails the test rather than + /// being skipped silently. + /// + private static readonly Dictionary Models = new(StringComparer.Ordinal) + { + ["AccountRoot"] = typeof(LOAccountRoot), + ["AMM"] = typeof(LOAmm), + ["Amendments"] = typeof(LOAmendments), + ["Bridge"] = typeof(LOBridge), + ["Check"] = typeof(LOCheck), + ["Credential"] = typeof(LOCredential), + ["Delegate"] = typeof(LODelegate), + ["DepositPreauth"] = typeof(LODepositPreauth), + ["DID"] = typeof(LODID), + ["DirectoryNode"] = typeof(LODirectoryNode), + ["Escrow"] = typeof(LOEscrow), + ["FeeSettings"] = typeof(LOFeeSettings), + ["LedgerHashes"] = typeof(LOLedgerHashes), + ["Loan"] = typeof(LOLoan), + ["LoanBroker"] = typeof(LOLoanBroker), + ["MPToken"] = typeof(LOMPToken), + ["MPTokenIssuance"] = typeof(LOMPTokenIssuance), + ["NegativeUNL"] = typeof(LONegativeUNL), + ["NFTokenOffer"] = typeof(LONFTokenOffer), + ["NFTokenPage"] = typeof(LONFTokenPage), + ["Offer"] = typeof(LOOffer), + ["Oracle"] = typeof(LOOracle), + ["PayChannel"] = typeof(LOPayChannel), + ["PermissionedDomain"] = typeof(LOPermissionedDomain), + ["RippleState"] = typeof(LORippleState), + ["SignerList"] = typeof(LOSignerList), + ["Sponsorship"] = typeof(LOSponsorship), + ["Ticket"] = typeof(LOTicket), + ["Vault"] = typeof(LOVault), + ["XChainOwnedClaimID"] = typeof(LOXChainOwnedClaimID), + ["XChainOwnedCreateAccountClaimID"] = typeof(LOXChainOwnedCreateAccountClaimID), + }; + + /// + /// Names that appear on a model but are not fields of that ledger object, with the + /// reason each is legitimate. Anything else the reverse check reports is a real finding. + /// Common fields are handled separately, via + /// . + /// + private static readonly Dictionary KnownExtras = new(StringComparer.Ordinal) + { + // BaseLedgerEntry.Index, serialized as "index" — the object's own key. rippled + // returns it alongside the object (account_objects, ledger_entry) and it is not + // part of any object's template + ["index"] = "the entry's key, returned beside the object rather than inside it", + }; + + /// + /// The JSON name a property maps to: when + /// present, the property name otherwise. Properties marked + /// never reach the wire and are excluded — that is where the computed helpers live + /// (DataParsed, MPTokenMetadataRow, Metadata, …). + /// + private static Dictionary WireProperties(Type model) + { + Dictionary map = new(StringComparer.Ordinal); + + foreach (PropertyInfo property in model.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() != null) + continue; + + string name = property.GetCustomAttribute()?.Name ?? property.Name; + map[name] = property; + } + + return map; + } + + [TestMethod] + public void TestULedgerEntryModels_MatchRippledLedgerEntriesMacro() + { + Dictionary> upstream = + RippledLedgerEntryFormats.Parse(); + HashSet common = RippledLedgerEntryFormats.CommonFields(); + StringBuilder report = new StringBuilder(); + + foreach (KeyValuePair> entry + in upstream.OrderBy(e => e.Key, StringComparer.Ordinal)) + { + if (!Models.TryGetValue(entry.Key, out Type model)) + { + report.AppendLine( + $"{entry.Key}: declared in ledger_entries.macro but no model is registered for it — " + + "add the LO type and register it in Models"); + continue; + } + + Dictionary mine = WireProperties(model); + + foreach (string field in entry.Value.Keys.OrderBy(f => f, StringComparer.Ordinal)) + { + if (!mine.ContainsKey(field)) + { + report.AppendLine( + $"{entry.Key}.{field} ({entry.Value[field]}): declared by rippled, " + + $"missing from {model.Name}"); + } + } + + foreach (string name in mine.Keys.OrderBy(n => n, StringComparer.Ordinal)) + { + if (entry.Value.ContainsKey(name) || common.Contains(name) || KnownExtras.ContainsKey(name)) + continue; + + report.AppendLine( + $"{model.Name}.{name}: on the model, not a field of {entry.Key} in rippled"); + } + } + + Assert.AreEqual( + string.Empty, + report.ToString(), + $"Ledger-object models diverge from rippled ledger_entries.macro ({RippledLedgerEntryFormats.FixturePath}):\n" + report); + } + + [TestMethod] + public void TestULedgerEntryFixture_ParsesFully() + { + Dictionary> upstream = + RippledLedgerEntryFormats.Parse(); + + Assert.IsTrue( + upstream.Count >= RippledLedgerEntryFormats.MinimumExpectedEntries, + $"Parsed {upstream.Count} ledger entries, expected at least {RippledLedgerEntryFormats.MinimumExpectedEntries}"); + + int fields = upstream.Sum(e => e.Value.Count); + Assert.IsTrue( + fields >= RippledLedgerEntryFormats.MinimumExpectedFields, + $"Parsed {fields} fields, expected at least {RippledLedgerEntryFormats.MinimumExpectedFields}"); + + // Counts alone would still pass on a parse that dropped requirements + Assert.AreEqual( + RippledLedgerEntryFormats.Requirement.Default, + upstream["Vault"]["LEVersion"], + "Vault.LEVersion should parse as SoeDefault"); + Assert.AreEqual( + RippledLedgerEntryFormats.Requirement.Required, + upstream["Vault"]["Owner"], + "Vault.Owner should parse as SoeRequired"); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs new file mode 100644 index 00000000..af3b3d96 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds the ledger-object flag enums to the values rippled declares in the vendored + /// LedgerFormats.h. + /// + /// + /// The counterpart of for the other half of the + /// protocol surface. Nothing else in the suite notices a missing flag: an unnamed bit still + /// arrives in the model as a number, so reading the object keeps working and only the + /// consumer's ability to test it by name is lost. That is how lsfMPTAMM — present since at + /// least 3.2.1 — went unnoticed until a manual diff found it. + /// + /// Pinned copy, not the live develop branch, for the same reason as the TxFormat guard: + /// tracking upstream drift is protocol-watch's job, and a network-backed test would go red + /// on Ripple's release schedule instead of ours. + /// + [TestClass] + public class TestULedgerFlagsConformance + { + /// + /// rippled LEDGER_OBJECT name -> the enum that names its flags in the models. + /// Every flagged object in the fixture must appear here; a new one fails the test + /// rather than being skipped silently. + /// + private static readonly Dictionary FlagEnums = new(StringComparer.Ordinal) + { + ["AccountRoot"] = typeof(AccountRootFlags), + ["Offer"] = typeof(OfferFlags), + ["RippleState"] = typeof(RippleStateFlags), + ["SignerList"] = typeof(SignerListFlags), + ["DirNode"] = typeof(DirectoryNodeFlags), + ["NFTokenOffer"] = typeof(NFTokenOffer), + ["MPTokenIssuance"] = typeof(MPTokenIssuanceFlags), + // rippled declares these as lsif* constants rather than a LEDGER_OBJECT block; + // TxFlags.h then aliases tifX = lsifX, and the SDK shares one enum between + // MPTokenIssuanceCreate.ImmutableFlags and MPTokenIssuanceSet.ImmutableFlags + [RippledLedgerFlags.ImmutableFlagsObject] = typeof(MPTokenIssuanceImmutableFlags), + ["MPToken"] = typeof(MPTokenFlags), + ["Credential"] = typeof(CredentialFlags), + ["Vault"] = typeof(VaultLedgerFlags), + ["Loan"] = typeof(LoanFlags), + ["Sponsorship"] = typeof(SponsorshipFlags), + }; + + /// + /// Strips the prefix rippled and the models use for the same bit, so + /// lsfMPTLocked, MPTLocked and tifMPTCanLock compare + /// against their upstream counterparts. + /// + private static string Normalize(string name) => + Regex.Replace(name, "^(lsmf|lsif|lsf|tmf|tif)", string.Empty); + + [TestMethod] + public void TestULedgerFlags_MatchRippledLedgerFormats() + { + Dictionary> upstream = RippledLedgerFlags.Parse(); + StringBuilder report = new StringBuilder(); + + foreach (KeyValuePair> entry in upstream.OrderBy(o => o.Key, StringComparer.Ordinal)) + { + if (!FlagEnums.TryGetValue(entry.Key, out Type flagEnum)) + { + report.AppendLine( + $"{entry.Key}: declares {entry.Value.Count} flag(s) in LedgerFormats.h but no model enum " + + "is registered for it — add the enum and register it in FlagEnums"); + continue; + } + + Dictionary mine = Enum.GetNames(flagEnum) + .ToDictionary( + name => Normalize(name), + name => Convert.ToUInt32(Enum.Parse(flagEnum, name)), + StringComparer.Ordinal); + + foreach (KeyValuePair flag in entry.Value.OrderBy(f => f.Key, StringComparer.Ordinal)) + { + string key = Normalize(flag.Key); + if (!mine.TryGetValue(key, out uint value)) + { + report.AppendLine( + $"{entry.Key}.{flag.Key} (0x{flag.Value:X8}): declared by rippled, " + + $"missing from {flagEnum.Name}"); + } + else if (value != flag.Value) + { + report.AppendLine( + $"{entry.Key}.{flag.Key}: rippled 0x{flag.Value:X8}, {flagEnum.Name} 0x{value:X8}"); + } + } + + // The other direction: a bit the models claim the protocol does not have. + // Zero members (None) carry no bit, and tf* members are transaction flags + // that share an enum with the ledger ones (OfferFlags.tfInnerBatchTxn). + HashSet declared = entry.Value.Keys.Select(Normalize).ToHashSet(StringComparer.Ordinal); + foreach (string name in Enum.GetNames(flagEnum).OrderBy(n => n, StringComparer.Ordinal)) + { + uint value = Convert.ToUInt32(Enum.Parse(flagEnum, name)); + if (value == 0 || name.StartsWith("tf", StringComparison.Ordinal) && !name.StartsWith("tmf", StringComparison.Ordinal)) + continue; + + if (!declared.Contains(Normalize(name))) + { + report.AppendLine( + $"{flagEnum.Name}.{name} (0x{value:X8}): in the models, " + + $"not a flag of {entry.Key} in rippled"); + } + } + } + + Assert.AreEqual( + string.Empty, + report.ToString(), + $"Ledger flag enums diverge from rippled LedgerFormats.h ({RippledLedgerFlags.FixturePath}):\n" + report); + } + + [TestMethod] + public void TestULedgerFlags_FixtureParsesFully() + { + Dictionary> upstream = RippledLedgerFlags.Parse(); + + Assert.IsTrue( + upstream.Count >= RippledLedgerFlags.MinimumExpectedObjects, + $"Parsed {upstream.Count} flagged ledger objects, expected at least {RippledLedgerFlags.MinimumExpectedObjects}"); + + int flags = upstream.Sum(o => o.Value.Count); + Assert.IsTrue( + flags >= RippledLedgerFlags.MinimumExpectedFlags, + $"Parsed {flags} flags, expected at least {RippledLedgerFlags.MinimumExpectedFlags}"); + + // A parse that yields objects but drops their values would still satisfy the counts + Assert.AreEqual( + 0x00000080u, + upstream["MPTokenIssuance"]["lsfMPTCanHoldConfidentialBalance"], + "lsfMPTCanHoldConfidentialBalance should parse to 0x80"); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUPathStep.cs b/Tests/Xrpl.Tests/Models/TestUPathStep.cs new file mode 100644 index 00000000..8e565c06 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUPathStep.cs @@ -0,0 +1,128 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Collections.Generic; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Enums; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace XrplTests.Xrpl.Models +{ + /// + /// The path step `type` field is a bitmask (STPathElement in rippled) and is modelled as the + /// [Flags] enum Xrpl.Models.Enums.PathStepType. It must stay a number on the wire: + /// XrplJsonOptions deliberately registers no global JsonStringEnumConverter, because XRPL + /// protocol enums are numeric. + /// + [TestClass] + public class TestUPathStep + { + private const string MptIssuanceId = "00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"; + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepTypeDeserializesAsFlags() + { + // shape of mainnet tx 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 + string json = @"{""currency"":""4249547800000000000000000000000000000000"",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":48}"; + + Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(PathStepType.Currency | PathStepType.Issuer, step.Type); + Assert.IsTrue(step.Type.Value.HasFlag(PathStepType.Issuer)); + Assert.IsFalse(step.Type.Value.HasFlag(PathStepType.Account)); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepMptTypeDeserializesAsFlags() + { + string json = @"{""mpt_issuance_id"":""" + MptIssuanceId + @""",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":96}"; + + Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(MptIssuanceId, step.MPTokenIssuanceID); + Assert.AreEqual(PathStepType.MPTokenIssuanceID | PathStepType.Issuer, step.Type); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepTypeStaysNumericOnTheWire() + { + Path step = new Path + { + CurrencyCode = "4249547800000000000000000000000000000000", + Issuer = "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3", + Type = PathStepType.Currency | PathStepType.Issuer, + }; + + string json = JsonSerializer.Serialize(step, XrplJsonOptions.Default); + + StringAssert.Contains(json, @"""type"":48", $"type must serialize as the number rippled sends. Got: {json}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepUndeclaredTypeBitSurvives() + { + // a future protocol bit the enum does not name must not break deserialization + Path step = JsonSerializer.Deserialize(@"{""type"":176}", XrplJsonOptions.Default); + + Assert.AreEqual(176u, (uint)step.Type.Value); + Assert.IsTrue(step.Type.Value.HasFlag(PathStepType.Issuer)); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepIgnoresLegacyTypeHex() + { + // rippled dropped type_hex from its JSON output in 1.7.0 and the property is gone from the + // model; a response from an ancient server must still deserialize, with the key ignored + string json = @"{""currency"":""USD"",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":48,""type_hex"":""0000000000000030""}"; + + Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(PathStepType.Currency | PathStepType.Issuer, step.Type); + Assert.AreEqual("USD", step.CurrencyCode); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepValidationMatchesRippledToStrand() + { + // rippled toStrand(): hasAccount && (hasIssuer || hasCurrency) -> temBAD_PATH, + // hasMPT && (hasCurrency || hasAccount) -> temBAD_PATH + Assert.IsTrue(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"))), "account-only step is valid"); + Assert.IsTrue(Validation.IsPathStep(Step(("currency", "USD"), ("issuer", "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"))), "currency+issuer step is valid"); + Assert.IsTrue(Validation.IsPathStep(Step(("mpt_issuance_id", MptIssuanceId))), "MPT step is valid"); + Assert.IsTrue(Validation.IsPathStep(Step(("mpt_issuance_id", MptIssuanceId), ("issuer", "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"))), "MPT+issuer step is valid"); + + Assert.IsFalse(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"), ("currency", "USD"))), "account+currency is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"), ("issuer", "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"))), "account+issuer is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"), ("mpt_issuance_id", MptIssuanceId))), "account+MPT is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step(("currency", "USD"), ("mpt_issuance_id", MptIssuanceId))), "currency+MPT is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step()), "an empty step carries no asset and no account"); + } + + private static Dictionary Step(params (string Key, object Value)[] fields) + { + Dictionary step = new Dictionary(); + foreach ((string key, object value) in fields) + { + step[key] = value; + } + return step; + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepWithoutTypeIsNull() + { + Path step = JsonSerializer.Deserialize(@"{""account"":""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn""}", XrplJsonOptions.Default); + + Assert.IsNull(step.Type); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 367382b5..5d62982c 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -80,7 +80,8 @@ public void TestUMPTokenIssuanceSet_DynamicFields_RoundTrip() { Account = Account1, MPTokenIssuanceID = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanLock | MPTokenIssuanceSetMutableFlags.tmfMPTSetRequireAuth, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanLock | MPTokenIssuanceSetFlags.tfMPTSetRequireAuth, + ImmutableFlags = MPTokenIssuanceImmutableFlags.tifMPTCanTrade | MPTokenIssuanceImmutableFlags.tifMPTMetadata, TransferFee = 250, MPTokenMetadata = "DEADBEEF", DomainID = new string('B', 64), @@ -94,7 +95,12 @@ public void TestUMPTokenIssuanceSet_DynamicFields_RoundTrip() string blob = XrplBinaryCodec.Encode(json); JsonObject decoded = XrplBinaryCodec.Decode(blob).AsObject(); - Assert.AreEqual(3u, decoded["MutableFlags"]!.GetValue()); + Assert.AreEqual( + (uint)(MPTokenIssuanceSetFlags.tfMPTSetCanLock | MPTokenIssuanceSetFlags.tfMPTSetRequireAuth), + decoded["Flags"]!.GetValue()); + Assert.AreEqual( + (uint)(MPTokenIssuanceImmutableFlags.tifMPTCanTrade | MPTokenIssuanceImmutableFlags.tifMPTMetadata), + decoded["ImmutableFlags"]!.GetValue()); Assert.AreEqual(250u, decoded["TransferFee"]!.GetValue()); Assert.AreEqual("DEADBEEF", decoded["MPTokenMetadata"]!.GetValue()); Assert.AreEqual(new string('B', 64), decoded["DomainID"]!.GetValue()); @@ -199,14 +205,25 @@ public async Task TestUMPTokenIssuanceSet_PreflightRules() ["MPTokenIssuanceID"] = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", }; - // MutableFlags: zero and out-of-mask values are temINVALID_FLAG - tx["MutableFlags"] = 0u; + // A non-numeric Flags value must report as ValidationException like every other + // malformed field here, not as a raw conversion exception callers do not catch. + tx["Flags"] = "not-a-number"; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); - tx["MutableFlags"] = 0x80u; + tx.Remove("Flags"); + + // ImmutableFlags: zero and out-of-mask values are temINVALID_FLAG + tx["ImmutableFlags"] = 0u; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); + tx["ImmutableFlags"] = 0x1u; // outside tif* mask (0x2..0x80, 0x10000, 0x20000) await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); - // Non-zero TransferFee combined with enabling confidential balances is temBAD_TRANSFER_FEE - tx["MutableFlags"] = (uint)MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance; + tx["ImmutableFlags"] = (uint)MPTokenIssuanceImmutableFlags.tifMPTCanHoldConfidentialBalance; + await Validation.ValidateMPTokenIssuanceSet(tx); + + // Non-zero TransferFee combined with enabling confidential balances is temBAD_TRANSFER_FEE. + // Since 3.3.0 the capability is enabled through a tf* flag, not through a separate field. + tx.Remove("ImmutableFlags"); + tx["Flags"] = (uint)MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance; tx["TransferFee"] = 10u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); @@ -215,7 +232,7 @@ public async Task TestUMPTokenIssuanceSet_PreflightRules() } [TestMethod] - public async Task TestUMPTokenIssuanceCreate_MutableFlagsMask() + public async Task TestUMPTokenIssuanceCreate_ImmutableFlagsMask() { Dictionary tx = new() { @@ -223,12 +240,12 @@ public async Task TestUMPTokenIssuanceCreate_MutableFlagsMask() ["Account"] = Account1, }; - tx["MutableFlags"] = 0u; + tx["ImmutableFlags"] = 0u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceCreate(tx)); - tx["MutableFlags"] = 0x100u; // outside tmf* mask + tx["ImmutableFlags"] = 0x100u; // outside tif* mask await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceCreate(tx)); - tx["MutableFlags"] = (uint)(MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee); + tx["ImmutableFlags"] = (uint)(MPTokenIssuanceImmutableFlags.tifMPTMetadata | MPTokenIssuanceImmutableFlags.tifMPTTransferFee); await Validation.ValidateMPTokenIssuanceCreate(tx); tx["DomainID"] = 12345; @@ -322,5 +339,47 @@ public void TestULORippleState_SponsorFields_Deserialize() Assert.AreEqual(Account1, state.HighSponsor); Assert.AreEqual(Account2, state.LowSponsor); } + + [TestMethod] + public void TestULOVault_LEVersion_Deserialize() + { + string json = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "Vault", + ["Account"] = Account1, + ["Owner"] = Account2, + ["ShareMPTID"] = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", + ["WithdrawalPolicy"] = 1, + ["Scale"] = 6, + ["LEVersion"] = (uint)VaultVersion.CashBasis, + }); + LOVault vault = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual((uint)VaultVersion.CashBasis, vault.LEVersion); + + // A vault created before cash-basis accounting carries no LEVersion at all; + // rippled resolves that absence as VaultVersion.Legacy rather than an error + string legacy = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "Vault", + ["Account"] = Account1, + ["Owner"] = Account2, + }); + Assert.IsNull(JsonSerializer.Deserialize(legacy, XrplJsonOptions.Default).LEVersion); + } + + [TestMethod] + public void TestULEVersion_BinaryRoundTrip() + { + // The field only travels if definitions.json knows it — this fails with an + // encoding error, not an assertion, when the entry is missing. + // Parsed from text rather than built from int literals: that is the shape a + // node response arrives in, and Uint8.FromJson takes a byte, not an Int32 + JsonObject json = JsonNode.Parse("""{"LEVersion":1,"Scale":6}""")!.AsObject(); + string blob = XrplBinaryCodec.Encode(json); + JsonObject decoded = XrplBinaryCodec.Decode(blob).AsObject(); + + Assert.AreEqual(1u, decoded["LEVersion"]!.GetValue()); + Assert.AreEqual(6u, decoded["Scale"]!.GetValue()); + } } } diff --git a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs index f57096d6..88391e29 100644 --- a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs +++ b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Text.Json.Nodes; @@ -248,6 +248,293 @@ await Helper.ThrowsExceptionAsync(async () => #endregion + #region ConfidentialMPT Fee Tests + + [TestMethod] + public async Task TestUCalculateFee_ConfidentialMPTSend_AppliesConfidentialMultiplier() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = new Dictionary + { + ["TransactionType"] = "ConfidentialMPTSend", + ["Account"] = "rTestAccount" + }; + + await client.CalculateFeePerTransactionType(tx); + + // rippled: Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier) + // = base * 1 + base * 9 = base * 10 = 120 + Assert.AreEqual("120", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_ConfidentialMPTClawback_Multisig_AddsSignerFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = new Dictionary + { + ["TransactionType"] = "ConfidentialMPTClawback", + ["Account"] = "rTestAccount" + }; + + await client.CalculateFeePerTransactionType(tx, signersCount: 2); + + // base * (1 + 2 signers) + base * 9 = 36 + 108 = 144 + Assert.AreEqual("144", tx["Fee"]); + } + + #endregion + + #region LoanSet Fee Tests + + [TestMethod] + public async Task TestUCalculateFee_LoanSet_UsesCounterpartySignerListSize() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + CounterpartySignerLists = CreateSignerList(3) + }; + var tx = CreateLoanSetTx(); + + await client.CalculateFeePerTransactionType(tx); + + // base * (1 + 3 counterparty signers) = 48 + Assert.AreEqual("48", tx["Fee"]); + Assert.AreEqual(1, client.AccountInfoCalls); + // A signer list set in the last ledger is not in `validated` yet; missing it would underpay. + Assert.AreEqual(LedgerIndexType.Current, client.LastAccountInfoRequest?.LedgerIndex?.LedgerIndexType); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanSet_CounterpartyWithoutSignerList_ChargesOneSignature() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = CreateLoanSetTx(); + + await client.CalculateFeePerTransactionType(tx); + + // base * (1 + 1 counterparty signature) = 24 + Assert.AreEqual("24", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanSet_ExistingCounterpartySignature_CountsActualSigners() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = CreateLoanSetTx(); + tx["CounterpartySignature"] = new JsonObject + { + ["Signers"] = new JsonArray + { + new JsonObject { ["Signer"] = new JsonObject { ["Account"] = "rSigner1" } }, + new JsonObject { ["Signer"] = new JsonObject { ["Account"] = "rSigner2" } } + } + }; + + await client.CalculateFeePerTransactionType(tx); + + // Signature already present: count it instead of querying the counterparty. + // base * (1 + 2 signers) = 36 + Assert.AreEqual("36", tx["Fee"]); + Assert.AreEqual(0, client.AccountInfoCalls); + } + + #endregion + + #region Cancellation + + /// + /// A cancelled token must stop fee calculation rather than be absorbed by the fallback that + /// exists for a counterparty account which does not exist yet. + /// + /// + /// Both lookups sit behind a broad catch, so without an exception filter the + /// OperationCanceledException became a silent "assume one signer" and autofill carried on + /// writing a fee the caller never asked for. + /// + [TestMethod] + public async Task TestUCalculateFee_LoanSet_CancellationIsNotSwallowedByTheSignerFallback() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + CounterpartySignerLists = CreateSignerList(3) + }; + var tx = CreateLoanSetTx(); + + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => client.CalculateFeePerTransactionType(tx, 0, cts.Token)); + + Assert.IsFalse(tx.ContainsKey("Fee"), "A cancelled autofill must not leave a fee behind."); + } + + /// The same for the Loan lookup, whose fallback is a null object. + [TestMethod] + public async Task TestUCalculateFee_LoanPay_CancellationIsNotSwallowedByTheLoanFallback() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + LoanEntry = CreateLoan(paymentRemaining: 50) + }; + var tx = CreateLoanPayTx(amount: "10000"); + + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => client.CalculateFeePerTransactionType(tx, 0, cts.Token)); + + Assert.IsFalse(tx.ContainsKey("Fee"), "A cancelled autofill must not leave a fee behind."); + } + + /// + /// The filter must not turn every failure into a hard error: a lookup that fails on its own — + /// the object is missing — still falls back while the caller's token is untouched. + /// + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FailedLookupStillFallsBackWhenNotCancelled() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LedgerEntryThrows = true }; + var tx = CreateLoanPayTx(amount: "10000"); + + using CancellationTokenSource cts = new CancellationTokenSource(); + + await client.CalculateFeePerTransactionType(tx, 0, cts.Token); + + Assert.IsTrue(tx.ContainsKey("Fee"), "An unreadable Loan object is a fallback, not a failure."); + } + + #endregion + + #region LoanPay Fee Tests + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FullPaymentFlag_UsesBaseFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 50) }; + var tx = CreateLoanPayTx(amount: "10000"); + tx["Flags"] = (uint)LoanPayFlags.tfLoanFullPayment; + + await client.CalculateFeePerTransactionType(tx); + + Assert.AreEqual("12", tx["Fee"]); + Assert.AreEqual(0, client.LedgerEntryCalls); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FewPaymentsRemaining_UsesBaseFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 5) }; + var tx = CreateLoanPayTx(amount: "10000"); + + await client.CalculateFeePerTransactionType(tx); + + // PaymentRemaining <= kLoanPaymentsPerFeeIncrement: no extra charge + Assert.AreEqual("12", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_ManyPayments_ChargesPerFiveIncrements() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 50) }; + var tx = CreateLoanPayTx(amount: "1000"); // 1000 / 100 = 10 payments → ceil(10/5) = 2 increments + + await client.CalculateFeePerTransactionType(tx); + + Assert.AreEqual("24", tx["Fee"]); + // A loan created in the last ledger is not in `validated` yet; missing it would underpay. + Assert.AreEqual(LedgerIndexType.Current, client.LastLedgerEntryRequest?.LedgerIndex?.LedgerIndexType); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FewPaymentsRemainingWithLargeAmount_StillChargesMaxIncrements() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 6) }; + var tx = CreateLoanPayTx(amount: "1000000"); + + await client.CalculateFeePerTransactionType(tx); + + // rippled reads PaymentRemaining only as the <= 5 short-circuit and never clamps the + // estimate by it, so an amount covering 100 regular payments costs the full 20 increments + // even with 6 payments left. Clamping here would underpay and hit telINSUF_FEE_P. + Assert.AreEqual("240", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_PeriodicPaymentNearDecimalLimit_DoesNotOverflow() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + LoanEntry = CreateLoan(paymentRemaining: 50, periodicPayment: "79000000000000000000000000000") + }; + var tx = CreateLoanPayTx(amount: "1000"); + + await client.CalculateFeePerTransactionType(tx); + + // The amount does not even cover one payment: one increment, and no arithmetic overflow. + Assert.AreEqual("12", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_HugeAmount_CapsAtMaxIncrements() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 500) }; + var tx = CreateLoanPayTx(amount: "1000000"); // 10000 payments, capped at 100/5 = 20 increments + + await client.CalculateFeePerTransactionType(tx); + + Assert.AreEqual("240", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_LoanNotFound_UsesBaseFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LedgerEntryThrows = true }; + var tx = CreateLoanPayTx(amount: "1000"); + + await client.CalculateFeePerTransactionType(tx); + + // rippled falls back to the normal cost and lets preclaim reject it + Assert.AreEqual("12", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_Multisig_MultipliesWholeCost() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 50) }; + var tx = CreateLoanPayTx(amount: "1000"); + + await client.CalculateFeePerTransactionType(tx, signersCount: 1); + + // rippled multiplies the full Transactor cost: (base * (1 + 1)) * 2 increments = 48 + Assert.AreEqual("48", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_IouAmount_RoundsPeriodicPaymentToLoanScale() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + // PeriodicPayment 1.001 rounds up to 1.01 at scale -2, service fee 0.09 → 1.10 per payment + LoanEntry = CreateLoan(paymentRemaining: 50, periodicPayment: "1.001", loanServiceFee: "0.09", loanScale: -2) + }; + var tx = CreateLoanPayTx(amount: new Dictionary + { + ["currency"] = "USD", + ["issuer"] = "rIssuer", + ["value"] = "11" + }); + + await client.CalculateFeePerTransactionType(tx); + + // 11 / 1.10 = 10 payments → ceil(10/5) = 2 increments = 24 + Assert.AreEqual("24", tx["Fee"]); + } + + #endregion + #region MaxFee Tests [TestMethod] @@ -292,6 +579,47 @@ public async Task TestUCalculateFee_AccountDelete_NotCapped() ["Amount"] = "1000000" }; + private static Dictionary CreateLoanSetTx() => new() + { + ["TransactionType"] = "LoanSet", + ["Account"] = "rTestAccount", + ["LoanBrokerID"] = "0000000000000000000000000000000000000000000000000000000000000001", + ["Counterparty"] = "rCounterparty" + }; + + private static Dictionary CreateLoanPayTx(object amount) => new() + { + ["TransactionType"] = "LoanPay", + ["Account"] = "rTestAccount", + ["LoanID"] = "0000000000000000000000000000000000000000000000000000000000000002", + ["Amount"] = amount + }; + + private static LOLoan CreateLoan( + uint paymentRemaining, + string periodicPayment = "100", + string loanServiceFee = "0", + int loanScale = 0) => new() + { + PaymentRemaining = paymentRemaining, + PeriodicPayment = periodicPayment, + LoanServiceFee = loanServiceFee, + LoanScale = loanScale + }; + + private static LOSignerList[] CreateSignerList(int entries) + { + var list = new LOSignerList { SignerEntries = new List() }; + for (int i = 0; i < entries; i++) + { + list.SignerEntries.Add(new SignerEntryWrapper + { + SignerEntry = new SignerEntry { Account = $"rSigner{i}", SignerWeight = 1 } + }); + } + return new[] { list }; + } + #endregion } @@ -317,6 +645,21 @@ public FeeTestClient(string feeXrp, uint reserveInc, string maxFeeXRP = "5") public string maxFeeXRP { get; set; } public uint? networkID { get; set; } + /// Loan ledger object returned by , if any. + public LOLoan? LoanEntry { get; set; } + + /// Signer lists returned by , if any. + public LOSignerList[]? CounterpartySignerLists { get; set; } + + /// When true, fails as it would for a missing object. + public bool LedgerEntryThrows { get; set; } + + public int AccountInfoCalls { get; private set; } + public int LedgerEntryCalls { get; private set; } + + public AccountInfoRequest? LastAccountInfoRequest { get; private set; } + public LedgerEntryRequest? LastLedgerEntryRequest { get; private set; } + public Task ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default) { var info = new ServerInfo @@ -372,7 +715,16 @@ public Task ServerState(ServerStateRequest request, CancellationTok public Task Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Ping(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Fee(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) + { + // Honour the token the way a real client does, so tests can assert that a caller's + // cancellation reaches autofill instead of being turned into a fee fallback. + cancellationToken.ThrowIfCancellationRequested(); + AccountInfoCalls++; + LastAccountInfoRequest = request; + return Task.FromResult(new AccountInfo { SignerLists = CounterpartySignerLists }); + } + public Task AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -385,7 +737,16 @@ public Task ServerState(ServerStateRequest request, CancellationTok public Task LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + LedgerEntryCalls++; + LastLedgerEntryRequest = request; + if (LedgerEntryThrows) + throw new XrplException("entryNotFound"); + return Task.FromResult(new LedgerEntryResponse { Index = request.Index, Node = LoanEntry }); + } + public Task Submit(Dictionary tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Submit(ITransactionRequest tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Tx(TxRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/Tests/Xrpl.Tests/TestUtils.cs b/Tests/Xrpl.Tests/TestUtils.cs index 1420cb87..a0fdf567 100644 --- a/Tests/Xrpl.Tests/TestUtils.cs +++ b/Tests/Xrpl.Tests/TestUtils.cs @@ -1,21 +1,116 @@  // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/testUtils.ts +using System; +using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; +using System.Text; namespace Xrpl.Tests { public class TestUtils { + /// + /// Ports this process has already handed out. The OS is free to return a just-released + /// port to the next caller, and test classes run in parallel (see test.runsettings), so + /// two callers could otherwise receive the same port and the second server would fail to + /// bind — silently, because the mock listens on a background thread, leaving the test to + /// time out instead of reporting a conflict. + /// + private static readonly ConcurrentDictionary ClaimedPorts = new(); + + /// + /// A loopback port free at the moment of the call and not handed out before. + /// + /// + /// The listener is stopped before returning, so the port is closed when the caller gets + /// it — several tests need exactly that (connect to a server that is not up yet, start it + /// later). The gap that leaves cannot be closed while callers need a closed port; what + /// this does remove is the collision between concurrent callers inside this process, + /// which is the reachable half of the race. + /// static public int GetFreePort() { - TcpListener l = new TcpListener(IPAddress.Loopback, 0); - l.Start(); - int port = ((IPEndPoint)l.LocalEndpoint).Port; - l.Stop(); - return port; + for (int attempt = 0; attempt < 50; attempt++) + { + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + if (ClaimedPorts.TryAdd(port, 0)) + { + return port; + } + } + + throw new InvalidOperationException( + "GetFreePort: could not obtain an unclaimed loopback port after 50 attempts"); + } + + /// + /// Whether a mock server on still completes a WebSocket handshake. + /// + /// + /// A dead accept loop leaves the listen socket bound, so a plain TCP connect still + /// succeeds and proves nothing — only an answered handshake shows the mock is serving. + /// Tests use this to say whether a connection failure was the client's doing or the + /// mock's, instead of blaming the client for a server that went deaf. + /// + static public bool MockCompletesHandshake(int port, TimeSpan timeout) + { + try + { + using TcpClient probe = new TcpClient(); + if (!probe.ConnectAsync(IPAddress.Loopback, port).Wait(timeout)) + { + return false; + } + + NetworkStream stream = probe.GetStream(); + stream.WriteTimeout = (int)timeout.TotalMilliseconds; + stream.ReadTimeout = (int)timeout.TotalMilliseconds; + + // The mock reads the key by offset from "Sec-WebSocket-Key: ", so the header must + // carry a full 24-character value like a real client sends. + byte[] request = Encoding.ASCII.GetBytes( + "GET / HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n"); + stream.Write(request, 0, request.Length); + + byte[] buffer = new byte[256]; + int read = stream.Read(buffer, 0, buffer.Length); + return read > 0 && Encoding.ASCII.GetString(buffer, 0, read).Contains("101"); + } + catch (Exception) + { + return false; + } + } + + /// + /// Whether can still be bound on loopback right now. Tests that + /// hold a port across an await use this to fail fast with a clear reason instead of + /// waiting out a connection timeout when something else took it. + /// + static public bool IsPortStillFree(int port) + { + try + { + TcpListener listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } } } } - diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index f076abe6..95f801ec 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -30,7 +30,16 @@ PreserveNewest - + + + PreserveNewest + + + PreserveNewest + + PreserveNewest diff --git a/Xrpl/Client/Json/Converters/GenericStringConverter.cs b/Xrpl/Client/Json/Converters/GenericStringConverter.cs index 80d8ba33..9d105eaa 100644 --- a/Xrpl/Client/Json/Converters/GenericStringConverter.cs +++ b/Xrpl/Client/Json/Converters/GenericStringConverter.cs @@ -15,12 +15,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial { if (reader.TokenType == JsonTokenType.StartObject) { - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is GenericStringConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter>(options); return JsonSerializer.Deserialize(ref reader, innerOptions); } diff --git a/Xrpl/Client/Json/Converters/LONFTokenConverter.cs b/Xrpl/Client/Json/Converters/LONFTokenConverter.cs index 84699e10..e19f1b37 100644 --- a/Xrpl/Client/Json/Converters/LONFTokenConverter.cs +++ b/Xrpl/Client/Json/Converters/LONFTokenConverter.cs @@ -28,8 +28,7 @@ public override void Write(Utf8JsonWriter writer, NFToken value, JsonSerializerO writer.WritePropertyName("NFToken"); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, innerOptions); writer.WriteEndObject(); diff --git a/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs b/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs index 3e9dc0d5..3036c9e7 100644 --- a/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs +++ b/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs @@ -26,8 +26,7 @@ public override void Write(Utf8JsonWriter writer, IBaseLedgerEntity value, JsonS } // Serialize the concrete type to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); if (value is LedgerBinaryEntity binaryEntity) JsonSerializer.Serialize(writer, binaryEntity, innerOptions); @@ -59,8 +58,7 @@ public override IBaseLedgerEntity Read(ref Utf8JsonReader reader, Type typeToCon string rawJson = root.GetRawText(); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); Type targetType = DetermineType(root); return (IBaseLedgerEntity)JsonSerializer.Deserialize(rawJson, targetType, innerOptions); diff --git a/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs b/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs index 6cf3e59c..cd870d1c 100644 --- a/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs +++ b/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs @@ -229,12 +229,7 @@ public static BaseLedgerEntry GetBaseRippleLO( string rawJson = element.Value.GetRawText(); // Remove LOConverter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is LOConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); Type targetType = GetTypeForLedgerEntry(type); @@ -270,12 +265,7 @@ public override void Write(Utf8JsonWriter writer, BaseLedgerEntry value, JsonSer } // Serialize the concrete runtime type to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is LOConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, value.GetType(), innerOptions); } @@ -329,9 +319,12 @@ private static Type DetermineType(Type objectType, JsonElement root) ? letEl.GetString() : null; + // TryParse writes default(LedgerEntryType) — which is AccountRoot — on failure, so an + // unrecognized type must be mapped back to Unknown explicitly instead of being read as an + // account root with every field silently dropped. LedgerEntryType entryType = LedgerEntryType.Unknown; - if (ledgerEntryType != null) - Enum.TryParse(ledgerEntryType, ignoreCase: true, out entryType); + if (ledgerEntryType != null && !Enum.TryParse(ledgerEntryType, ignoreCase: true, out entryType)) + entryType = LedgerEntryType.Unknown; return GetTypeForLedgerEntry(entryType); } @@ -346,12 +339,7 @@ public override BaseLedgerEntry Read(ref Utf8JsonReader reader, Type typeToConve string rawJson = root.GetRawText(); // Remove LOConverter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is LOConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); return (BaseLedgerEntry)JsonSerializer.Deserialize(rawJson, targetType, innerOptions); } diff --git a/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs b/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs index 0880d5da..e0d8c719 100644 --- a/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs +++ b/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs @@ -23,8 +23,7 @@ public override void Write(Utf8JsonWriter writer, Meta value, JsonSerializerOpti } // Remove this converter from options to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, innerOptions); } @@ -46,8 +45,7 @@ public override Meta Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSer if (reader.TokenType == JsonTokenType.StartObject) { // Remove this converter from options to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); return JsonSerializer.Deserialize(ref reader, innerOptions); } diff --git a/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs b/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs index f8403893..2c1310c6 100644 --- a/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs +++ b/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs @@ -20,8 +20,7 @@ public override void Write(Utf8JsonWriter writer, ITransactionRequest value, Jso } // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, value.GetType(), innerOptions); } @@ -157,12 +156,7 @@ public override ITransactionRequest Read(ref Utf8JsonReader reader, Type typeToC string rawJson = root.GetRawText(); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is TransactionRequestConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); try { diff --git a/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs b/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs index a87081b3..d9fe9593 100644 --- a/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs +++ b/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs @@ -23,12 +23,7 @@ public override void Write(Utf8JsonWriter writer, ITransactionResponse value, Js } // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is TransactionResponseConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, value.GetType(), innerOptions); } @@ -169,12 +164,7 @@ public override ITransactionResponse Read(ref Utf8JsonReader reader, Type typeTo string rawJson = root.GetRawText(); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is TransactionResponseConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); try { diff --git a/Xrpl/Client/Json/JsonSerializerOptionsCache.cs b/Xrpl/Client/Json/JsonSerializerOptionsCache.cs new file mode 100644 index 00000000..3128ce50 --- /dev/null +++ b/Xrpl/Client/Json/JsonSerializerOptionsCache.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Xrpl.Client.Json +{ + /// + /// Caches the derived that polymorphic converters build when they + /// re-enter the serializer with their own converter removed to avoid infinite recursion. + /// + /// + /// Building those options inside Read/Write cost an allocation, a copy of the whole converter list and a + /// structural-equality lookup in System.Text.Json's caching-context pool — once per converted value, so + /// once per element of a collection. Type metadata itself was not rebuilt: since .NET 8 System.Text.Json + /// shares a caching context between structurally equal options instances, which is what kept the per-call + /// copy from being far worse than it was. That pool is capped (64 contexts); caching here removes the + /// dependency on it as well.
+ /// Measured on 200 account_objects pages of 200 entries: 456 ms / 47 MB allocated before, + /// 217 ms / 29 MB after.
+ /// Entries are keyed weakly by the source options instance, so caller-supplied options stay collectable, + /// and by converter type within that instance.
+ /// Caching the source is safe because System.Text.Json freezes an options instance on first use: by the + /// time a converter runs, the options it was handed can no longer change. + ///
+ internal static class JsonSerializerOptionsCache + { + private static readonly ConditionalWeakTable> Cache = new(); + + /// + /// Returns a copy of with every converter of type + /// removed. Repeated calls with the same source options and the + /// same converter type return the same instance. + /// + /// Converter type to strip from the returned options. + /// Source options, as handed to the converter. + public static JsonSerializerOptions WithoutConverter(JsonSerializerOptions options) + where TConverter : JsonConverter + { + ConcurrentDictionary byConverterType = + Cache.GetValue(options, static _ => new ConcurrentDictionary()); + + return byConverterType.GetOrAdd(typeof(TConverter), static (_, source) => Build(source), options); + } + + /// + /// Whether already has a cached entry for . + /// Exists so tests can assert that a converter went through the cache rather than building its own + /// copy — the two are indistinguishable from the outside, because System.Text.Json hands converters + /// the options of the pooled caching context rather than the instance they were called with. + /// + internal static bool HasCachedEntry(JsonSerializerOptions options) + where TConverter : JsonConverter + { + return Cache.TryGetValue(options, out ConcurrentDictionary byConverterType) + && byConverterType.ContainsKey(typeof(TConverter)); + } + + private static JsonSerializerOptions Build(JsonSerializerOptions source) + where TConverter : JsonConverter + { + JsonSerializerOptions derived = new JsonSerializerOptions(source); + for (int i = derived.Converters.Count - 1; i >= 0; i--) + { + if (derived.Converters[i] is TConverter) + derived.Converters.RemoveAt(i); + } + + return derived; + } + } +} diff --git a/Xrpl/Client/WebSocketClient.cs b/Xrpl/Client/WebSocketClient.cs index 97cf200a..dd8636e8 100644 --- a/Xrpl/Client/WebSocketClient.cs +++ b/Xrpl/Client/WebSocketClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net.WebSockets; @@ -307,7 +308,11 @@ private async void SendMessageAsync(byte[] message) } catch (Exception e) { - //_onError?.Invoke(e, this); + // The send is fire-and-forget (async void), so nothing can observe this exception: + // the pending request just sits there until its RequestTimeout expires. Surface it + // through the error callback - report-only, the connection itself is left alone. + Debug.WriteLine($"{DateTime.Now}WebSocket send failed: {e.GetType().Name}: {e.Message}"); + CallOnError(e); return; } } diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index d443c3dd..84afbe80 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -212,6 +212,32 @@ public class ConnectionOptions /// public bool UseCheckHealth { get; set; } = false; + /// + /// Gets or sets how often the background health check runs — the timer that notices a socket + /// which is no longer Open and hands the client to the fast-reconnect path.
+ /// Default: 20 seconds, the interval this check has always used. + ///
+ /// + /// Exposed primarily so tests can exercise the ping and fast-reconnect paths without waiting + /// out the default interval; those paths were previously unreachable from a unit test, which + /// is why they went uncovered through several fixes. Lowering it in production only makes the + /// state check more frequent — it sends no network requests of its own. + /// + public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(20); + + /// + /// Gets or sets how long a connection may go without any inbound activity before the health + /// check treats it as dead and hands it to the fast-reconnect path.
+ /// Default: 60 seconds, the threshold this check has always used. + ///
+ /// + /// A socket whose peer vanished stays Open until the next I/O, so silence is the only + /// signal available without sending traffic. Exposed together with + /// so the fast-reconnect path is reachable from a test in + /// under a second instead of over a minute. + /// + public TimeSpan InactivityTimeout { get; set; } = TimeSpan.FromSeconds(60); + /// /// Gets or sets the policy that determines how failed requests are handled. /// @@ -237,6 +263,23 @@ private void ValidateConfig() throw new ArgumentException( $"ConnectionAcquisitionTimeout ({config.ConnectionAcquisitionTimeout.TotalSeconds}s) must be >= ConnectionAttemptTimeout ({config.ConnectionAttemptTimeout.TotalSeconds}s) to allow at least one full connection attempt."); } + + // The WASM timer takes this as an int of milliseconds: zero fires once and never repeats, + // and anything past int.MaxValue or below zero is rejected outright by the timer itself. + // Fail here instead, where the message can say which option is wrong. + double healthCheckMs = config.HealthCheckInterval.TotalMilliseconds; + if (healthCheckMs < 1 || healthCheckMs > int.MaxValue) + { + throw new ArgumentException( + $"HealthCheckInterval ({config.HealthCheckInterval}) must be between 1ms and {int.MaxValue}ms."); + } + + if (config.InactivityTimeout <= TimeSpan.Zero) + { + throw new ArgumentException( + $"InactivityTimeout ({config.InactivityTimeout}) must be positive - a non-positive value would " + + "treat every connection as dead on the first health check."); + } } // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/client/connection.ts createWebSocket @@ -280,9 +323,47 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con private int _reconnectAttempts = 0; + // Number of consecutive times the consumer OnConnected handler threw. + // Not part of the reconnect state: OnceOpen clears the reconnect state before invoking the handler, + // so this counter is the only thing that can bound an endlessly failing handler. + private int _connectHandlerFailures = 0; + private static readonly Random _random = new(); - private CancellationTokenSource _reconnectCts; + /// + /// Guards the reconnect session — , and + /// — wherever one is read and another written as a unit: + /// , , + /// , and + /// the ownership-guarded writes in . + /// + /// + /// Not every touch of these fields is covered: the per-iteration _reconnectAttempts++ in + /// , the plain resets in ChangeServer and + /// OnceClose, and the "is a loop already running" pre-checks in + /// OnConnectionFailed and OnceClose (which read _reconnectLoop, a + /// non-volatile field, outside the lock) all still run outside it. Those predate this lock; do + /// not read the list above as "all three fields are always synchronized". + /// + /// + /// + /// volatile alone was not enough: it makes each individual access atomic, not the + /// sequence of them. The stop path used to read the field three times in a row (Cancel, + /// Dispose, null it), so a start running in between could have its brand-new source disposed + /// and cleared by the retiring stop — leaving the loop with a dead source and nobody + /// reconnecting, which is exactly the permanent wedge this whole area exists to prevent. + /// + /// + /// Nothing that can call back into consumer code runs while the lock is held: cancellation and + /// disposal of a retired source happen after the lock is released, and the loop body starts + /// with a yield so that starting it under the lock never runs a notification inline. + /// + /// + private readonly object _reconnectStateLock = new object(); + + // Volatile so the ownership checks in ReconnectLoopAsync can read it outside the lock: + // a single reference read is atomic, and those checks only ever compare, never mutate. + private volatile CancellationTokenSource _reconnectCts; private Task _reconnectLoop; @@ -369,14 +450,27 @@ private void SetConnectionState( _previousNotifiedMessage = message; - OnConnectionStatus?.Invoke( - new ConnectionStatusInfo - { - Message = message, - Severity = severity, - Reconnect = reconnect, - ConnectionState = newState, - }); + // Contained here, once, rather than at each call site. Every state notification in this class + // funnels through this method, and several call sites are places where an escaping exception + // costs the client its reconnect: the fast-reconnect path (running on a ping task whose + // callers swallow everything) and ReconnectLoopAsync, which notifies before its first + // connection attempt and would fault with _reconnectCts still installed and no live loop. + // A consumer's status handler must not be able to take the connection down. + try + { + OnConnectionStatus?.Invoke( + new ConnectionStatusInfo + { + Message = message, + Severity = severity, + Reconnect = reconnect, + ConnectionState = newState, + }); + } + catch (Exception notifyError) + { + Debug.WriteLine($"{DateTime.Now}OnConnectionStatus handler threw for state {newState}: {notifyError.Message}"); + } } private ReconnectInfo BuildReconnectInfo(int? explicitAttempt = null, TimeSpan? delay = null) @@ -456,10 +550,16 @@ public async Task ChangeServer( ws = null; } - // 7. Mark socket for intentional disconnect + // 7. Mark old socket for intentional disconnect (per-socket tracking only) + // CRITICAL: Do NOT set global _isIntentionalDisconnect = true here - same rule as the ping/network + // recovery path. The global flag was only reset in OnceOpen, so if the NEW server never came up it + // stayed set forever: OnConnectionFailed then read the failure of the new socket as a user disconnect, + // reported "Connection closed permanently." and started no reconnect loop, leaving the client dead + // with the misleading "No connection attempt in progress. Call Connect() first." + // Per-socket tracking (_userInitiatedSockets + the socket's own flag, set in RetireOldSessionAsync) + // already filters late callbacks from the old socket, and keeps global state clean for the new one. if (oldSocket != null) { - _isIntentionalDisconnect = true; Interlocked.Exchange(ref _userInitiatedSocket, oldSocket); MarkSocketAsUserInitiated(oldSocket); @@ -476,11 +576,15 @@ public async Task ChangeServer( ValidateConfig(); _reconnectAttempts = 0; + Interlocked.Exchange(ref _connectHandlerFailures, value: 0); // 8. Reset permanentlyDisconnected for new connection _permanentlyDisconnected = false; - // _isIntentionalDisconnect stays true - reset in OnceOpen + // Clear the global intentional-disconnect flag explicitly: it may still be set from an earlier + // user Disconnect() (it is only ever reset in OnceOpen), and leaving it set would make a failure + // of the NEW connection look intentional and suppress reconnection. + _isIntentionalDisconnect = false; // 9. Immediately connect to new server (new session created in Connect) await Connect(cancellationToken); @@ -522,17 +626,26 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) _reconnectMode = ReconnectMode.FastReconnect; _isFastReconnectActive = true; // Keep for backward compatibility - // 2. Stop any existing reconnect loop - var oldCts = _reconnectCts; + // 2-3. Retire the previous reconnect session and install this one as a single transaction, + // so a concurrent stop/start cannot dispose the source created here. Cancellation and + // disposal of the old source happen after the lock is released. + CancellationTokenSource oldCts; + CancellationTokenSource ownCts; + lock (_reconnectStateLock) + { + oldCts = _reconnectCts; + _reconnectLoop = null; // Clear old loop reference so StartReconnectLoop can start a new one + _reconnectAttempts = 1; + ownCts = new CancellationTokenSource(); + _reconnectCts = ownCts; + } + oldCts?.Cancel(); oldCts?.Dispose(); - _reconnectLoop = null; // Clear old loop reference so StartReconnectLoop can start a new one - - // 3. Initialize reconnect state BEFORE any notifications - _reconnectAttempts = 1; - _reconnectCts = new CancellationTokenSource(); // 4. Now send first notification - IsReconnectActive() will return true + // Consumer handler exceptions are contained inside SetConnectionState - an escaping throw + // here would leave the source installed above with no loop and nobody to dispose it. SetConnectionState( XrpConnectionState.RestoringConnection, message: $"{reason} Reconnecting immediately...", @@ -595,7 +708,34 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) _pingTimeoutSocket = null; _networkDropSocket = null; - // 11. Reset permanentlyDisconnected for new connection + // 11. Reset permanentlyDisconnected for new connection - unless the user asked to disconnect + // while the awaits above were running. Disconnect() sets the flag, clears the reconnect + // state and then waits on the ping task this method runs inside, so it is still blocked + // here and cannot have finished its teardown. Clearing its flag and reconnecting anyway + // would resurrect a client the consumer explicitly took down - and Disconnect() would + // return reporting success while a fresh session was being built behind it. + if (_permanentlyDisconnected) + { + CancellationTokenSource abandoned = null; + lock (_reconnectStateLock) + { + if (ReferenceEquals(_reconnectCts, ownCts)) + { + abandoned = ownCts; + _reconnectCts = null; + _reconnectAttempts = 0; + _reconnectLoop = null; + } + } + + abandoned?.Cancel(); + abandoned?.Dispose(); + _isFastReconnectActive = false; + + Debug.WriteLine($"{DateTime.Now}Fast reconnect abandoned before connecting - the client was disconnected by the user"); + return; + } + _permanentlyDisconnected = false; // Note: _reconnectAttempts and _reconnectCts already set at the start of this method @@ -606,29 +746,78 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) // and Connecting would overwrite ReconnectInfo, confusing consuming apps try { - await ConnectInternalAsync().ConfigureAwait(false); - await WaitForConnectionAsync(config.ConnectionAcquisitionTimeout, CancellationToken.None).ConfigureAwait(false); + // Pass the token of the session this method owns: a user Disconnect() cancels it, so the + // attempt below stops instead of opening a socket behind a client that was taken down. + // Disconnect() waits only briefly for the ping task, while acquisition can run much + // longer, so the flag check above cannot cover this window on its own. + await ConnectInternalAsync(ownCts.Token).ConfigureAwait(false); + await WaitForConnectionAsync(config.ConnectionAcquisitionTimeout, ownCts.Token).ConfigureAwait(false); // Connect succeeded - cleanup reconnect state // Note: _reconnectMode will be cleared in OnceOpen when connection is fully established _isFastReconnectActive = false; - _reconnectCts?.Cancel(); - _reconnectCts?.Dispose(); - _reconnectCts = null; - _reconnectAttempts = 0; + + // Only tear down the source this method installed. The awaits above give a concurrent + // path (RestartReconnectLoop from a failing OnConnected handler, say) room to install a + // newer one; cancelling and disposing that would strand the sequence it belongs to, + // which is the same wedge the ownership checks in ReconnectLoopAsync guard against. + // When ownership is lost, ownCts needs no cleanup here: whoever evicted it from the + // field cancelled and disposed it as part of doing so. + CancellationTokenSource settled = null; + lock (_reconnectStateLock) + { + if (ReferenceEquals(_reconnectCts, ownCts)) + { + settled = ownCts; + _reconnectCts = null; + _reconnectAttempts = 0; + + // Drop the task reference in the same transaction, for the same reason + // StopReconnectLoop does: a loop may have been started on this very source + // while the awaits above were running (OnConnectionFailed sees no live loop - + // the entry above cleared the reference - and StartReconnectLoop reuses a + // still-valid source). Cancelling that source without clearing the reference + // leaves every loopIsRunning check looking at a task that is exiting, so + // nobody starts a replacement and nobody reconnects. + _reconnectLoop = null; + } + } + + settled?.Cancel(); + settled?.Dispose(); } catch (Exception ex) { // If Connect fails, transition to loop reconnect mode // Keep _reconnectMode set (will be LoopReconnect after StartReconnectLoop) - // _reconnectCts is already set, so StartReconnectLoop will reuse it + // A user Disconnect() can land while the awaits above are running - and it will wait on + // the very ping task this method runs inside, so it cannot have finished yet. Handing + // the client back to a reconnect loop then would undo an explicit disconnect. The flag + // is the authority: leave the state alone and let Disconnect() finish its teardown. + if (_permanentlyDisconnected) + { + Debug.WriteLine($"{DateTime.Now}Fast reconnect abandoned - the client was disconnected by the user: {ex.Message}"); + return; + } + + // Start the loop BEFORE notifying: SetConnectionState calls into consumer code, and an + // exception from a handler must not cost us the reconnect loop. Ordering matters more + // than the message here - without the loop the client never comes back. + // + // StartReconnectLoop reuses the source installed above when it is still there. It may + // not be: the awaits could have let another path replace or clear it, the same way the + // success branch above can no longer assume it still owns ownCts. Either outcome is + // survivable here - a live foreign loop makes the call return early, a cleared source + // makes it start a fresh sequence (losing only the seeded first delay) - so this path + // does not need an ownership check of its own. + StartReconnectLoop(); + SetConnectionState( XrpConnectionState.RestoringConnection, message: $"Reconnection failed: {ex.Message}. Retrying...", ConnectionCloseSeverity.Warning, reconnect: BuildReconnectInfo()); - StartReconnectLoop(); } } @@ -659,6 +848,15 @@ public async Task WaitForConnectionAsync(TimeSpan? timeout = null, CancellationT while (!IsConnected()) { + // Re-checked on every iteration, not only on entry: the client can be disconnected while a + // caller is already waiting here (user Disconnect(), or the client giving up on a permanently + // failing OnConnected handler). Without this the caller would sit out the whole acquisition + // timeout and get a generic TimeoutException instead of the actual reason. + if (_permanentlyDisconnected) + { + throw new NotConnectedException("Client has been disconnected. Call Connect() to reconnect."); + } + if (config.StopAfterMaxAttempts && _reconnectAttempts >= config.MaxReconnectAttempts && _reconnectCts == null) @@ -718,6 +916,7 @@ public async Task Connect(CancellationToken cancellationToken) } StopReconnectLoop(); + Interlocked.Exchange(ref _connectHandlerFailures, value: 0); SetConnectionState(XrpConnectionState.Connecting, message: $"Connecting to {url}..."); await ConnectInternalAsync(); await WaitForConnectionAsync(config.ConnectionAcquisitionTimeout, cancellationToken); @@ -838,6 +1037,29 @@ await OnConnectionFailed( } }); + ws.OnError(async (e, errorSocket) => + { + try + { + // Report-only: a failed send does not by itself mean the connection is gone, so this + // path never triggers a reconnect. Without it a fire-and-forget send failure would be + // invisible and the request would simply sit until its RequestTimeout expires. + var errorHandler = OnError; + if (errorHandler is not null) + { + await errorHandler.Invoke( + error: "error", + errorMessage: "socketSendError", + e.Message, + data: e); + } + } + catch (Exception ex) + { + Debug.WriteLine($"{DateTime.Now}OnError callback error: {ex.Message}"); + } + }); + ws.OnMessageReceived(async (m, ws) => { try @@ -1623,15 +1845,16 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) await OnConnected?.Invoke(); } + Interlocked.Exchange(ref _connectHandlerFailures, value: 0); SetConnectionState(XrpConnectionState.Connected, message: $"Connected {url}"); } catch (Exception error) { connectionManager.RejectAllAwaiting(error); - await Disconnect(); + await OnConnectHandlerFailedAsync(connectedSocket, error); return; // Don't start ping timer if connection failed } - + // Start ping timer AFTER connection is fully established and all callbacks completed // This is outside try/catch to ensure it always runs on successful connection StartPingTimer(); @@ -1640,6 +1863,144 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) StartMessageProcessor(); } + /// + /// Handles an exception thrown by a consumer handler. + /// + /// A failing handler is a CONNECTION failure, not a user disconnect. Calling here + /// would set the permanent-disconnect flag and clear the reconnect state, stranding the client forever: + /// no reconnect loop is restarted, no new socket is ever opened and every later request fails with + /// . This is a very reachable scenario - restoring subscriptions in + /// fails whenever the node accepts TCP before it starts serving requests. + /// + /// + /// Instead the socket is torn down as a transport failure so the regular reconnect loop (with exponential + /// backoff) brings the client back. A handler that keeps failing is bounded by + /// when + /// is set, so a broken consumer cannot spin forever. + /// + /// + /// The socket whose handler threw. + /// The exception thrown by the handler. + private async Task OnConnectHandlerFailedAsync(WebSocketClient failedSocket, Exception error) + { + int failures = Interlocked.Increment(ref _connectHandlerFailures); + + Debug.WriteLine($"{DateTime.Now}OnConnected handler failed ({failures}): {error.Message}"); + + var errorHandler = OnError; + if (errorHandler is not null) + { + try + { + await errorHandler + .Invoke(error: "error", errorMessage: "connectHandlerError", error.Message, data: error) + .ConfigureAwait(false); + } + catch (Exception notifyError) + { + Debug.WriteLine($"{DateTime.Now}OnError handler threw while reporting OnConnected failure: {notifyError.Message}"); + } + } + + bool giveUp = config.StopAfterMaxAttempts && failures >= config.MaxReconnectAttempts; + if (giveUp) + { + // Terminal state on purpose: the handler is broken, not the connection. Disconnect() gives the + // consumer an immediate, actionable NotConnectedException instead of a silent 5-minute wait, + // and Connect() resets the counter so recovery stays possible. + // The detailed reason has to be notified BEFORE Disconnect(): Disconnect() moves the state to + // Disconnected itself, and SetConnectionState only notifies on a state change, so a call after it + // would be swallowed and the consumer would see "Disconnected by user request." instead. + SetConnectionState( + XrpConnectionState.Disconnected, + message: + $"OnConnected handler failed {failures} time(s) in a row: {error.Message}. Giving up after {config.MaxReconnectAttempts} attempts. Call Connect() to retry.", + ConnectionCloseSeverity.Error); + + await Disconnect(); + return; + } + + SetConnectionState( + XrpConnectionState.RestoringConnection, + message: $"OnConnected handler failed: {error.Message}. Reconnecting...", + ConnectionCloseSeverity.Warning, + reconnect: BuildReconnectInfo(failures)); + + StopPingTimerSync(); + requestManager.RejectAllWithCancellation(); + await WaitForPingToFinishAsync(); + + // Always tear down the socket the handler actually ran for. WebSocketClient.Connect invokes its + // OnConnect callback without awaiting it, so the connect lock can be released while this method is + // still running: by now `ws` may already point at a newer socket that must not be touched. + bool wasCurrentSocket; + lock (_disconnectLock) + { + wasCurrentSocket = ReferenceEquals(ws, failedSocket); + if (wasCurrentSocket) + { + ws = null; + } + } + + // The socket is deliberately NOT marked as user-initiated: OnceClose must treat this as a real + // close so the standard reconnect path runs instead of the "closed permanently" branch. + failedSocket.Cancel(); + failedSocket.Disconnect(); + + if (!wasCurrentSocket) + { + // A newer connection already replaced this socket - it owns the reconnect state now. + return; + } + + // Take ownership of the reconnect state instead of asking "is a loop already running?". + // This method can run inside the reconnect loop's own attempt: that loop breaks as soon as the + // socket reports Open, which happens before the handler has even finished failing. Both this check + // and the one in OnceClose would then race with the loop's exit, and losing the race leaves nobody + // reconnecting - the very wedge this path exists to prevent. Cancel whatever is there, start fresh; + // the later OnceClose sees a live loop and correctly stands down. + // Seed the attempt counter with the consecutive-failure count. StopReconnectLoop zeroes + // _reconnectAttempts and a fresh sequence would zero it again, and CalcBackoff derives the + // delay from that counter alone — so without the seed every handler failure would restart + // the backoff at ReconnectBaseDelay. With StopAfterMaxAttempts = false (no give-up branch) + // that means connect -> handler failure -> teardown forever at a constant 2s, a sustained + // connection load on a node that accepts TCP but cannot serve requests yet. + RestartReconnectLoop(initialAttempts: failures); + } + + /// + /// Retires the current reconnect session and installs a fresh one in a single transaction, + /// seeding the attempt counter with . + /// + /// + /// Doing this as StopReconnectLoop(); _reconnectLoop = null; StartReconnectLoop(seed); + /// took the lock twice with a bare write in between, so a concurrent start (from OnceClose or + /// OnConnectionFailed) could slip in and install its own loop; the seeded start would then see + /// a live loop, return without applying the seed, and the backoff would silently stop growing + /// across consecutive handler failures — the very regression the seed exists to prevent. + /// + private void RestartReconnectLoop(int initialAttempts) + { + CancellationTokenSource retired; + lock (_reconnectStateLock) + { + retired = _reconnectCts; + _reconnectMode = ReconnectMode.LoopReconnect; + _isFastReconnectActive = false; + _reconnectAttempts = initialAttempts; + _reconnectCts = new CancellationTokenSource(); + + // Safe to start under the lock: ReconnectLoopAsync reads its token and yields before + // anything else, so this only schedules the loop - no consumer notification runs inline. + _reconnectLoop = ReconnectLoopAsync(_reconnectCts); + } + + retired?.Cancel(); + retired?.Dispose(); + } + private async Task OnceClose(int? code, string? description, WebSocketClient closingSocket, long sessionId) { var (severity, userMessage) = DescribeClose(code, description); @@ -1780,10 +2141,27 @@ private async Task OnceClose(int? code, string? description, WebSocketClient clo private void StopReconnectLoop() { - _reconnectCts?.Cancel(); - _reconnectCts?.Dispose(); - _reconnectCts = null; - _reconnectAttempts = 0; + // Detach under the lock, then cancel/dispose outside it: a start racing with this stop can + // no longer have its fresh source torn down, and cancellation callbacks never run while the + // lock is held. + CancellationTokenSource retired; + lock (_reconnectStateLock) + { + retired = _reconnectCts; + _reconnectCts = null; + _reconnectAttempts = 0; + + // Drop the task reference too, in the same transaction. The retired loop exits + // asynchronously - it only notices it lost ownership on its next check - so leaving the + // reference behind makes StartReconnectLoop see `!IsCompleted` and return without + // starting anything, while the retired loop then stands down on its ownership check. + // Nobody would be reconnecting. Reachable whenever Connect or ChangeServer stops a live + // loop and the new connection fails. + _reconnectLoop = null; + } + + retired?.Cancel(); + retired?.Dispose(); // Note: Do NOT clear _reconnectMode here! // _reconnectMode is cleared only by: // - OnceOpen (connection succeeded) @@ -1803,45 +2181,82 @@ private void ClearReconnectState() _reconnectMode = ReconnectMode.None; } + /// + /// Starts a reconnect loop unless one is already running, reusing a pre-created cancellation + /// source when there is one. A fresh sequence starts its attempt counter at zero, so the first + /// delay is CalcBackoff(1) — twice ReconnectBaseDelay — except on the + /// ping-timeout and network-drop paths, where the first attempt skips the delay entirely. The + /// OnConnected-handler path needs a seeded counter instead and uses + /// . + /// private void StartReconnectLoop() { // Set reconnect mode to LoopReconnect (upgrades from FastReconnect or sets from None) _reconnectMode = ReconnectMode.LoopReconnect; - - // CRITICAL: If a loop is already running, don't start another or reset the counter - // This prevents _reconnectAttempts from being reset mid-loop when callbacks trigger - // reconnect logic (OnceClose, OnConnectionFailed, etc.) - var loopIsRunning = _reconnectLoop != null && !_reconnectLoop.IsCompleted; - if (loopIsRunning) - { - // Loop is already running - let it continue, don't reset _reconnectAttempts - return; - } - - // If we have a valid pre-created CTS (from RetireCurrentSessionAndReconnectAsync), - // we should reuse it. Check for this case first. - var existingCts = _reconnectCts; - var hasValidPreCreatedCts = existingCts != null && !existingCts.IsCancellationRequested; - - // If no valid pre-created CTS, create a new one - // Only reset _reconnectAttempts when creating a FRESH CTS (new reconnect sequence) - if (!hasValidPreCreatedCts) + + // The whole decision — is a loop already running, is the current source reusable, install a + // fresh one, hand it to the new loop — is one transaction. Split across the lock it would + // race with StopReconnectLoop and with another start: two loops could end up running, or a + // loop could be handed a source that a concurrent stop has already disposed. + CancellationTokenSource retired = null; + lock (_reconnectStateLock) { - // Cancel/dispose old CTS if any - existingCts?.Cancel(); - existingCts?.Dispose(); - _reconnectCts = new CancellationTokenSource(); - _reconnectAttempts = 0; + // CRITICAL: If a loop is already running, don't start another or reset the counter + // This prevents _reconnectAttempts from being reset mid-loop when callbacks trigger + // reconnect logic (OnceClose, OnConnectionFailed, etc.) + var loopIsRunning = _reconnectLoop != null && !_reconnectLoop.IsCompleted; + if (loopIsRunning) + { + // Loop is already running - let it continue, don't reset _reconnectAttempts + return; + } + + // If we have a valid pre-created CTS (from RetireCurrentSessionAndReconnectAsync), + // we should reuse it. Check for this case first. + var existingCts = _reconnectCts; + var hasValidPreCreatedCts = existingCts != null && !existingCts.IsCancellationRequested; + + // If no valid pre-created CTS, create a new one + // Only reset _reconnectAttempts when creating a FRESH CTS (new reconnect sequence) + if (!hasValidPreCreatedCts) + { + // Retire the old CTS after the lock is released - see _reconnectStateLock + retired = existingCts; + _reconnectCts = new CancellationTokenSource(); + _reconnectAttempts = 0; + } + // else: Reuse existing valid CTS (pre-created for fast reconnect) + // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence + // Note: _reconnectLoop was already cleared by RetireCurrentSessionAndReconnectAsync + + // Safe to start under the lock: ReconnectLoopAsync yields before touching anything, so + // this call only schedules the loop and returns - no consumer notification runs inline. + _reconnectLoop = ReconnectLoopAsync(_reconnectCts); } - // else: Reuse existing valid CTS (pre-created for fast reconnect) - // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence - // Note: _reconnectLoop was already cleared by RetireCurrentSessionAndReconnectAsync - - _reconnectLoop = ReconnectLoopAsync(_reconnectCts.Token); + + retired?.Cancel(); + retired?.Dispose(); } - private async Task ReconnectLoopAsync(CancellationToken ct) + private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) { + // The CTS this loop owns. StopReconnectLoop cancels without awaiting the loop, so a retired loop + // can still be running - or reach its tail - after a replacement has been installed. Everything + // this loop writes to shared reconnect state is therefore guarded by an ownership check. + // + // Read BEFORE the yield below, and deliberately so: the caller still holds + // _reconnectStateLock here, so this source cannot yet have been retired. After the yield a + // concurrent stop may already have disposed it - Cancel/Dispose of a retired source run + // outside the lock - and CancellationTokenSource.Token throws ObjectDisposedException once + // disposed. Taken after the yield, that throw would land outside every try below, faulting + // the loop before its first attempt and vanishing as an unobserved task exception. + CancellationToken ct = ownCts.Token; + + // Yield so nothing beyond that read runs inline on the caller: StartReconnectLoop starts the + // loop while holding _reconnectStateLock, and a consumer notification executing under that + // lock could deadlock against any path that takes it (Disconnect from a handler, say). + await Task.Yield(); + // Don't reset _reconnectAttempts here - it may be pre-set to 1 by fast reconnect path // StartReconnectLoop() sets it to 0 when creating a new CTS @@ -1855,6 +2270,12 @@ private async Task ReconnectLoopAsync(CancellationToken ct) while (!ct.IsCancellationRequested) { + if (!ReferenceEquals(_reconnectCts, ownCts)) + { + // Retired: a newer loop owns the reconnect sequence now. + break; + } + _reconnectAttempts++; // Skip delay for first attempt if this is immediate reconnect (ping timeout or network drop) @@ -1898,6 +2319,14 @@ private async Task ReconnectLoopAsync(CancellationToken ct) { break; } + catch (ObjectDisposedException) + { + // The source this loop owns was retired and disposed while the delay was being + // set up: registering a callback on a token whose source is gone throws instead + // of cancelling. Same meaning as cancellation - a newer sequence owns the + // reconnect state now - so leave quietly rather than fault the task. + break; + } } if (ct.IsCancellationRequested) @@ -1938,7 +2367,16 @@ private async Task ReconnectLoopAsync(CancellationToken ct) if (IsConnected()) { - _reconnectAttempts = 0; + // Ownership check and the write it guards belong together: checked outside the + // lock, this loop could be retired in between and reset a live sequence's counter. + lock (_reconnectStateLock) + { + if (ReferenceEquals(_reconnectCts, ownCts)) + { + _reconnectAttempts = 0; + } + } + break; } } @@ -1969,17 +2407,36 @@ private async Task ReconnectLoopAsync(CancellationToken ct) // This ensures late callbacks from ping-timeout socket are still filtered // even if reconnect attempts fail + // A newer loop may already have taken over (this one was retired by StopReconnectLoop, which does + // not await it). Its state belongs to that loop: clearing the mode or disposing the CTS here would + // strand the live reconnect sequence. + if (!ReferenceEquals(_reconnectCts, ownCts)) + { + return; + } + // When loop exits (cancelled, max attempts, or success) and connection is not established, // clear the reconnect mode. If connected, OnceOpen already cleared it. if (!IsConnected()) { _reconnectMode = ReconnectMode.None; } - + if (config.StopAfterMaxAttempts && _reconnectAttempts >= config.MaxReconnectAttempts) { - _reconnectCts?.Dispose(); - _reconnectCts = null; + // Re-check ownership inside the lock: between the check above and here a new sequence + // could have installed its own source, and disposing that one would strand it. + CancellationTokenSource finished = null; + lock (_reconnectStateLock) + { + if (ReferenceEquals(_reconnectCts, ownCts)) + { + finished = _reconnectCts; + _reconnectCts = null; + } + } + + finished?.Dispose(); } } @@ -2008,8 +2465,8 @@ private void StartWasmPingTimer(CancellationTokenSource cts) _ = ExecutePingCheckAndReleaseAsync(innerCts, tcs); }, state: cts, - dueTime: 20000, - period: 20000); + dueTime: (int)config.HealthCheckInterval.TotalMilliseconds, + period: (int)config.HealthCheckInterval.TotalMilliseconds); } private async Task ExecutePingCheckAndReleaseAsync(CancellationTokenSource cts, TaskCompletionSource tcs) @@ -2078,11 +2535,13 @@ private async Task ExecutePingCheckAsync(CancellationTokenSource cts) return; } - if (timeSinceLastActivity > 60) + double inactivityLimit = config.InactivityTimeout.TotalSeconds; + if (timeSinceLastActivity > inactivityLimit) { _pingTimeoutSocket = ws; - await RetireCurrentSessionAndReconnectAsync("Connection timeout (no activity for 60+ seconds)."); + await RetireCurrentSessionAndReconnectAsync( + $"Connection timeout (no activity for {inactivityLimit:F0}+ seconds)."); return; } @@ -2193,7 +2652,7 @@ private void StartPingTimer() } else { - pingTimer = new Timer(20000); + pingTimer = new Timer(config.HealthCheckInterval.TotalMilliseconds); pingTimer.Elapsed += (sender, e) => { if (cts.IsCancellationRequested) diff --git a/Xrpl/Models/Enums/PathStepType.cs b/Xrpl/Models/Enums/PathStepType.cs new file mode 100644 index 00000000..23ac89b4 --- /dev/null +++ b/Xrpl/Models/Enums/PathStepType.cs @@ -0,0 +1,27 @@ +using System; + +//https://xrpl.org/paths.html#path-steps +namespace Xrpl.Models.Enums +{ + /// + /// Bitmask describing which fields a path step carries, as reported by rippled in the + /// type field of every path step (STPathElement upstream).
+ /// The value is derived from the fields actually present in the step: rippled ignores it when + /// parsing a submitted transaction, and the binary codec synthesizes the byte itself.
+ /// A value carrying a bit this enum does not declare is preserved as-is on deserialization. + ///
+ [Flags] + public enum PathStepType : uint + { + /// No field present. + None = 0x00, + /// Rippling through an account (as opposed to taking an offer). + Account = 0x01, + /// A currency is present, changing the asset through an order book. + Currency = 0x10, + /// An issuer is present. + Issuer = 0x20, + /// An MPTokenIssuanceID is present (rippled 3.2.0+, MPTokensV2 amendment). + MPTokenIssuanceID = 0x40, + } +} diff --git a/Xrpl/Models/Ledger/LOAmendments.cs b/Xrpl/Models/Ledger/LOAmendments.cs index 688c3f7a..7ee5ad8e 100644 --- a/Xrpl/Models/Ledger/LOAmendments.cs +++ b/Xrpl/Models/Ledger/LOAmendments.cs @@ -43,6 +43,18 @@ public LOAmendments() /// No flags are defined for the Amendments object type, so this value is always 0. /// public uint Flags { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } public class Majority diff --git a/Xrpl/Models/Ledger/LOAmm.cs b/Xrpl/Models/Ledger/LOAmm.cs index bcb7ead1..67d3b284 100644 --- a/Xrpl/Models/Ledger/LOAmm.cs +++ b/Xrpl/Models/Ledger/LOAmm.cs @@ -12,11 +12,13 @@ public class LOAmm : BaseLedgerEntry { public LOAmm() { - LedgerEntryType = LedgerEntryType.AccountRoot; + LedgerEntryType = LedgerEntryType.AMM; } /// - /// The account that tracks the balance of LPTokens between the AMM instance via Trustline. + /// The special account that holds the AMM's assets and issues its LPTokens. + /// Serialized as Account, which is the name rippled gives this field. /// + [JsonPropertyName("Account")] public string AMMAccount { get; set; } /// /// Specifies one of the pool assets (XRP or token) of the AMM instance. @@ -53,21 +55,22 @@ public LOAmm() /// A list of vote objects, representing votes on the pool's trading fee.. /// public List VoteSlots { get; set; } - /// - /// The ledger index of the current in-progress ledger, which was used when - /// retrieving this information. - /// - public int? LedgerCurrentIndex { get; set; } - /// - /// True if this data is from a validated ledger version;
- /// if omitted or set to false, this data is not final. - ///
- public bool? Validated { get; set; } - /// Owner directory page hint (hex UInt64). [JsonPropertyName("OwnerNode")] public string OwnerNode { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } public interface IAuthAccount diff --git a/Xrpl/Models/Ledger/LOCredential.cs b/Xrpl/Models/Ledger/LOCredential.cs index c7325f5c..1360741e 100644 --- a/Xrpl/Models/Ledger/LOCredential.cs +++ b/Xrpl/Models/Ledger/LOCredential.cs @@ -101,12 +101,6 @@ public string URI [JsonPropertyName("Flags")] public new uint Flags { get; set; } - /// - /// A hint indicating which page of the owner directory links to this entry. - /// - [JsonPropertyName("OwnerNode")] - public string OwnerNode { get; set; } - /// /// A hint indicating which page of the subject's owner directory links to this entry. /// diff --git a/Xrpl/Models/Ledger/LODirectoryNode.cs b/Xrpl/Models/Ledger/LODirectoryNode.cs index 3a60528a..4999a9fd 100644 --- a/Xrpl/Models/Ledger/LODirectoryNode.cs +++ b/Xrpl/Models/Ledger/LODirectoryNode.cs @@ -7,6 +7,27 @@ namespace Xrpl.Models.Ledger { + /// + /// Flags of a DirectoryNode ledger object. + /// + /// + /// stays a raw uint for backwards compatibility; + /// test a bit with (dir.Flags & (uint)DirectoryNodeFlags.lsfNFTokenBuyOffers) != 0. + /// + [System.Flags] + public enum DirectoryNodeFlags : uint + { + /// + /// The directory holds buy offers for an NFToken. + /// + lsfNFTokenBuyOffers = 0x00000001, + + /// + /// The directory holds sell offers for an NFToken. + /// + lsfNFTokenSellOffers = 0x00000002, + } + /// /// The DirectoryNode object type provides a list of links to other objects in the ledger's state tree. /// @@ -19,8 +40,8 @@ public LODirectoryNode() } /// - /// A bit-map of boolean flags enabled for this directory.Currently, - /// the protocol defines no flags for DirectoryNode objects. + /// A bit-map of boolean flags enabled for this directory. + /// See for the values the protocol defines. /// public uint Flags { get; set; } /// @@ -81,5 +102,17 @@ public LODirectoryNode() /// MPT order books: MPT issuance id on the TakerGets side. [JsonPropertyName("TakerGetsMPT")] public string TakerGetsMPT { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LOFeeSettings.cs b/Xrpl/Models/Ledger/LOFeeSettings.cs index de6998aa..4b2fc02b 100644 --- a/Xrpl/Models/Ledger/LOFeeSettings.cs +++ b/Xrpl/Models/Ledger/LOFeeSettings.cs @@ -47,5 +47,17 @@ public LOFeeSettings() /// XRPFees: owner reserve increment in drops. [JsonPropertyName("ReserveIncrementDrops")] public string ReserveIncrementDrops { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LOLoan.cs b/Xrpl/Models/Ledger/LOLoan.cs index 43be63bd..3b3dafe8 100644 --- a/Xrpl/Models/Ledger/LOLoan.cs +++ b/Xrpl/Models/Ledger/LOLoan.cs @@ -8,6 +8,28 @@ namespace Xrpl.Models.Ledger; +/// +/// Flags of a Loan ledger object. +/// +[Flags] +public enum LoanFlags : uint +{ + /// + /// The loan is in default: the borrower missed a payment past the grace period. + /// + lsfLoanDefault = 0x00010000, + + /// + /// The loan is impaired: the broker expects it not to be repaid in full. + /// + lsfLoanImpaired = 0x00020000, + + /// + /// The loan allows overpayments. + /// + lsfLoanOverpayment = 0x00040000, +} + /// /// A Loan ledger object represents a loan between a borrower and a loan broker. /// @@ -19,6 +41,12 @@ public LOLoan() LedgerEntryType = LedgerEntryType.Loan; } + /// + /// A bit-map of boolean flags enabled for this loan. + /// + [JsonPropertyName("Flags")] + public LoanFlags? Flags { get; init; } + /// /// The account address of the Borrower. /// @@ -73,12 +101,6 @@ public LOLoan() [JsonPropertyName("PrincipalOutstanding")] public string PrincipalOutstanding { get; init; } - /// - /// The principal amount originally requested (Number type, string representation). - /// - [JsonPropertyName("PrincipalRequested")] - public string PrincipalRequested { get; init; } - /// /// The total amount owed including fees (Number type, string representation). /// diff --git a/Xrpl/Models/Ledger/LOMPToken.cs b/Xrpl/Models/Ledger/LOMPToken.cs index 25e96920..e46a0449 100644 --- a/Xrpl/Models/Ledger/LOMPToken.cs +++ b/Xrpl/Models/Ledger/LOMPToken.cs @@ -21,6 +21,11 @@ public enum MPTokenFlags : uint /// it can also be "un-set" using a MPTokenAuthorize transaction specifying the tfMPTUnauthorize flag. /// lsfMPTAuthorized = 2, + /// + /// If set, indicates that this MPToken belongs to an AMM pseudo-account.
+ /// AMMCreate sets it together with lsfMPTAuthorized to implicitly authorize the MPT asset for the pool. + ///
+ lsfMPTAMM = 4, } /// /// The MPToken object represents an amount of an MPT held by an account that is not the issuer. diff --git a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs index a819cf5d..d01a39af 100644 --- a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs +++ b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs @@ -50,6 +50,12 @@ public enum MPTokenIssuanceFlags : uint /// Issuer can claw back balances from holders. /// MPTCanClawback = 0x00000040, + + /// + /// Holders can hold confidential (encrypted) balances of this MPT. + /// Requires ConfidentialTransfer amendment. + /// + MPTCanHoldConfidentialBalance = 0x00000080, } /// @@ -167,10 +173,10 @@ public LOMPTokenIssuance() public string? DomainID { get; init; } /// - /// DynamicMPT: which issuance flags remain mutable. + /// DynamicMPT: which issuance capabilities and fields are frozen. /// - [JsonPropertyName("MutableFlags")] - public uint? MutableFlags { get; init; } + [JsonPropertyName("ImmutableFlags")] + public uint? ImmutableFlags { get; init; } /// /// MPTokensV2: the reference holding object for DEX trading. diff --git a/Xrpl/Models/Ledger/LONFTokenPage.cs b/Xrpl/Models/Ledger/LONFTokenPage.cs index c6cea0b0..31a555aa 100644 --- a/Xrpl/Models/Ledger/LONFTokenPage.cs +++ b/Xrpl/Models/Ledger/LONFTokenPage.cs @@ -17,11 +17,6 @@ public LONFTokenPage() } [JsonConverter(typeof(NumberOrStringConverter))] public string Flags { get; set; } - /// - /// The locator of the next page, if any. Details about this field and how it should be used are outlined below. - /// - public string NFTokenPage { get; set; } - /// /// The collection of NFToken objects contained in this NFTokenPage object. /// This specification places an upper bound of 32 NFToken objects per page. diff --git a/Xrpl/Models/Ledger/LONegativeUNL.cs b/Xrpl/Models/Ledger/LONegativeUNL.cs index 96441a3b..bc7f8c92 100644 --- a/Xrpl/Models/Ledger/LONegativeUNL.cs +++ b/Xrpl/Models/Ledger/LONegativeUNL.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Xrpl.Models.Ledger { @@ -25,6 +26,18 @@ public LONegativeUNL() /// The public key of a trusted validator in the Negative UNL that is scheduled to be re-enabled in the next flag ledger. /// public string ValidatorToReEnable { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } public interface IDisabledValidator { diff --git a/Xrpl/Models/Ledger/LOSignerList.cs b/Xrpl/Models/Ledger/LOSignerList.cs index 3ea1194c..e608fd65 100644 --- a/Xrpl/Models/Ledger/LOSignerList.cs +++ b/Xrpl/Models/Ledger/LOSignerList.cs @@ -9,6 +9,23 @@ namespace Xrpl.Models.Ledger; +/// +/// Flags of a SignerList ledger object. +/// +/// +/// stays a raw uint for backwards compatibility; +/// test a bit with (list.Flags & (uint)SignerListFlags.lsfOneOwnerCount) != 0. +/// +[Flags] +public enum SignerListFlags : uint +{ + /// + /// The signer list counts as one item against the owner reserve + /// rather than one per signer entry (set on every list created since MultiSignReserve). + /// + lsfOneOwnerCount = 0x00010000, +} + /// /// The SignerList object type represents a list of parties that, as a group, /// are authorized to sign a transaction in place of an individual account.
diff --git a/Xrpl/Models/Ledger/LOVault.cs b/Xrpl/Models/Ledger/LOVault.cs index f50c08f8..d8594488 100644 --- a/Xrpl/Models/Ledger/LOVault.cs +++ b/Xrpl/Models/Ledger/LOVault.cs @@ -23,6 +23,27 @@ public enum VaultLedgerFlags : uint lsfVaultPrivate = 0x00010000, } +/// +/// Values of the Vault ledger entry's LEVersion field (rippled VaultVersion). +/// +/// +/// stays a plain uint?, matching the other UInt8 +/// fields of this object; these constants name the values the protocol defines so far. +/// +public enum VaultVersion : uint +{ + /// + /// Accrual-basis accounting. Vaults created before cash-basis accounting was activated + /// carry no LEVersion at all and are treated as this version implicitly. + /// + Legacy = 0, + + /// + /// Cash-basis accounting (rippled #7817). + /// + CashBasis = 1, +} + /// /// Recommended structure for the Vault Data field. /// The JSON is whitespace-removed and hex-encoded (max 256 bytes). @@ -144,6 +165,14 @@ public LOVault() [JsonPropertyName("Scale")] public uint? Scale { get; init; } + /// + /// Schema version of this ledger entry (UInt8), see . + /// Absent on vaults created before cash-basis accounting was activated, which + /// rippled resolves as (0) rather than an error. + /// + [JsonPropertyName("LEVersion")] + public uint? LEVersion { get; init; } + /// /// Arbitrary hex-encoded data associated with the vault, limited to 256 bytes. /// Use for a human-readable representation. @@ -173,12 +202,6 @@ public string DataRaw } } - /// - /// The ID of a permissioned domain associated with the vault. - /// - [JsonPropertyName("DomainID")] - public string DomainID { get; init; } - /// /// The transaction sequence number that created the vault. /// diff --git a/Xrpl/Models/Methods/AccountObjects.cs b/Xrpl/Models/Methods/AccountObjects.cs index 385b9881..be5b0ec8 100644 --- a/Xrpl/Models/Methods/AccountObjects.cs +++ b/Xrpl/Models/Methods/AccountObjects.cs @@ -20,10 +20,14 @@ public class AccountObjects //todo rename to response public string Account { get; set; } /// /// Array of objects owned by this account.
- /// Each object is in its raw ledger format. + /// Each object is in its raw ledger format.
+ /// Elements are deserialized into the concrete LO* type named by their LedgerEntryType + /// discriminator by , which is registered globally in ; + /// a type the SDK does not know falls back to itself, which is why that + /// one stays a concrete class rather than an interface. ///
[JsonPropertyName("account_objects")] - public List AccountObjectList { get; set; } //todo change from class to interface and parse same as transactionResponse + public List AccountObjectList { get; set; } /// /// The identifying hash of the ledger that was used to generate this response. /// diff --git a/Xrpl/Models/Methods/Path.cs b/Xrpl/Models/Methods/Path.cs index f9a7579b..bd8915a1 100644 --- a/Xrpl/Models/Methods/Path.cs +++ b/Xrpl/Models/Methods/Path.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Xrpl.Models.Enums; //https://github.com/XRPLF/xrpl.js/blob/b20c05c3680d80344006d20c44b4ae1c3b0ffcac/packages/xrpl/src/models/common/index.ts#L62 //https://xrpl.org/paths.html#path-steps namespace Xrpl.Models.Methods @@ -20,7 +21,8 @@ public class Path //todo rename to path steps? /// /// (Optional) If present, this path step represents changing currencies through an order book.
/// The currency specified indicates the new currency.
- /// MUST NOT be provided if this step specifies the account field. + /// MUST NOT be provided if this step specifies the account field.
+ /// MUST NOT be combined with the mpt_issuance_id field. ///
[JsonPropertyName("currency")] public string CurrencyCode { get; set; } @@ -36,16 +38,23 @@ public class Path //todo rename to path steps? public string Issuer { get; set; } /// - /// (Optional) An integer bitfield indicating which fields are present in this path step.
- /// 0x01 = account, 0x10 = currency, 0x20 = issuer. + /// (Optional) If present, this path step represents changing assets through an MPT order book.
+ /// Requires rippled 3.2.0+ with the MPTokensV2 amendment enabled.
+ /// MUST NOT be combined with the currency field. ///
- [JsonPropertyName("type")] - public int? Type { get; set; } + [JsonPropertyName("mpt_issuance_id")] + public string MPTokenIssuanceID { get; set; } /// - /// (Optional) Hex representation of the type field. + /// (Optional) A bitfield indicating which fields are present in this path step.
+ /// Serialized as the number rippled sends: 0x01 account, 0x10 currency, 0x20 issuer, + /// 0x40 mpt_issuance_id — a value the enum does not declare is preserved as-is.
+ /// The XRPL documentation marks the field as deprecated, but every rippled version still emits it on + /// every path step of every response.
+ /// Read-only in practice: the value is ignored both by rippled when it parses a submitted transaction + /// and by the binary codec, which derives the byte from the fields actually present in the step. ///
- [JsonPropertyName("type_hex")] - public string TypeHex { get; set; } + [JsonPropertyName("type")] + public PathStepType? Type { get; set; } } } diff --git a/Xrpl/Models/Transactions/Common.cs b/Xrpl/Models/Transactions/Common.cs index 48b300c4..f55c6b4a 100644 --- a/Xrpl/Models/Transactions/Common.cs +++ b/Xrpl/Models/Transactions/Common.cs @@ -171,6 +171,23 @@ public static bool TryGetUInt32(object value, out uint result) } } + /// + /// Extracts a signed Int32 from any integral representation produced by the JSON layer. + /// Used by delta fields such as SponsorshipSet.RemainingOwnerCountDelta, which are + /// serialized as the XRPL Int32 type and may be negative. + /// + public static bool TryGetInt32(object value, out int result) + { + switch (value) + { + case int i: result = i; return true; + case uint u when u <= int.MaxValue: result = (int)u; return true; + case long l when l >= int.MinValue && l <= int.MaxValue: result = (int)l; return true; + case ulong ul when ul <= int.MaxValue: result = (int)ul; return true; + default: result = 0; return false; + } + } + /// /// Validates a flags value that must be non-zero and contain only bits /// defined by (rippled temINVALID_FLAG pattern). diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs b/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs index d97e0ab7..b4bd61b4 100644 --- a/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs +++ b/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs @@ -50,42 +50,6 @@ public enum MPTokenIssuanceCreateFlags : uint tfMPTCanClawback = 64 } - /// - /// DynamicMPT (XLS-94): MutableFlags values for MPTokenIssuanceCreate — - /// which capabilities/fields may be changed after issuance. - /// Values mirror rippled TxFlags.h (tmf* = lsmf* ledger flags). - /// - [Flags] - public enum MPTokenIssuanceCreateMutableFlags : uint - { - /// Allow enabling lsfMPTCanLock after issuance. - tmfMPTCanEnableCanLock = 0x00000002, - - /// Allow enabling lsfMPTRequireAuth after issuance. - tmfMPTCanEnableRequireAuth = 0x00000004, - - /// Allow enabling lsfMPTCanEscrow after issuance. - tmfMPTCanEnableCanEscrow = 0x00000008, - - /// Allow enabling lsfMPTCanTrade after issuance. - tmfMPTCanEnableCanTrade = 0x00000010, - - /// Allow enabling lsfMPTCanTransfer after issuance. - tmfMPTCanEnableCanTransfer = 0x00000020, - - /// Allow enabling lsfMPTCanClawback after issuance. - tmfMPTCanEnableCanClawback = 0x00000040, - - /// Forbid enabling confidential balances (ConfidentialTransfer) after issuance. - tmfMPTCannotEnableCanHoldConfidentialBalance = 0x00000080, - - /// Allow mutating MPTokenMetadata after issuance. - tmfMPTCanMutateMetadata = 0x00010000, - - /// Allow mutating TransferFee after issuance. - tmfMPTCanMutateTransferFee = 0x00020000, - } - /// /// The MPTokenIssuanceCreate transaction creates an MPTokenIssuance object /// and adds it to the relevant directory node of the creator account. @@ -134,8 +98,8 @@ public interface IMPTokenIssuanceCreate : ITransactionCommon set => MPTokenMetadata = value?.ToHex(); } - /// DynamicMPT: which issuance flags remain mutable after creation. - public MPTokenIssuanceCreateMutableFlags? MutableFlags { get; set; } + /// DynamicMPT: which issuance capabilities and fields are frozen at creation. + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// PermissionedDomains: domain restricting who may hold this MPT. public string DomainID { get; set; } @@ -191,8 +155,8 @@ public MPTokenMetadataSchema? Metadata } /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceCreateMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("DomainID")] @@ -251,8 +215,8 @@ public MPTokenMetadataSchema? Metadata } /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceCreateMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("DomainID")] @@ -342,16 +306,16 @@ public static async Task ValidateMPTokenIssuanceCreate(Dictionary(mutable, "MPTokenIssuanceCreate: invalid MutableFlags"); + // set and only tif* bits are allowed (temINVALID_FLAG otherwise) + Common.ValidateNonZeroFlagsMask(immutable, "MPTokenIssuanceCreate: invalid ImmutableFlags"); } if (tx.TryGetValue("DomainID", out var domainId) && domainId is not null) diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs b/Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs new file mode 100644 index 00000000..3484ccea --- /dev/null +++ b/Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs @@ -0,0 +1,52 @@ +#nullable enable +using System; + +namespace Xrpl.Models.Transactions +{ + /// + /// DynamicMPT (XLS-94): ImmutableFlags values shared by MPTokenIssuanceCreate + /// and MPTokenIssuanceSet — which capabilities and fields are frozen for the + /// lifetime of the issuance. + /// + /// A bit that is NOT set leaves the corresponding capability or field mutable, + /// so an issuance created without ImmutableFlags can be changed later. Bits are + /// only ever added: MPTokenIssuanceSet ORs the value into the ledger object + /// (rippled MPTokenIssuanceSet::doApply), it never clears a bit. + /// + /// Values mirror rippled TxFlags.h tif* constants, which alias the lsif* + /// ledger constants in LedgerFormats.h. + /// + [Flags] + public enum MPTokenIssuanceImmutableFlags : uint + { + /// lsfMPTCanLock may never be enabled or disabled after this transaction. + tifMPTCanLock = 0x00000002, + + /// lsfMPTRequireAuth may never be enabled or disabled after this transaction. + tifMPTRequireAuth = 0x00000004, + + /// lsfMPTCanEscrow may never be enabled or disabled after this transaction. + tifMPTCanEscrow = 0x00000008, + + /// lsfMPTCanTrade may never be enabled or disabled after this transaction. + tifMPTCanTrade = 0x00000010, + + /// lsfMPTCanTransfer may never be enabled or disabled after this transaction. + tifMPTCanTransfer = 0x00000020, + + /// lsfMPTCanClawback may never be enabled or disabled after this transaction. + tifMPTCanClawback = 0x00000040, + + /// + /// lsfMPTCanHoldConfidentialBalance may never be enabled after this transaction. + /// Requires the ConfidentialTransfer amendment. + /// + tifMPTCanHoldConfidentialBalance = 0x00000080, + + /// MPTokenMetadata may never be changed after this transaction. + tifMPTMetadata = 0x00010000, + + /// TransferFee may never be changed after this transaction. + tifMPTTransferFee = 0x00020000, + } +} diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs b/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs index f73118b4..b1c75a50 100644 --- a/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs +++ b/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs @@ -12,50 +12,44 @@ namespace Xrpl.Models.Transactions { /// - /// DynamicMPT (XLS-94): MutableFlags values for MPTokenIssuanceSet — - /// one-way enabling of capability flags (once enabled, cannot be disabled here). - /// Values mirror rippled TxFlags.h tmfMPTSet*. + /// Enum representing flags for MPTokenIssuanceSet transactions. /// [Flags] - public enum MPTokenIssuanceSetMutableFlags : uint + public enum MPTokenIssuanceSetFlags : uint { - /// Enable lsfMPTCanLock on the issuance. - tmfMPTSetCanLock = 0x00000001, + /// + /// If set, indicates that all MPT balances for this asset should be locked. + /// + tfMPTLock = 0x00000001, - /// Enable lsfMPTRequireAuth on the issuance. - tmfMPTSetRequireAuth = 0x00000002, + /// + /// If set, indicates that all MPT balances for this asset should be unlocked. + /// + tfMPTUnlock = 0x00000002, - /// Enable lsfMPTCanEscrow on the issuance. - tmfMPTSetCanEscrow = 0x00000004, + /// DynamicMPT: enable lsfMPTCanLock on the issuance. + tfMPTSetCanLock = 0x00000004, - /// Enable lsfMPTCanTrade on the issuance. - tmfMPTSetCanTrade = 0x00000008, + /// DynamicMPT: enable lsfMPTRequireAuth on the issuance. + tfMPTSetRequireAuth = 0x00000008, - /// Enable lsfMPTCanTransfer on the issuance. - tmfMPTSetCanTransfer = 0x00000010, + /// DynamicMPT: enable lsfMPTCanEscrow on the issuance. + tfMPTSetCanEscrow = 0x00000010, - /// Enable lsfMPTCanClawback on the issuance. - tmfMPTSetCanClawback = 0x00000020, + /// DynamicMPT: enable lsfMPTCanTrade on the issuance. + tfMPTSetCanTrade = 0x00000020, - /// Enable holding confidential balances (ConfidentialTransfer). - tmfMPTSetCanHoldConfidentialBalance = 0x00000040, - } + /// DynamicMPT: enable lsfMPTCanTransfer on the issuance. + tfMPTSetCanTransfer = 0x00000040, - /// - /// Enum representing flags for MPTokenIssuanceSet transactions. - /// - [Flags] - public enum MPTokenIssuanceSetFlags : uint - { - /// - /// If set, indicates that all MPT balances for this asset should be locked. - /// - tfMPTLock = 1, + /// DynamicMPT: enable lsfMPTCanClawback on the issuance. + tfMPTSetCanClawback = 0x00000080, /// - /// If set, indicates that all MPT balances for this asset should be unlocked. + /// DynamicMPT: enable lsfMPTCanHoldConfidentialBalance on the issuance. + /// Requires the ConfidentialTransfer amendment. /// - tfMPTUnlock = 2 + tfMPTSetCanHoldConfidentialBalance = 0x00000100 } /// @@ -76,13 +70,13 @@ public interface IMPTokenIssuanceSet : ITransactionCommon public string? Holder { get; set; } public new MPTokenIssuanceSetFlags? Flags { get; set; } - /// DynamicMPT: capability flags to enable on the issuance (one-way). - public MPTokenIssuanceSetMutableFlags? MutableFlags { get; set; } + /// DynamicMPT: capabilities and fields to freeze on the issuance (one-way). + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } - /// DynamicMPT: new transfer fee (requires tfMPTCanMutateTransferFee). + /// DynamicMPT: new transfer fee (rejected once tifMPTTransferFee froze the field). public ushort? TransferFee { get; set; } - /// DynamicMPT: new metadata blob in hex (requires tfMPTCanMutateMetadata). + /// DynamicMPT: new metadata blob in hex (rejected once tifMPTMetadata froze the field). public string MPTokenMetadata { get; set; } /// PermissionedDomains: domain restricting who may hold this MPT. @@ -123,8 +117,8 @@ public MPTokenIssuanceSet() /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceSetMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("TransferFee")] @@ -184,8 +178,8 @@ public class MPTokenIssuanceSetResponse : TransactionResponse, IMPTokenIssuanceS /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceSetMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("TransferFee")] @@ -247,9 +241,17 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t } } + uint flagValue = 0; if (tx.TryGetValue("Flags", out var flags) && flags is not null) { - uint flagValue = Convert.ToUInt32(flags); + // Same reporting as the ImmutableFlags check below: a non-numeric value has to + // surface as ValidationException, which is what callers of this method catch — + // Convert.ToUInt32 would throw FormatException or InvalidCastException instead. + if (!Common.TryGetUInt32(flags, out flagValue)) + { + throw new ValidationException("MPTokenIssuanceSet: Flags must be a number"); + } + bool hasLock = (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTLock) != 0; bool hasUnlock = (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTUnlock) != 0; @@ -259,17 +261,16 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t } } - uint mutable = 0; - if (tx.TryGetValue("MutableFlags", out var mutableFlags) && mutableFlags is not null) + if (tx.TryGetValue("ImmutableFlags", out var immutableFlags) && immutableFlags is not null) { - if (!Common.TryGetUInt32(mutableFlags, out mutable)) + if (!Common.TryGetUInt32(immutableFlags, out uint immutable)) { - throw new ValidationException("MPTokenIssuanceSet: MutableFlags must be a number"); + throw new ValidationException("MPTokenIssuanceSet: ImmutableFlags must be a number"); } // rippled MPTokenIssuanceSet::preflight: at least one flag must be - // set and only tmfMPTSet* bits are allowed (temINVALID_FLAG otherwise) - Common.ValidateNonZeroFlagsMask(mutable, "MPTokenIssuanceSet: invalid MutableFlags"); + // set and only tif* bits are allowed (temINVALID_FLAG otherwise) + Common.ValidateNonZeroFlagsMask(immutable, "MPTokenIssuanceSet: invalid ImmutableFlags"); } if (tx.TryGetValue("TransferFee", out var transferFee) && transferFee is not null) @@ -286,9 +287,9 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t // rippled MPTokenIssuanceSet::preflight: a non-zero TransferFee combined // with enabling confidential balances is temBAD_TRANSFER_FEE - if (fee > 0 && (mutable & (uint)MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance) != 0) + if (fee > 0 && (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance) != 0) { - throw new ValidationException("MPTokenIssuanceSet: TransferFee must be 0 when tmfMPTSetCanHoldConfidentialBalance is set"); + throw new ValidationException("MPTokenIssuanceSet: TransferFee must be 0 when tfMPTSetCanHoldConfidentialBalance is set"); } } diff --git a/Xrpl/Models/Transactions/Payment.cs b/Xrpl/Models/Transactions/Payment.cs index 7bbcd7f7..6338a821 100644 --- a/Xrpl/Models/Transactions/Payment.cs +++ b/Xrpl/Models/Transactions/Payment.cs @@ -342,10 +342,18 @@ public static bool IsPathStep(Dictionary pathStep) return false; if (pathStep.TryGetValue("issuer", out var issuer) && issuer is not string { }) return false; + if (pathStep.TryGetValue("mpt_issuance_id", out var mptIssuanceId) && mptIssuanceId is not string { }) + return false; - if (acc is not null && currency is null && issuer is null) + // rippled toStrand(): `hasAccount && (hasIssuer || hasCurrency)` and + // `hasMPT && (hasCurrency || hasAccount)` are both temBAD_PATH + if (currency is not null && mptIssuanceId is not null) + return false; + if (acc is not null && (currency is not null || issuer is not null || mptIssuanceId is not null)) + return false; + if (acc is not null) return true; - if (currency is not null || issuer is not null) + if (currency is not null || issuer is not null || mptIssuanceId is not null) return true; return false; } diff --git a/Xrpl/Models/Transactions/SponsorshipSet.cs b/Xrpl/Models/Transactions/SponsorshipSet.cs index 501fe240..d5ae4ff6 100644 --- a/Xrpl/Models/Transactions/SponsorshipSet.cs +++ b/Xrpl/Models/Transactions/SponsorshipSet.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -71,9 +72,11 @@ public interface ISponsorshipSet : ITransactionCommon string CounterpartySponsor { get; set; } /// - /// The amount of fees the sponsor commits to cover. + /// Signed change applied to the FeeAmount held by the Sponsorship object — + /// XRP the sponsor adds to (positive) or reclaims from (negative) the fee budget. + /// Must be a non-zero XRP amount, and positive when the object is being created. /// - Currency FeeAmount { get; set; } + Currency FeeAmountDelta { get; set; } /// /// The maximum fee per transaction the sponsor is willing to cover. @@ -81,9 +84,11 @@ public interface ISponsorshipSet : ITransactionCommon Currency MaxFee { get; set; } /// - /// The number of owner-reserve slots the sponsor commits to cover. + /// Signed change applied to the RemainingOwnerCount held by the Sponsorship + /// object — owner-reserve slots the sponsor adds (positive) or withdraws + /// (negative). Must be non-zero, and positive when the object is being created. /// - uint? RemainingOwnerCount { get; set; } + int? RemainingOwnerCountDelta { get; set; } } /// @@ -114,9 +119,9 @@ public SponsorshipSet() public string CounterpartySponsor { get; set; } /// - [JsonPropertyName("FeeAmount")] + [JsonPropertyName("FeeAmountDelta")] [JsonConverter(typeof(CurrencyConverter))] - public Currency FeeAmount { get; set; } + public Currency FeeAmountDelta { get; set; } /// [JsonPropertyName("MaxFee")] @@ -124,8 +129,8 @@ public SponsorshipSet() public Currency MaxFee { get; set; } /// - [JsonPropertyName("RemainingOwnerCount")] - public uint? RemainingOwnerCount { get; set; } + [JsonPropertyName("RemainingOwnerCountDelta")] + public int? RemainingOwnerCountDelta { get; set; } } /// @@ -151,9 +156,9 @@ public class SponsorshipSetResponse : TransactionResponse, ISponsorshipSet public string CounterpartySponsor { get; set; } /// - [JsonPropertyName("FeeAmount")] + [JsonPropertyName("FeeAmountDelta")] [JsonConverter(typeof(CurrencyConverter))] - public Currency FeeAmount { get; set; } + public Currency FeeAmountDelta { get; set; } /// [JsonPropertyName("MaxFee")] @@ -161,8 +166,8 @@ public class SponsorshipSetResponse : TransactionResponse, ISponsorshipSet public Currency MaxFee { get; set; } /// - [JsonPropertyName("RemainingOwnerCount")] - public uint? RemainingOwnerCount { get; set; } + [JsonPropertyName("RemainingOwnerCountDelta")] + public int? RemainingOwnerCountDelta { get; set; } } public partial class Validation @@ -179,10 +184,40 @@ public static async Task ValidateSponsorshipSet(Dictionary tx) if (hasSponsee == hasCounterpartySponsor) throw new ValidationException("SponsorshipSet: exactly one of Sponsee or CounterpartySponsor must be present"); - if (tx.TryGetValue("RemainingOwnerCount", out var roc) && !Common.IsUInt32(roc)) - throw new ValidationException("SponsorshipSet: invalid RemainingOwnerCount"); - uint flags = ExtractFlags(tx); + bool isDelete = (flags & (uint)SponsorshipSetFlags.tfDeleteObject) != 0; + + bool hasFeeAmountDelta = tx.TryGetValue("FeeAmountDelta", out var feeDelta) && feeDelta is not null; + bool hasRemainingOwnerCountDelta = tx.TryGetValue("RemainingOwnerCountDelta", out var rocDelta) && rocDelta is not null; + bool hasMaxFee = tx.TryGetValue("MaxFee", out var maxFee) && maxFee is not null; + + // rippled SponsorshipSet::preflight: a delete carries no modification fields + if (isDelete && (hasFeeAmountDelta || hasRemainingOwnerCountDelta || hasMaxFee)) + throw new ValidationException("SponsorshipSet: tfDeleteObject cannot be combined with FeeAmountDelta, RemainingOwnerCountDelta or MaxFee"); + + if (hasRemainingOwnerCountDelta) + { + // The field is serialized as Int32 and may be negative, but never zero (temINVALID) + if (!Common.TryGetInt32(rocDelta, out int delta)) + throw new ValidationException("SponsorshipSet: RemainingOwnerCountDelta must be a number"); + + if (delta == 0) + throw new ValidationException("SponsorshipSet: RemainingOwnerCountDelta must not be zero"); + } + + if (hasFeeAmountDelta) + { + // rippled SponsorshipSet::preflight: a non-zero XRP amount, so drops + // as a string — an issued currency object is temBAD_AMOUNT. The delta + // may be negative, which reclaims budget from the Sponsorship object. + if (feeDelta is not string drops || + !long.TryParse(drops, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long dropsValue)) + throw new ValidationException("SponsorshipSet: FeeAmountDelta must be an XRP amount in drops"); + + if (dropsValue == 0) + throw new ValidationException("SponsorshipSet: FeeAmountDelta must not be zero"); + } + const uint feePair = (uint)(SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee | SponsorshipSetFlags.tfSponsorshipClearRequireSignForFee); const uint reservePair = (uint)(SponsorshipSetFlags.tfSponsorshipSetRequireSignForReserve | SponsorshipSetFlags.tfSponsorshipClearRequireSignForReserve); if ((flags & feePair) == feePair || (flags & reservePair) == reservePair) diff --git a/Xrpl/Models/Transactions/TxFormat.cs b/Xrpl/Models/Transactions/TxFormat.cs index 5d43aa4a..0e1cd8b1 100644 --- a/Xrpl/Models/Transactions/TxFormat.cs +++ b/Xrpl/Models/Transactions/TxFormat.cs @@ -623,7 +623,7 @@ static TxFormat() [Field.MaximumAmount] = Requirement.Optional, [Field.MPTokenMetadata] = Requirement.Optional, [Field.DomainID] = Requirement.Optional, - [Field.MutableFlags] = Requirement.Optional, + [Field.ImmutableFlags] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.MPTokenIssuanceDestroy] = new TxFormat { @@ -636,7 +636,7 @@ static TxFormat() [Field.DomainID] = Requirement.Optional, [Field.MPTokenMetadata] = Requirement.Optional, [Field.TransferFee] = Requirement.Optional, - [Field.MutableFlags] = Requirement.Optional, + [Field.ImmutableFlags] = Requirement.Optional, [Field.IssuerEncryptionKey] = Requirement.Optional, [Field.AuditorEncryptionKey] = Requirement.Optional, }, @@ -651,9 +651,9 @@ static TxFormat() { [Field.CounterpartySponsor] = Requirement.Optional, [Field.Sponsee] = Requirement.Optional, - [Field.FeeAmount] = Requirement.Optional, + [Field.FeeAmountDelta] = Requirement.Optional, [Field.MaxFee] = Requirement.Optional, - [Field.RemainingOwnerCount] = Requirement.Optional, + [Field.RemainingOwnerCountDelta] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.SponsorshipTransfer] = new TxFormat { diff --git a/Xrpl/Sugar/Autofill.cs b/Xrpl/Sugar/Autofill.cs index f5e3bcdf..92d42be1 100644 --- a/Xrpl/Sugar/Autofill.cs +++ b/Xrpl/Sugar/Autofill.cs @@ -48,6 +48,26 @@ public static class AutofillSugar /// const int BATCH_BASE_FEE_MULTIPLIER = 3; + /// + /// rippled kConfidentialFeeMultiplier: extra base fee units charged to confidential MPT transactions. + /// + const int CONFIDENTIAL_FEE_MULTIPLIER = 9; + + /// + /// rippled lending::kLoanPaymentsPerFeeIncrement: loan payments covered by one base fee increment. + /// + const int LOAN_PAYMENTS_PER_FEE_INCREMENT = 5; + + /// + /// rippled lending::kLoanMaximumPaymentsPerTransaction: payments a single LoanPay ever processes. + /// + const int LOAN_MAX_PAYMENTS_PER_TRANSACTION = 100; + + /// + /// Upper bound on LoanPay fee increments, mirroring rippled kMaxFeeIncrements. + /// + const int LOAN_MAX_FEE_INCREMENTS = LOAN_MAX_PAYMENTS_PER_TRANSACTION / LOAN_PAYMENTS_PER_FEE_INCREMENT; + /// /// Autofills fields in a transaction. This will set `Sequence`, `Fee`, @@ -206,6 +226,14 @@ public static async Task CalculateFeePerTransactionType(this IXrplClient client, var sponsorSignerFee = baseFee * GetSponsorSignerCount(tx); calculatedFee += signerFee + sponsorSignerFee; + + // rippled LoanPay::calculateBaseFee multiplies the whole Transactor cost — + // signatures included — by one increment per kLoanPaymentsPerFeeIncrement payments. + if (transactionType == nameof(TransactionType.LoanPay)) + { + calculatedFee *= await GetLoanPayFeeIncrements(client, tx, cancellationToken); + } + BigInteger totalFee; if (!string.IsNullOrWhiteSpace(client.maxFeeXRP)) { @@ -234,14 +262,299 @@ private static async Task CalculateBaseFeeForType( { "EscrowFinish" when tx.TryGetValue("Fulfillment", out _) => CalculateEscrowFinishFee(tx, netFeeDrops), "Batch" => await CalculateBatchFee(client, tx, baseFee, cancellationToken), - // LoanSet requires CounterpartySignature (~150 bytes extra). - // Fee formula: baseFee * (1 + 1 counterparty signer) = baseFee * 2 - "LoanSet" => baseFee * 2, + nameof(TransactionType.LoanSet) => await CalculateLoanSetFee(client, tx, baseFee, cancellationToken), + _ when IsConfidentialMPTTx(transactionType) => baseFee * (1 + CONFIDENTIAL_FEE_MULTIPLIER), _ when IsReserveFeeTxNeed(tx) => await FetchReserveFee(client, cancellationToken), _ => baseFee }; } + /// + /// Confidential MPT transactions pay a flat extra multiplier for the cryptographic proofs + /// they carry (rippled Transactor::calculateBaseFee with kConfidentialFeeMultiplier). + /// + private static bool IsConfidentialMPTTx(string transactionType) + { + return transactionType + is nameof(TransactionType.ConfidentialMPTSend) + or nameof(TransactionType.ConfidentialMPTConvert) + or nameof(TransactionType.ConfidentialMPTConvertBack) + or nameof(TransactionType.ConfidentialMPTMergeInbox) + or nameof(TransactionType.ConfidentialMPTClawback); + } + + /// + /// Calculates fee for LoanSet, which charges one extra base fee per counterparty signature + /// (rippled LoanSet::calculateBaseFee counts CounterpartySignature.Signers, or the single + /// signature when present). + /// + /// + /// When the counterparty has not signed yet — the usual case during autofill — the signature + /// count is unknown, so the counterparty's signer list size is used to avoid underpaying. + /// + private static async Task CalculateLoanSetFee(IXrplClient client, Dictionary tx, BigInteger baseFee, CancellationToken cancellationToken = default) + { + int counterpartySigners = GetCounterpartySignerCount(tx); + if (counterpartySigners == 0) + { + counterpartySigners = await FetchCounterpartySignerCount(client, tx, cancellationToken); + } + + return baseFee * (1 + counterpartySigners); + } + + /// + /// Counts signatures already present in CounterpartySignature: every entry of a nested + /// Signers array, or one for a single signature. Returns 0 when the field is absent. + /// + private static int GetCounterpartySignerCount(Dictionary tx) + { + if (!tx.TryGetValue("CounterpartySignature", out var counterpartySignature) || counterpartySignature == null) + return 0; + + int nestedSigners = CountSigners(GetNestedField(counterpartySignature, "Signers")); + if (nestedSigners > 0) + return nestedSigners; + + return GetNestedField(counterpartySignature, "TxnSignature") != null ? 1 : 0; + } + + /// + /// Fetches the size of the counterparty's signer list, mirroring xrpl.js autofill: + /// the counterparty may multi-sign, so the fee has to cover every possible signer. + /// Falls back to a single signature when there is no signer list. + /// + private static async Task FetchCounterpartySignerCount(IXrplClient client, Dictionary tx, CancellationToken cancellationToken = default) + { + if (!tx.TryGetValue("Counterparty", out var counterparty) || counterparty is not string account || string.IsNullOrWhiteSpace(account)) + return 1; + + // Current, not validated: a signer list set in the last ledger has not been validated + // yet, and missing it would underpay the fee. + AccountInfoRequest request = new AccountInfoRequest(account) + { + LedgerIndex = new LedgerIndex(LedgerIndexType.Current), + SignerLists = true, + }; + + try + { + AccountInfo data = await client.AccountInfo(request, cancellationToken); + int? entries = data?.SignerLists?.Length > 0 ? data.SignerLists[0].SignerEntries?.Count : null; + return entries is > 0 ? entries.Value : 1; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // The counterparty account may not exist yet; preclaim rejects the transaction anyway. + // The filter keeps a caller's cancellation out of that fallback: without it an + // OperationCanceledException would be swallowed and autofill would carry on with a + // guessed signer count instead of stopping. A timeout inside the client still falls + // back, since it does not cancel this token. + return 1; + } + } + + /// + /// Number of base fee increments a LoanPay transaction is charged: one per + /// kLoanPaymentsPerFeeIncrement payments the transaction is expected to process, + /// capped at kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement. + /// Returns 1 whenever rippled falls back to the normal cost. + /// + private static async Task GetLoanPayFeeIncrements(IXrplClient client, Dictionary tx, CancellationToken cancellationToken = default) + { + uint flags = ParseTransactionFlags(tx); + bool isFullPayment = (flags & (uint)LoanPayFlags.tfLoanFullPayment) != 0; + bool isLatePayment = (flags & (uint)LoanPayFlags.tfLoanLatePayment) != 0; + + // A full or late payment performs one set of calculations regardless of the amount. + if (isFullPayment || isLatePayment) + return BigInteger.One; + + if (!tx.TryGetValue("LoanID", out var loanId) || loanId is not string id || string.IsNullOrWhiteSpace(id)) + return BigInteger.One; + + if (!TryGetLoanPayAmount(tx, out decimal amount, out bool integralAsset) || amount <= 0) + return BigInteger.One; + + LOLoan loan = await FetchLoan(client, id, cancellationToken); + if (loan == null) + return BigInteger.One; + + // Fewer payments left than one increment covers: no extra work to charge for. + if (loan.PaymentRemaining is null or <= LOAN_PAYMENTS_PER_FEE_INCREMENT) + return BigInteger.One; + + if (!TryParseNumber(loan.PeriodicPayment, out decimal periodicPayment) || periodicPayment <= 0) + return BigInteger.One; + + TryParseNumber(loan.LoanServiceFee, out decimal serviceFee); + decimal regularPayment = RoundPeriodicPayment(periodicPayment, integralAsset, loan.LoanScale ?? 0) + serviceFee; + if (regularPayment <= 0) + return BigInteger.One; + + // The payment handler never processes more than kLoanMaximumPaymentsPerTransaction payments. + // Divided rather than multiplied: a periodic payment near decimal.MaxValue overflows when + // scaled up, while dividing the amount by a constant never can. + if (amount / LOAN_MAX_PAYMENTS_PER_TRANSACTION >= regularPayment) + return LOAN_MAX_FEE_INCREMENTS; + + // Overpayments do about as much work as a full payment, so they round up. + bool isOverpayment = (flags & (uint)LoanPayFlags.tfLoanOverpayment) != 0; + decimal paymentEstimate = amount / regularPayment; + decimal payments = isOverpayment ? Math.Ceiling(paymentEstimate) : Math.Floor(paymentEstimate); + + decimal increments = Math.Ceiling(payments / LOAN_PAYMENTS_PER_FEE_INCREMENT); + if (increments < 1) + return BigInteger.One; + + return increments > LOAN_MAX_FEE_INCREMENTS ? LOAN_MAX_FEE_INCREMENTS : new BigInteger(increments); + } + + /// + /// Reads the Loan ledger object referenced by LoanPay. Returns null when it cannot be + /// retrieved — rippled behaves the same way and lets preclaim report the error. + /// + private static async Task FetchLoan(IXrplClient client, string loanId, CancellationToken cancellationToken = default) + { + try + { + // Current, not validated: a loan created in the last ledger has not been validated + // yet, and treating it as missing would underpay the fee. + LedgerEntryRequest request = new LedgerEntryRequest + { + Index = loanId, + LedgerIndex = new LedgerIndex(LedgerIndexType.Current), + }; + LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken); + return response?.Node as LOLoan; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Same reasoning as FetchCounterpartySignerCount: a missing object is a fallback, + // a cancellation asked for by the caller is not. + return null; + } + } + + /// + /// rippled roundPeriodicPayment: integral assets (XRP, MPT) round up to whole units, + /// IOUs round up to a multiple of 10^scale. + /// + private static decimal RoundPeriodicPayment(decimal periodicPayment, bool integralAsset, int scale) + { + if (integralAsset) + return Math.Ceiling(periodicPayment); + + // Outside this range the step cannot be represented as a decimal; leave the value alone. + if (scale is < -28 or > 28) + return periodicPayment; + + decimal step = scale >= 0 + ? Pow10(scale) + : 1m / Pow10(-scale); + + return Math.Ceiling(periodicPayment / step) * step; + } + + private static decimal Pow10(int exponent) + { + decimal result = 1m; + for (int i = 0; i < exponent; i++) + { + result *= 10m; + } + return result; + } + + /// + /// Extracts the LoanPay amount and whether its asset is integral (XRP drops or MPT units, + /// which rippled rounds to whole units) as opposed to an IOU. + /// + private static bool TryGetLoanPayAmount(Dictionary tx, out decimal amount, out bool integralAsset) + { + amount = 0m; + integralAsset = true; + + if (!tx.TryGetValue("Amount", out var rawAmount) || rawAmount == null) + return false; + + if (rawAmount is string xrpDrops) + return TryParseNumber(xrpDrops, out amount); + + object value = GetNestedField(rawAmount, "value"); + if (value == null) + return false; + + // MPT amounts are integral like XRP; issued currencies carry decimals. + integralAsset = GetNestedField(rawAmount, "mpt_issuance_id") != null; + return TryParseNumber(ToPlainValue(value), out amount); + } + + private static uint ParseTransactionFlags(Dictionary tx) + { + if (!tx.TryGetValue("Flags", out var flags) || flags == null) + return 0; + + return flags switch + { + uint u => u, + int i when i >= 0 => (uint)i, + long l when l is >= 0 and <= uint.MaxValue => (uint)l, + LoanPayFlags f => (uint)f, + JsonValue jv when jv.TryGetValue(out uint u) => u, + JsonElement je when je.ValueKind == JsonValueKind.Number && je.TryGetUInt32(out uint u) => u, + string s when uint.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint u) => u, + _ => 0 + }; + } + + /// + /// Parses a rippled Number field, which is serialized as a decimal string. + /// + private static bool TryParseNumber(object value, out decimal result) + { + result = 0m; + return value != null + && decimal.TryParse( + value as string ?? value.ToString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out result); + } + + private static object GetNestedField(object container, string fieldName) + { + return container switch + { + Dictionary dict => dict.TryGetValue(fieldName, out var value) ? value : null, + JsonObject jo => jo.TryGetPropertyValue(fieldName, out var node) ? node : null, + JsonElement je when je.ValueKind == JsonValueKind.Object && je.TryGetProperty(fieldName, out var prop) => prop, + _ => null + }; + } + + private static object ToPlainValue(object value) + { + return value switch + { + JsonNode node => node.ToString(), + JsonElement je => je.ValueKind == JsonValueKind.String ? je.GetString() : je.ToString(), + _ => value + }; + } + + private static int CountSigners(object signers) + { + return signers switch + { + JsonArray ja => ja.Count, + JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(), + ICollection collection => collection.Count, + IEnumerable enumerable => enumerable.Count(), + _ => 0 + }; + } + /// /// Calculates fee for EscrowFinish with Fulfillment. /// Formula: 10 drops × (33 + (Fulfillment size in bytes / 16)) @@ -297,20 +610,7 @@ private static int GetSponsorSignerCount(Dictionary tx) if (!tx.TryGetValue("SponsorSignature", out var sponsorSignature) || sponsorSignature == null) return 0; - object signers = sponsorSignature switch - { - Dictionary dict => dict.TryGetValue("Signers", out var s) ? s : null, - JsonObject jo => jo.TryGetPropertyValue("Signers", out var node) ? (object)node : null, - _ => null - }; - - return signers switch - { - JsonArray ja => ja.Count, - ICollection collection => collection.Count, - IEnumerable enumerable => enumerable.Count(), - _ => 0 - }; + return CountSigners(GetNestedField(sponsorSignature, "Signers")); } private static bool TryGetInnerFieldsAsDict(object item, out Dictionary dict) diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 745c024a..8adc651e 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -1,4 +1,4 @@ - + @@ -14,13 +14,16 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.10.0.0 + 10.11.0.0 4 true + + +