From aedecc77922e47c6479216640b078f74cb0109de Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 10:49:55 -0300 Subject: [PATCH 01/11] feat(protocol)!: schema for rippled develop e3c8996e (3.4.0-rc1), and the nightly-pin report that hid it definitions.json had been synced for 3.3.0 and was eight fields behind develop, which definitions-watch reported as node-only for three weeks. The nightly-pin bump (#182) that would have listed them opened with an empty definitions section: the step inherits `bash -e` from the runner, the diff exits 1 whenever it finds drift, and errexit ended the step at the assignment before the report was echoed or recorded. - definitions.json: ContractResult, VaultKind, SubscriptionDate, RedemptionDate, IssuerKeyEpoch, AuditorKeyEpoch, IssuerKeyMirrorEpoch, AuditorKeyMirrorEpoch; Xrpl.BinaryCodec 11.1.0.0 - closed-ended vaults (rippled #7921, LendingProtocolV1_1): VaultKind, SubscriptionDate, RedemptionDate on VaultCreate and LOVault, the VaultKind enum, and rippled's preflight rules in ValidateVaultCreate - confidential MPT key rotation (rippled #7915): IssuerKeyEpoch and AuditorKeyEpoch on LOMPTokenIssuance; MPTokenIssuanceSet docs describe rotation - CredentialIDs on VaultWithdraw and LoanBrokerCoverWithdraw - transactions.macro fixture pinned to the same develop commit as ledger_entries.macro; the 3.3.0 tag no longer agrees with develop on fields - nightly-pin-watch: `set +e` before the diff so the report survives drift - guides: closed-ended vaults (Vault-Guide), key rotation (ConfidentialMPT-Guide) Verified: 1290 TestU pass; `GenerateEnums diff` against devnet (3.4.0-rc2) reports 0 node-only, down from 4; the nightly pin adds the four key-epoch fields on top, all covered by TestUDevelopFields_BinaryRoundTrip. --- .github/workflows/nightly-pin-watch.yml | 4 + .../Enums/Field.Uint32.Generated.cs | 6 + .../Enums/Field.Uint8.Generated.cs | 2 + Base/Xrpl.BinaryCodec/Enums/definitions.json | 80 +++ Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj | 2 +- CHANGES.md | 7 + DocFx/ConfidentialMPT-Guide.md | 3 +- DocFx/ConfidentialMPT-Guide.ru.md | 3 +- DocFx/Vault-Guide.md | 31 ++ DocFx/Vault-Guide.ru.md | 31 ++ .../Xrpl.Tests/Fixtures/ledger_entries.macro | 5 + .../Fixtures/ledger_entries.macro.ref | 16 +- Tests/Xrpl.Tests/Fixtures/transactions.macro | 505 ++++++++---------- .../Fixtures/transactions.macro.ref | 17 +- .../Models/TestUProtocolCompleteness.cs | 215 ++++++++ Xrpl/Models/Ledger/LOMPTokenIssuance.cs | 15 + Xrpl/Models/Ledger/LOVault.cs | 47 ++ .../Transactions/LoanBrokerCoverWithdraw.cs | 22 + .../Models/Transactions/MPTokenIssuanceSet.cs | 12 +- Xrpl/Models/Transactions/TxFormat.cs | 5 + Xrpl/Models/Transactions/VaultCreate.cs | 91 ++++ Xrpl/Models/Transactions/VaultWithdraw.cs | 22 + 22 files changed, 837 insertions(+), 304 deletions(-) diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml index 8b8b8459..e1dcc3a2 100644 --- a/.github/workflows/nightly-pin-watch.yml +++ b/.github/workflows/nightly-pin-watch.yml @@ -187,6 +187,10 @@ jobs: id: definitions continue-on-error: true run: | + # The runner starts this script with `bash -e`, and the diff exits 1 whenever it + # finds drift: errexit would end the step at the assignment below, before the + # report is echoed or recorded - the one case the step exists for. + set +e set -uo pipefail out=$(dotnet run --project Tools/GenerateEnums -- diff http://localhost:5005 2>&1) status=$? diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs index 64e90439..734e7774 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs @@ -78,5 +78,11 @@ public partial class Field public static readonly Uint32Field SponsoringAccountCount = new Uint32Field(nameof(SponsoringAccountCount), 72); public static readonly Uint32Field RemainingOwnerCount = new Uint32Field(nameof(RemainingOwnerCount), 73); public static readonly Uint32Field SponsorFlags = new Uint32Field(nameof(SponsorFlags), 74); + public static readonly Uint32Field SubscriptionDate = new Uint32Field(nameof(SubscriptionDate), 75); + public static readonly Uint32Field RedemptionDate = new Uint32Field(nameof(RedemptionDate), 76); + public static readonly Uint32Field IssuerKeyEpoch = new Uint32Field(nameof(IssuerKeyEpoch), 77); + public static readonly Uint32Field AuditorKeyEpoch = new Uint32Field(nameof(AuditorKeyEpoch), 78); + public static readonly Uint32Field IssuerKeyMirrorEpoch = new Uint32Field(nameof(IssuerKeyMirrorEpoch), 79); + public static readonly Uint32Field AuditorKeyMirrorEpoch = new Uint32Field(nameof(AuditorKeyMirrorEpoch), 80); } } diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs index 01a4ca2f..878c4680 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs @@ -16,5 +16,7 @@ public partial class Field public static readonly Uint8Field HookResult = new Uint8Field(nameof(HookResult), 18); public static readonly Uint8Field WasLockingChainSend = new Uint8Field(nameof(WasLockingChainSend), 19); public static readonly Uint8Field WithdrawalPolicy = new Uint8Field(nameof(WithdrawalPolicy), 20); + public static readonly Uint8Field ContractResult = new Uint8Field(nameof(ContractResult), 21); + public static readonly Uint8Field VaultKind = new Uint8Field(nameof(VaultKind), 22); } } diff --git a/Base/Xrpl.BinaryCodec/Enums/definitions.json b/Base/Xrpl.BinaryCodec/Enums/definitions.json index 62446095..4b24a517 100644 --- a/Base/Xrpl.BinaryCodec/Enums/definitions.json +++ b/Base/Xrpl.BinaryCodec/Enums/definitions.json @@ -3220,6 +3220,26 @@ "type": "UInt8" } ], + [ + "ContractResult", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 21, + "type": "UInt8" + } + ], + [ + "VaultKind", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 22, + "type": "UInt8" + } + ], [ "TakerPaysCurrency", { @@ -3690,6 +3710,66 @@ "type": "UInt32" } ], + [ + "SubscriptionDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 75, + "type": "UInt32" + } + ], + [ + "RedemptionDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 76, + "type": "UInt32" + } + ], + [ + "IssuerKeyEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 77, + "type": "UInt32" + } + ], + [ + "AuditorKeyEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 78, + "type": "UInt32" + } + ], + [ + "IssuerKeyMirrorEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 79, + "type": "UInt32" + } + ], + [ + "AuditorKeyMirrorEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 80, + "type": "UInt32" + } + ], [ "ConfidentialBalanceVersion", { diff --git a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj index e6711263..4ac78134 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 - 11.0.1.0 + 11.1.0.0 diff --git a/CHANGES.md b/CHANGES.md index 6153cefc..99342a67 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,13 @@ * the documentation of `UseCheckHealth` and `InactivityTimeout` promised more than the code does: it said the health check on its own reconnects after sixty seconds without inbound data, while the inactivity check has always run only with `UseCustomPing` enabled - and deliberately so, since an idle connection with no subscriptions receives nothing by design, and silence without keepalive pings would declare a healthy socket dead every minute. The behaviour stays; the docs now say what it is. Found by driving the Blazor test client through a connection that stayed open and went silent * pinned by tests that issue the second operation from a callback the first one runs, which lands it inside the first one's yields every time: a `Disconnect()` and a second `ChangeServer` from the session-ended handler of a `ChangeServer`, a `ChangeServer` from the `RestoringConnection` notification of the fast reconnect, a `Disconnect()` from the `OnConnected` handler, a `Connect()` after a `Disconnect()` against a server that comes up later, and a server that closes each connection the moment its handshake completes, so the reconnect loop's success and the close it has to survive arrive together +* **The protocol schema follows rippled develop at `e3c8996e`, the 3.4.0-rc1 build the nightly stand is pinned to** (#182 bumps the pin). `definitions.json` had been synced for 3.3.0 and was eight fields behind develop, which definitions-watch had reported as node-only for three weeks. The nightly-pin bump that would have listed them opened with an empty "definitions.json vs the new build" section: the step inherits `bash -e` from the runner, and the diff exits 1 whenever it finds drift, so errexit ended the step at the assignment - before the report was echoed or recorded - in the one case the step exists for. Fixed alongside. + * **closed-ended vaults** (rippled #7921, LendingProtocolV1_1): `VaultCreate` and `LOVault` carry `VaultKind`, `SubscriptionDate` and `RedemptionDate`, and the `VaultKind` enum names the two kinds. `ValidateVaultCreate` pins rippled's preflight: the dates only on a closed-ended vault, both of them, with the redemption at least one minute and less than thirty years after the subscription. Deposits are accepted in the subscription phase only, withdrawals in every phase but investment + * **confidential MPT key rotation** (rippled #7915, ConfidentialMPTKeyRotation): `LOMPTokenIssuance` carries `IssuerKeyEpoch` and `AuditorKeyEpoch`, incremented each time `MPTokenIssuanceSet` replaces the key. The transaction is unchanged - the same `IssuerEncryptionKey`/`AuditorEncryptionKey` fields rotate a key once the amendment is active, and the current key is refused with `tecDUPLICATE`. `IssuerKeyMirrorEpoch`, `AuditorKeyMirrorEpoch` and `ContractResult` (rippled #7988) are known to the codec but belong to no format yet + * `VaultWithdraw` and `LoanBrokerCoverWithdraw` accept `CredentialIDs`, for a `Destination` that requires deposit authorization; validated the way `Payment.CredentialIDs` is + * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do + * `Xrpl.BinaryCodec` 11.1.0.0 for the new codec entries. The CI stand (3.3.0) knows none of the new fields, so they are covered by round-trip and validation unit tests rather than integration tests; the nightly stand after #182 has every amendment involved enabled at genesis + ## 11.3.2.0 06/09/2026 * **A request issued while the client is switching servers no longer hangs until `RequestTimeout`** (#177). Every path that retires a connection - `ChangeServer`, the ping-triggered fast reconnect, `Disconnect`, `DisconnectAndWaitAsync`, and the path taken when an `OnConnected` handler fails - rejected the pending requests first and cleared the socket reference afterwards. The rejection resumes the consumer, and a consumer that issues its next request from there - the second value of a page load, read from the response handler of the first - found the retired socket still installed, passed the connectivity check on it, and was written into it after the sweep that would have rejected it. Nothing completed it: the sweep had run, and a failed send is report-only. Forty seconds later it timed out, with the connection healthy for thirty-nine of them. diff --git a/DocFx/ConfidentialMPT-Guide.md b/DocFx/ConfidentialMPT-Guide.md index 77542e3a..345716c8 100644 --- a/DocFx/ConfidentialMPT-Guide.md +++ b/DocFx/ConfidentialMPT-Guide.md @@ -63,6 +63,7 @@ Rules enforced by rippled preflight (mirrored by SDK validation): - a non-zero `TransferFee` **cannot** be combined with enabling confidential balances (`temBAD_TRANSFER_FEE`); - `tifMPTCanHoldConfidentialBalance` in `ImmutableFlags` — on the create or on any later set — permanently forbids enabling privacy; - an `AuditorEncryptionKey` requires an `IssuerEncryptionKey`. +- a key is registered once. With the ConfidentialMPTKeyRotation amendment active, an `MPTokenIssuanceSet` carrying a different key rotates it and increments `IssuerKeyEpoch` / `AuditorKeyEpoch` on the issuance; the current key is refused (`tecDUPLICATE`), and an auditor key may then be registered after the issuer key. --- @@ -92,7 +93,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), `ImmutableFlags` +- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `IssuerKeyEpoch` and `AuditorKeyEpoch` (rotation counters, absent until the first rotation), `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 298cf2ed..d6845eaa 100644 --- a/DocFx/ConfidentialMPT-Guide.ru.md +++ b/DocFx/ConfidentialMPT-Guide.ru.md @@ -63,6 +63,7 @@ var set = new MPTokenIssuanceSet - ненулевой `TransferFee` **несовместим** со включением конфиденциальных балансов (`temBAD_TRANSFER_FEE`); - флаг `tifMPTCanHoldConfidentialBalance` в `ImmutableFlags` — при создании выпуска или в любой последующей транзакции — навсегда запрещает включение приватности; - `AuditorEncryptionKey` требует наличия `IssuerEncryptionKey`. +- ключ регистрируется один раз. При активной поправке ConfidentialMPTKeyRotation `MPTokenIssuanceSet` с другим ключом заменяет его и увеличивает `IssuerKeyEpoch` / `AuditorKeyEpoch` на выпуске; текущий ключ отклоняется (`tecDUPLICATE`), а ключ аудитора можно зарегистрировать уже после ключа эмитента. --- @@ -92,7 +93,7 @@ var set = new MPTokenIssuanceSet ## Объекты леджера -- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal-строка — base-ten UInt64 поле), `ImmutableFlags` +- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `IssuerKeyEpoch` и `AuditorKeyEpoch` (счётчики ротаций, отсутствуют до первой ротации), `ConfidentialOutstandingAmount` (decimal-строка — base-ten UInt64 поле), `ImmutableFlags` - `LOMPToken`: поля конфиденциального баланса/inbox (зашифрованные блобы + счётчики) --- diff --git a/DocFx/Vault-Guide.md b/DocFx/Vault-Guide.md index efd7c0c8..196f7b49 100644 --- a/DocFx/Vault-Guide.md +++ b/DocFx/Vault-Guide.md @@ -57,6 +57,33 @@ When a depositor adds assets, the vault issues shares as MPT tokens. The number Defines how withdrawals are handled: - `0x0001` (`vaultStrategyFirstComeFirstServe`) — depositors can redeem any amount of assets provided they hold sufficient shares +### Vault Kind (LendingProtocolV1_1) + +A vault is open-ended by default: deposits and withdrawals are accepted at any time. With the LendingProtocolV1_1 amendment a vault can instead be created closed-ended, with three phases fixed at creation: + +| Phase | Ends at | Deposits | Withdrawals | +|-------|---------|----------|-------------| +| Subscription | `SubscriptionDate` (inclusive) | accepted | accepted | +| Investment | `RedemptionDate` | refused (`tecEXPIRED`) | refused (`tecTOO_SOON`) | +| Redemption | never | refused (`tecEXPIRED`) | accepted | + +`VaultKind`, `SubscriptionDate` and `RedemptionDate` are set on `VaultCreate` and cannot be changed afterwards. A closed-ended vault requires both dates, with the redemption at least one minute and less than thirty years after the subscription; an open-ended vault may carry neither. The dates are `DateTime?` on the models and travel as seconds since the Ripple Epoch. + +```csharp +using Xrpl.Models.Ledger; // VaultKind + +VaultCreate vaultTx = new VaultCreate +{ + Account = wallet.ClassicAddress, + Asset = new IssuedCurrency { Currency = "XRP" }, + VaultKind = (uint)VaultKind.ClosedEnded, + SubscriptionDate = DateTime.UtcNow.AddDays(7), + RedemptionDate = DateTime.UtcNow.AddDays(97), +}; +``` + +A node without the amendment refuses a `VaultCreate` that carries any of the three fields (`temDISABLED`). On the ledger object `VaultKind` is absent for an open-ended vault, whether it was created before the amendment or after it. + ### Vault Flags Set only at creation time via `VaultCreate`: @@ -292,6 +319,10 @@ Console.WriteLine($"Metadata: {vault.DataParsed?.Name}"); | `ShareMPTID` | string | MPTokenIssuance ID for vault shares | | `WithdrawalPolicy` | uint? | Withdrawal strategy | | `Scale` | uint? | Decimal precision for share calculations | +| `LEVersion` | uint? | Schema version (`VaultVersion`), absent on vaults created before cash-basis accounting | +| `VaultKind` | uint? | `VaultKind.ClosedEnded` (1) for a closed-ended vault, absent otherwise | +| `SubscriptionDate` | DateTime? | End of the subscription phase (closed-ended only) | +| `RedemptionDate` | DateTime? | Start of the redemption phase (closed-ended only) | | `Data` | string | Hex-encoded metadata (max 256 bytes) | | `Sequence` | uint? | Creation transaction sequence | diff --git a/DocFx/Vault-Guide.ru.md b/DocFx/Vault-Guide.ru.md index 756addc5..7f596532 100644 --- a/DocFx/Vault-Guide.ru.md +++ b/DocFx/Vault-Guide.ru.md @@ -57,6 +57,33 @@ Vault — это ledger-структура, которая хранит один Определяет правила вывода: - `0x0001` (`vaultStrategyFirstComeFirstServe`) — вкладчики могут выкупить любое количество активов при наличии достаточного числа долей +### Вид vault (LendingProtocolV1_1) + +По умолчанию vault открытый (open-ended): внесение и вывод возможны в любой момент. С поправкой LendingProtocolV1_1 vault можно создать закрытым (closed-ended), с тремя фазами, зафиксированными при создании: + +| Фаза | Заканчивается | Внесение | Вывод | +|------|---------------|----------|-------| +| Subscription | `SubscriptionDate` (включительно) | разрешено | разрешён | +| Investment | `RedemptionDate` | отклоняется (`tecEXPIRED`) | отклоняется (`tecTOO_SOON`) | +| Redemption | никогда | отклоняется (`tecEXPIRED`) | разрешён | + +`VaultKind`, `SubscriptionDate` и `RedemptionDate` задаются в `VaultCreate` и позже не меняются. Закрытому vault нужны обе даты, причём redemption не раньше чем через минуту и строго раньше чем через тридцать лет после subscription; открытый vault не может нести ни одной. В моделях даты представлены как `DateTime?`, по сети передаются секундами от Ripple Epoch. + +```csharp +using Xrpl.Models.Ledger; // VaultKind + +VaultCreate vaultTx = new VaultCreate +{ + Account = wallet.ClassicAddress, + Asset = new IssuedCurrency { Currency = "XRP" }, + VaultKind = (uint)VaultKind.ClosedEnded, + SubscriptionDate = DateTime.UtcNow.AddDays(7), + RedemptionDate = DateTime.UtcNow.AddDays(97), +}; +``` + +Узел без поправки отклоняет `VaultCreate` с любым из трёх полей (`temDISABLED`). В ledger-объекте `VaultKind` отсутствует у открытого vault независимо от того, создан он до поправки или после. + ### Флаги Vault Устанавливаются только при создании через `VaultCreate`: @@ -292,6 +319,10 @@ Console.WriteLine($"Metadata: {vault.DataParsed?.Name}"); | `ShareMPTID` | string | ID MPTokenIssuance для долей | | `WithdrawalPolicy` | uint? | Стратегия вывода | | `Scale` | uint? | Точность при расчёте долей | +| `LEVersion` | uint? | Версия схемы (`VaultVersion`), отсутствует у vault, созданных до cash-basis учёта | +| `VaultKind` | uint? | `VaultKind.ClosedEnded` (1) для закрытого vault, иначе отсутствует | +| `SubscriptionDate` | DateTime? | Конец фазы subscription (только closed-ended) | +| `RedemptionDate` | DateTime? | Начало фазы redemption (только closed-ended) | | `Data` | string | Hex-метаданные (макс. 256 байт) | | `Sequence` | uint? | Sequence транзакции создания | diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro index ffcd025f..be12ad63 100644 --- a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro @@ -408,6 +408,8 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, + {sfIssuerKeyEpoch, SoeOptional}, + {sfAuditorKeyEpoch, SoeOptional}, {sfConfidentialOutstandingAmount, SoeDefault}, })) @@ -506,6 +508,9 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, {sfLEVersion, SoeDefault}, + {sfVaultKind, SoeDefault}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref index c9d8c4fa..d50c02a2 100644 --- a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref @@ -1,11 +1,11 @@ https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/ledger_entries.macro -sha 9859e5cedaffce2f9544d7e2b6aa0e041c3a6f75 -date 2026-08-07T15:00:25Z +sha e3c8996e44921fe3b4e02c65cb41948848bcc7c5 +date 2026-09-05T00:06:14Z 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 \ + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/e3c8996e44921fe3b4e02c65cb41948848bcc7c5/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 @@ -13,10 +13,12 @@ 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. +which is pinned to the 3.3.0 tag. The models track develop for fields: sfLEVersion +(Vault) existed only on develop before 3.3.0, and the closed-ended vault fields +(VaultKind, SubscriptionDate, RedemptionDate) and the confidential MPT key epochs +(IssuerKeyEpoch, AuditorKeyEpoch) are in the same position now. The sha is the +commit the nightly stand is pinned to (.ci-config/Dockerfile.nightly), so the +fixture and the stand describe the same build. 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 diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro b/Tests/Xrpl.Tests/Fixtures/transactions.macro index 1f9603db..dbf9b66a 100644 --- a/Tests/Xrpl.Tests/Fixtures/transactions.macro +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,31 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,9 +47,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -44,11 +64,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfCondition, SoeOptional}, @@ -61,11 +77,7 @@ TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, @@ -79,9 +91,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -99,11 +109,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -113,9 +119,7 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfRegularKey, SoeOptional}, })) @@ -127,9 +131,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -142,11 +144,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -156,11 +154,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -173,9 +167,7 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -185,11 +177,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -202,11 +190,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -216,11 +200,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -233,11 +213,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -250,9 +226,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -263,11 +237,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -275,11 +245,7 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, ({.delegable = Delegation::Delegable}), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, {sfAuthorizeCredentials, SoeOptional}, @@ -290,11 +256,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -305,9 +267,9 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, + ({ + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -321,9 +283,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -339,9 +299,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -351,11 +309,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -367,11 +321,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -379,11 +329,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -393,11 +339,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -407,9 +349,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -422,9 +367,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -436,9 +383,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -454,9 +399,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -471,9 +418,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,9 +430,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -501,9 +444,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -514,9 +459,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -525,9 +468,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -537,9 +478,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -550,9 +489,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -562,9 +499,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -581,11 +520,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -604,9 +544,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -615,9 +553,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -629,9 +565,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -643,9 +577,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -653,9 +585,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -670,9 +600,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -682,9 +610,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -696,9 +622,11 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, - Delegation::Delegable, - featureMPTokensV1, - CreateMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::CreateMptIssuance, + }), ({ {sfAssetScale, SoeOptional}, {sfTransferFee, SoeOptional}, @@ -713,9 +641,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -725,9 +655,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, - Delegation::Delegable, - featureMPTokensV1, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureMPTokensV1}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -744,9 +672,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -757,9 +687,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -772,9 +700,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -785,9 +711,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -799,9 +723,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -813,9 +735,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -826,9 +746,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -838,9 +756,9 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, + ({ + .amendment = featurePermissionDelegationV1_1, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -851,9 +769,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -862,6 +782,9 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, {sfWithdrawalPolicy, SoeOptional}, {sfData, SoeOptional}, {sfScale, SoeOptional}, + {sfVaultKind, SoeOptional}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, })) /** This transaction updates a single asset vault. */ @@ -869,9 +792,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -884,9 +808,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -897,9 +823,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -910,14 +837,17 @@ TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, # include #endif TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back tokens from a vault. */ @@ -925,9 +855,10 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, # include #endif TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfHolder, SoeRequired}, @@ -939,9 +870,9 @@ TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, # include #endif TRANSACTION(ttBATCH, 71, Batch, - Delegation::NotDelegable, - featureBatchV1_1, - NoPriv, + ({ + .amendment = featureBatchV1_1, + }), ({ {sfRawTransactions, SoeRequired}, {sfBatchSigners, SoeOptional}, @@ -954,9 +885,11 @@ TRANSACTION(ttBATCH, 71, Batch, # include #endif TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, - Delegation::NotDelegable, - featureLendingProtocol, - CreatePseudoAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfVaultID, SoeRequired}, {sfLoanBrokerID, SoeOptional}, {sfData, SoeOptional}, @@ -971,9 +904,11 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, # include #endif TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, - Delegation::NotDelegable, - featureLendingProtocol, - MustDeleteAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, })) @@ -982,9 +917,10 @@ TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -994,13 +930,16 @@ TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back First Loss Capital from a Loan Broker to @@ -1009,9 +948,10 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeOptional}, {sfAmount, SoeOptional, SoeMptSupported}, })) @@ -1021,9 +961,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, # include #endif TRANSACTION(ttLOAN_SET, 80, LoanSet, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, {sfCounterparty, SoeOptional}, @@ -1048,9 +990,10 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1059,12 +1002,14 @@ TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, # include #endif TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, - Delegation::NotDelegable, - featureLendingProtocol, - // All of the LoanManage options will modify the vault, but the - // transaction can succeed without options, essentially making it - // a noop. - MayModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + .privileges = Privilege::MayModifyVault, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1073,9 +1018,11 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, # include #endif TRANSACTION(ttLOAN_PAY, 84, LoanPay, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -1085,9 +1032,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::NotDelegable, - featureConfidentialTransfer, - NoPriv, + ({ + .amendment = featureConfidentialTransfer, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1104,9 +1051,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -1116,9 +1061,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1134,9 +1077,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfDestination, SoeRequired}, @@ -1155,9 +1096,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1170,9 +1109,9 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, # include #endif TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, - Delegation::NotDelegable, - featureSponsor, - NoPriv, + ({ + .amendment = featureSponsor, + }), ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1183,9 +1122,7 @@ TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, # include #endif TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, - Delegation::Delegable, - featureSponsor, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureSponsor}), ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1202,9 +1139,7 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1214,9 +1149,7 @@ TRANSACTION(ttAMENDMENT, 100, EnableAmendment, For details, see: https://xrpl.org/fee-voting.html */ TRANSACTION(ttFEE, 101, SetFee, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeOptional}, // Old version uses raw numbers @@ -1235,9 +1168,7 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref index 83256926..01a90aa9 100644 --- a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref @@ -1,14 +1,21 @@ -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 +https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/transactions.macro +sha e3c8996e44921fe3b4e02c65cb41948848bcc7c5 +date 2026-09-05T00:06:14Z 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/00a178fb92ca49521b937ae1a99d863765ea8a90/include/xrpl/protocol/detail/transactions.macro \ + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/e3c8996e44921fe3b4e02c65cb41948848bcc7c5/include/xrpl/protocol/detail/transactions.macro \ | diff - Tests/Xrpl.Tests/Fixtures/transactions.macro +Pinned to a develop commit rather than to a release tag, the way ledger_entries.macro +is. Up to 3.3.0 the two agreed on transaction fields, so the tag was the simpler +pin; since then develop has added fields the models carry ahead of a release +(VaultCreate.VaultKind/SubscriptionDate/RedemptionDate, VaultWithdraw.CredentialIDs, +LoanBrokerCoverWithdraw.CredentialIDs), and a tag would report them as fields the +models invented. The sha is the commit the nightly stand is pinned to +(.ci-config/Dockerfile.nightly), so the fixture and the stand describe the same build. + Do not hand-edit it. When protocol-watch reports a change to this file upstream, replace it wholesale, update the sha above, and let TestUTxFormatConformance show which TxFormat entries have to follow. diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 5d62982c..6653d053 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -12,6 +12,8 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Transactions; +using static Xrpl.Models.Common.Common; + using TxFormat = Xrpl.Models.Transaction.TxFormat; using Xrpl.Wallet; @@ -381,5 +383,218 @@ public void TestULEVersion_BinaryRoundTrip() Assert.AreEqual(1u, decoded["LEVersion"]!.GetValue()); Assert.AreEqual(6u, decoded["Scale"]!.GetValue()); } + private static readonly System.DateTime RippleEpoch = new System.DateTime(2000, 1, 1, 0, 0, 0, System.DateTimeKind.Utc); + + [TestMethod] + public void TestUVaultCreate_ClosedEndedFields_RoundTrip() + { + // rippled #7921 (LendingProtocolV1_1): VaultKind/SubscriptionDate/RedemptionDate on VaultCreate + VaultCreate create = new VaultCreate + { + Account = Account1, + Asset = new IssuedCurrency { Currency = "XRP" }, + VaultKind = (uint)Xrpl.Models.Ledger.VaultKind.ClosedEnded, + SubscriptionDate = RippleEpoch.AddSeconds(800000000), + RedemptionDate = RippleEpoch.AddSeconds(800086400), + Sequence = 1, + Fee = new Currency { Value = "12" }, + SigningPublicKey = "", + }; + JsonObject json = JsonNode.Parse(create.ToJson())!.AsObject(); + Assert.AreEqual(800000000u, json["SubscriptionDate"]!.GetValue(), "dates travel as seconds since the Ripple Epoch"); + + string blob = XrplBinaryCodec.Encode(json); + JsonObject decoded = XrplBinaryCodec.Decode(blob).AsObject(); + + Assert.AreEqual(1u, decoded["VaultKind"]!.GetValue()); + Assert.AreEqual(800000000u, decoded["SubscriptionDate"]!.GetValue()); + Assert.AreEqual(800086400u, decoded["RedemptionDate"]!.GetValue()); + + TxFormat format = TxFormat.Formats[BinaryCodec.Types.TransactionType.VaultCreate]; + Assert.AreEqual(TxFormat.Requirement.Optional, format[BinaryCodec.Enums.Field.VaultKind]); + Assert.AreEqual(TxFormat.Requirement.Optional, format[BinaryCodec.Enums.Field.SubscriptionDate]); + Assert.AreEqual(TxFormat.Requirement.Optional, format[BinaryCodec.Enums.Field.RedemptionDate]); + } + + [TestMethod] + public async Task TestUVaultCreate_ClosedEndedPreflightRules() + { + // rippled VaultCreate::preflight rules for closed-ended vaults pinned client-side + Dictionary tx = new() + { + ["TransactionType"] = "VaultCreate", + ["Account"] = Account1, + ["Asset"] = new Dictionary { ["currency"] = "XRP" }, + }; + await Validation.ValidateVaultCreate(tx); + + // an unknown kind is temMALFORMED + tx["VaultKind"] = 2u; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + // the dates belong to closed-ended vaults only + tx["VaultKind"] = (uint)Xrpl.Models.Ledger.VaultKind.OpenEnded; + tx["SubscriptionDate"] = 800000000u; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + tx.Remove("VaultKind"); + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + // a closed-ended vault needs both dates + tx["VaultKind"] = (uint)Xrpl.Models.Ledger.VaultKind.ClosedEnded; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + // kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod + tx["RedemptionDate"] = 800000059u; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + tx["RedemptionDate"] = 800000060u; + await Validation.ValidateVaultCreate(tx); + + tx["RedemptionDate"] = 800000000u + 946708560u; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + tx["RedemptionDate"] = 800000000u + 946708559u; + await Validation.ValidateVaultCreate(tx); + } + + [TestMethod] + public void TestULOVault_ClosedEndedFields_Deserialize() + { + string json = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "Vault", + ["Account"] = Account1, + ["Owner"] = Account2, + ["VaultKind"] = (uint)Xrpl.Models.Ledger.VaultKind.ClosedEnded, + ["SubscriptionDate"] = 800000000u, + ["RedemptionDate"] = 800086400u, + }); + LOVault vault = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual((uint)Xrpl.Models.Ledger.VaultKind.ClosedEnded, vault.VaultKind); + Assert.AreEqual(RippleEpoch.AddSeconds(800000000), vault.SubscriptionDate); + Assert.AreEqual(RippleEpoch.AddSeconds(800086400), vault.RedemptionDate); + + // An open-ended vault carries none of the three (VaultKind is SoeDefault), exactly + // like every vault created before the amendment + string openEnded = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "Vault", + ["Account"] = Account1, + ["Owner"] = Account2, + }); + LOVault legacy = JsonSerializer.Deserialize(openEnded, XrplJsonOptions.Default); + Assert.IsNull(legacy.VaultKind); + Assert.IsNull(legacy.SubscriptionDate); + Assert.IsNull(legacy.RedemptionDate); + } + + [TestMethod] + public void TestULOMPTokenIssuance_KeyEpochs_Deserialize() + { + // rippled #7915 (ConfidentialMPTKeyRotation): the epochs count rotations and are absent until the first one + string json = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "MPTokenIssuance", + ["Issuer"] = Account1, + ["IssuerEncryptionKey"] = new string('C', 66), + ["AuditorEncryptionKey"] = new string('D', 66), + ["IssuerKeyEpoch"] = 2u, + ["AuditorKeyEpoch"] = 1u, + }); + LOMPTokenIssuance issuance = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual(2u, issuance.IssuerKeyEpoch); + Assert.AreEqual(1u, issuance.AuditorKeyEpoch); + + string neverRotated = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "MPTokenIssuance", + ["Issuer"] = Account1, + ["IssuerEncryptionKey"] = new string('C', 66), + }); + LOMPTokenIssuance fresh = JsonSerializer.Deserialize(neverRotated, XrplJsonOptions.Default); + Assert.IsNull(fresh.IssuerKeyEpoch); + Assert.IsNull(fresh.AuditorKeyEpoch); + } + + [TestMethod] + public void TestUDevelopFields_BinaryRoundTrip() + { + // Every field rippled develop declares at e3c8996e that 3.3.0 does not. The mirror + // epochs and ContractResult belong to no format yet, so nothing but the codec + // table knows them: this fails with an encoding error when definitions.json lacks one. + JsonObject json = JsonNode.Parse(""" + {"ContractResult":7,"VaultKind":1,"SubscriptionDate":800000000,"RedemptionDate":800086400, + "IssuerKeyEpoch":2,"AuditorKeyEpoch":1,"IssuerKeyMirrorEpoch":2,"AuditorKeyMirrorEpoch":1} + """)!.AsObject(); + string blob = XrplBinaryCodec.Encode(json); + JsonObject decoded = XrplBinaryCodec.Decode(blob).AsObject(); + + Assert.AreEqual(7u, decoded["ContractResult"]!.GetValue()); + Assert.AreEqual(1u, decoded["VaultKind"]!.GetValue()); + Assert.AreEqual(800000000u, decoded["SubscriptionDate"]!.GetValue()); + Assert.AreEqual(800086400u, decoded["RedemptionDate"]!.GetValue()); + Assert.AreEqual(2u, decoded["IssuerKeyEpoch"]!.GetValue()); + Assert.AreEqual(1u, decoded["AuditorKeyEpoch"]!.GetValue()); + Assert.AreEqual(2u, decoded["IssuerKeyMirrorEpoch"]!.GetValue()); + Assert.AreEqual(1u, decoded["AuditorKeyMirrorEpoch"]!.GetValue()); + } + + [TestMethod] + public void TestUWithdraws_CredentialIDs_RoundTrip() + { + // develop adds CredentialIDs to VaultWithdraw and LoanBrokerCoverWithdraw for a + // Destination that requires deposit authorization + string credential = new string('A', 64); + VaultWithdraw withdraw = new VaultWithdraw + { + Account = Account1, + VaultID = new string('E', 64), + Amount = new Currency { ValueAsXrp = 1m }, + Destination = Account2, + CredentialIDs = new List { credential }, + Sequence = 1, + Fee = new Currency { Value = "12" }, + SigningPublicKey = "", + }; + JsonObject json = JsonNode.Parse(withdraw.ToJson())!.AsObject(); + JsonObject decoded = XrplBinaryCodec.Decode(XrplBinaryCodec.Encode(json)).AsObject(); + Assert.AreEqual(credential, decoded["CredentialIDs"]![0]!.GetValue()); + + Assert.IsTrue(TxFormat.Formats[BinaryCodec.Types.TransactionType.VaultWithdraw] + .ContainsKey(BinaryCodec.Enums.Field.CredentialIDs)); + Assert.IsTrue(TxFormat.Formats[BinaryCodec.Types.TransactionType.LoanBrokerCoverWithdraw] + .ContainsKey(BinaryCodec.Enums.Field.CredentialIDs)); + } + + [TestMethod] + public async Task TestUWithdraws_CredentialIDs_Validated() + { + Dictionary tx = new() + { + ["TransactionType"] = "VaultWithdraw", + ["Account"] = Account1, + ["VaultID"] = new string('E', 64), + ["Amount"] = "1000000", + ["CredentialIDs"] = new List { "not-a-hash" }, + }; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultWithdraw(tx)); + + tx["CredentialIDs"] = new List { new string('A', 64) }; + await Validation.ValidateVaultWithdraw(tx); + + Dictionary cover = new() + { + ["TransactionType"] = "LoanBrokerCoverWithdraw", + ["Account"] = Account1, + ["LoanBrokerID"] = new string('E', 64), + ["Amount"] = "1000000", + ["CredentialIDs"] = new List { "not-a-hash" }, + }; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateLoanBrokerCoverWithdraw(cover)); + + cover["CredentialIDs"] = new List { new string('A', 64) }; + await Validation.ValidateLoanBrokerCoverWithdraw(cover); + } } } diff --git a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs index 192b90f4..99fbd8a3 100644 --- a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs +++ b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs @@ -191,6 +191,21 @@ public class LOMPTokenIssuance : BaseLedgerEntry [JsonPropertyName("AuditorEncryptionKey")] public string? AuditorEncryptionKey { get; init; } + /// + /// ConfidentialMPTKeyRotation: how many times has been replaced + /// through MPTokenIssuanceSet. Absent (0) until the first rotation, including on issuances whose + /// key was registered before the amendment. + /// + [JsonPropertyName("IssuerKeyEpoch")] + public uint? IssuerKeyEpoch { get; init; } + + /// + /// ConfidentialMPTKeyRotation: how many times has been replaced + /// through MPTokenIssuanceSet. Absent (0) until the first rotation. + /// + [JsonPropertyName("AuditorKeyEpoch")] + public uint? AuditorKeyEpoch { get; init; } + /// /// ConfidentialTransfer: total amount held in confidential balances. /// UInt64 (0 .. 2^63-1) diff --git a/Xrpl/Models/Ledger/LOVault.cs b/Xrpl/Models/Ledger/LOVault.cs index 3742df2d..db770673 100644 --- a/Xrpl/Models/Ledger/LOVault.cs +++ b/Xrpl/Models/Ledger/LOVault.cs @@ -45,6 +45,31 @@ public enum VaultVersion : uint CashBasis = 1, } +/// +/// Values of the VaultKind field (rippled VaultKind, LendingProtocolV1_1, rippled #7921). +/// +/// +/// and stay +/// plain uint?, matching the other UInt8 fields; these constants name the values the protocol +/// defines so far. +/// +public enum VaultKind : uint +{ + /// + /// Deposits and withdrawals at any time. A vault with no VaultKind at all is open-ended, whether it + /// was created before the amendment or after it. + /// + OpenEnded = 0, + + /// + /// Three phases fixed at creation: subscription up to , + /// investment up to , redemption afterwards. Deposits are + /// accepted in the subscription phase only (tecEXPIRED later); withdrawals are refused during + /// the investment phase (tecTOO_SOON) and accepted in the other two. + /// + ClosedEnded = 1, +} + /// /// Recommended structure for the Vault Data field. /// The JSON is whitespace-removed and hex-encoded (max 256 bytes). @@ -188,6 +213,28 @@ public class LOVault : BaseLedgerEntry [JsonPropertyName("LEVersion")] public uint? LEVersion { get; init; } + /// + /// LendingProtocolV1_1: the kind of vault (UInt8), see . + /// Absent on open-ended vaults, which rippled resolves as + /// (0). + /// + [JsonPropertyName("VaultKind")] + public uint? VaultKind { get; init; } + + /// + /// LendingProtocolV1_1: the end of a closed-ended vault's subscription phase. Absent on open-ended vaults. + /// + [JsonPropertyName("SubscriptionDate")] + [JsonConverter(typeof(RippleDateTimeConverter))] + public DateTime? SubscriptionDate { get; init; } + + /// + /// LendingProtocolV1_1: the start of a closed-ended vault's redemption phase. Absent on open-ended vaults. + /// + [JsonPropertyName("RedemptionDate")] + [JsonConverter(typeof(RippleDateTimeConverter))] + public DateTime? RedemptionDate { get; init; } + /// /// Arbitrary hex-encoded data associated with the vault, limited to 256 bytes. /// Use for a human-readable representation. diff --git a/Xrpl/Models/Transactions/LoanBrokerCoverWithdraw.cs b/Xrpl/Models/Transactions/LoanBrokerCoverWithdraw.cs index 100793e5..896fdbf3 100644 --- a/Xrpl/Models/Transactions/LoanBrokerCoverWithdraw.cs +++ b/Xrpl/Models/Transactions/LoanBrokerCoverWithdraw.cs @@ -33,6 +33,13 @@ public interface ILoanBrokerCoverWithdraw : ITransactionCommon /// An arbitrary tag to identify the destination. Optional. /// uint? DestinationTag { get; set; } + + /// + /// Credentials (object IDs, 64 hex characters each) authorizing the withdrawal when the + /// requires deposit authorization with credential-based preauth + /// (XLS-70). Maximum 8 entries. + /// + List CredentialIDs { get; set; } } /// @@ -59,6 +66,11 @@ public LoanBrokerCoverWithdraw() /// [JsonPropertyName("DestinationTag")] public uint? DestinationTag { get; set; } + + /// + [JsonPropertyName("CredentialIDs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List CredentialIDs { get; set; } } /// @@ -80,6 +92,11 @@ public class LoanBrokerCoverWithdrawResponse : TransactionResponse, ILoanBrokerC /// [JsonPropertyName("DestinationTag")] public uint? DestinationTag { get; set; } + + /// + [JsonPropertyName("CredentialIDs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List CredentialIDs { get; set; } } public partial class Validation @@ -93,6 +110,11 @@ public static async Task ValidateLoanBrokerCoverWithdraw(DictionaryPermissionedDomains: domain restricting who may hold this MPT. public string DomainID { get; set; } - /// ConfidentialTransfer: issuer ElGamal encryption public key (hex). + /// + /// ConfidentialTransfer: issuer ElGamal encryption public key (hex). Registered once; with + /// ConfidentialMPTKeyRotation active a different key replaces the current one and increments + /// the issuance's IssuerKeyEpoch, while the current key is refused (tecDUPLICATE). + /// public string IssuerEncryptionKey { get; set; } - /// ConfidentialTransfer: auditor ElGamal encryption public key (hex). + /// + /// ConfidentialTransfer: auditor ElGamal encryption public key (hex). Rotates the same way as + /// (AuditorKeyEpoch). Before the amendment it could only + /// be registered together with the issuer key; with the amendment active it can also be added later. + /// public string AuditorEncryptionKey { get; set; } } diff --git a/Xrpl/Models/Transactions/TxFormat.cs b/Xrpl/Models/Transactions/TxFormat.cs index 0e1cd8b1..7bf41aca 100644 --- a/Xrpl/Models/Transactions/TxFormat.cs +++ b/Xrpl/Models/Transactions/TxFormat.cs @@ -502,6 +502,9 @@ static TxFormat() [Field.Scale] = Requirement.Optional, [Field.Data] = Requirement.Optional, [Field.DomainID] = Requirement.Optional, + [Field.VaultKind] = Requirement.Optional, + [Field.SubscriptionDate] = Requirement.Optional, + [Field.RedemptionDate] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.VaultSet] = new TxFormat { @@ -526,6 +529,7 @@ static TxFormat() [Field.Amount] = Requirement.Required, [Field.Destination] = Requirement.Optional, [Field.DestinationTag] = Requirement.Optional, + [Field.CredentialIDs] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.VaultClawback] = new TxFormat { @@ -560,6 +564,7 @@ static TxFormat() [Field.Amount] = Requirement.Required, [Field.Destination] = Requirement.Optional, [Field.DestinationTag] = Requirement.Optional, + [Field.CredentialIDs] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.LoanBrokerCoverClawback] = new TxFormat { diff --git a/Xrpl/Models/Transactions/VaultCreate.cs b/Xrpl/Models/Transactions/VaultCreate.cs index 61f3f05c..4434f7d1 100644 --- a/Xrpl/Models/Transactions/VaultCreate.cs +++ b/Xrpl/Models/Transactions/VaultCreate.cs @@ -77,6 +77,27 @@ public interface IVaultCreate : ITransactionCommon /// The ID of a permissioned domain to associate with the vault. /// string DomainID { get; set; } + + /// + /// LendingProtocolV1_1: the kind of vault, see . + /// Absent means open-ended. requires + /// both and ; an open-ended vault + /// may carry neither. + /// + uint? VaultKind { get; set; } + + /// + /// LendingProtocolV1_1: the end of a closed-ended vault's subscription phase, after which + /// its investment phase begins. Fixed at creation. Serialized as seconds since the Ripple Epoch. + /// + DateTime? SubscriptionDate { get; set; } + + /// + /// LendingProtocolV1_1: the start of a closed-ended vault's redemption phase. Fixed at creation, + /// and must lie at least one minute and less than thirty years after . + /// Serialized as seconds since the Ripple Epoch. + /// + DateTime? RedemptionDate { get; set; } } /// @@ -119,6 +140,20 @@ public VaultCreate() /// [JsonPropertyName("DomainID")] public string DomainID { get; set; } + + /// + [JsonPropertyName("VaultKind")] + public uint? VaultKind { get; set; } + + /// + [JsonPropertyName("SubscriptionDate")] + [JsonConverter(typeof(RippleDateTimeConverter))] + public DateTime? SubscriptionDate { get; set; } + + /// + [JsonPropertyName("RedemptionDate")] + [JsonConverter(typeof(RippleDateTimeConverter))] + public DateTime? RedemptionDate { get; set; } } /// @@ -156,16 +191,72 @@ public class VaultCreateResponse : TransactionResponse, IVaultCreate /// [JsonPropertyName("DomainID")] public string DomainID { get; set; } + + /// + [JsonPropertyName("VaultKind")] + public uint? VaultKind { get; set; } + + /// + [JsonPropertyName("SubscriptionDate")] + [JsonConverter(typeof(RippleDateTimeConverter))] + public DateTime? SubscriptionDate { get; set; } + + /// + [JsonPropertyName("RedemptionDate")] + [JsonConverter(typeof(RippleDateTimeConverter))] + public DateTime? RedemptionDate { get; set; } } public partial class Validation { + /// + /// rippled kMinInvestmentPeriod: the smallest gap between SubscriptionDate and RedemptionDate. + /// + private const long MinInvestmentPeriodSeconds = 60; + + /// + /// rippled kMaxInvestmentPeriod: thirty Gregorian years; the gap must stay below it. + /// + private const long MaxInvestmentPeriodSeconds = 946708560; + public static async Task ValidateVaultCreate(Dictionary tx) { await Common.ValidateBaseTransaction(tx); if (!tx.TryGetValue("Asset", out var asset) || asset is null) throw new ValidationException("VaultCreate: missing field Asset"); + + // rippled VaultCreate::preflight, closed-ended vaults (LendingProtocolV1_1) + bool closedEnded = false; + if (tx.TryGetValue("VaultKind", out var vaultKind) && vaultKind is not null) + { + if (!Common.TryGetUInt32(vaultKind, out uint kind)) + throw new ValidationException("VaultCreate: VaultKind must be a number"); + + if (kind != (uint)Ledger.VaultKind.OpenEnded && kind != (uint)Ledger.VaultKind.ClosedEnded) + throw new ValidationException("VaultCreate: VaultKind must be 0 (open-ended) or 1 (closed-ended)"); + + closedEnded = kind == (uint)Ledger.VaultKind.ClosedEnded; + } + + bool hasSubscription = tx.TryGetValue("SubscriptionDate", out var subscription) && subscription is not null; + bool hasRedemption = tx.TryGetValue("RedemptionDate", out var redemption) && redemption is not null; + + if (!closedEnded && (hasSubscription || hasRedemption)) + throw new ValidationException("VaultCreate: SubscriptionDate and RedemptionDate are only allowed on a closed-ended vault"); + + if (!closedEnded) + return; + + if (!hasSubscription || !hasRedemption) + throw new ValidationException("VaultCreate: a closed-ended vault requires both SubscriptionDate and RedemptionDate"); + + if (!Common.TryGetUInt32(subscription, out uint subscriptionDate) || !Common.TryGetUInt32(redemption, out uint redemptionDate)) + throw new ValidationException("VaultCreate: SubscriptionDate and RedemptionDate must be numbers (seconds since the Ripple Epoch)"); + + long gap = (long)redemptionDate - subscriptionDate; + if (gap < MinInvestmentPeriodSeconds || gap >= MaxInvestmentPeriodSeconds) + throw new ValidationException("VaultCreate: RedemptionDate must be at least one minute and less than thirty years after SubscriptionDate"); } } } diff --git a/Xrpl/Models/Transactions/VaultWithdraw.cs b/Xrpl/Models/Transactions/VaultWithdraw.cs index dfb3df93..662d23bf 100644 --- a/Xrpl/Models/Transactions/VaultWithdraw.cs +++ b/Xrpl/Models/Transactions/VaultWithdraw.cs @@ -36,6 +36,13 @@ public interface IVaultWithdraw : ITransactionCommon /// Arbitrary tag identifying the reason for the withdrawal to the destination. /// uint? DestinationTag { get; set; } + + /// + /// Credentials (object IDs, 64 hex characters each) authorizing the withdrawal when the + /// requires deposit authorization with credential-based preauth + /// (XLS-70). Maximum 8 entries. + /// + List CredentialIDs { get; set; } } /// @@ -62,6 +69,11 @@ public VaultWithdraw() /// [JsonPropertyName("DestinationTag")] public uint? DestinationTag { get; set; } + + /// + [JsonPropertyName("CredentialIDs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List CredentialIDs { get; set; } } /// @@ -83,6 +95,11 @@ public class VaultWithdrawResponse : TransactionResponse, IVaultWithdraw /// [JsonPropertyName("DestinationTag")] public uint? DestinationTag { get; set; } + + /// + [JsonPropertyName("CredentialIDs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List CredentialIDs { get; set; } } public partial class Validation @@ -97,6 +114,11 @@ public static async Task ValidateVaultWithdraw(Dictionary tx) if (!tx.TryGetValue("Amount", out var amount) || amount is null) throw new ValidationException("VaultWithdraw: missing field Amount"); + + if (tx.TryGetValue("CredentialIDs", out var credentialIds) && credentialIds is not null) + { + CredentialsValidator.ValidateCredentialsList(credentialIds, "VaultWithdraw", "CredentialIDs", isStringID: true); + } } /// From 974dfad692449a0b694d48b897603ccc9a3fc8a0 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 11:03:33 -0300 Subject: [PATCH 02/11] build(binarycodec): version 11.4.0.0, numbered with Xrpl when both packages move --- Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj | 2 +- CHANGES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj index 4ac78134..61d1e738 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 - 11.1.0.0 + 11.4.0.0 diff --git a/CHANGES.md b/CHANGES.md index 99342a67..3be9b572 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,7 +20,7 @@ * **confidential MPT key rotation** (rippled #7915, ConfidentialMPTKeyRotation): `LOMPTokenIssuance` carries `IssuerKeyEpoch` and `AuditorKeyEpoch`, incremented each time `MPTokenIssuanceSet` replaces the key. The transaction is unchanged - the same `IssuerEncryptionKey`/`AuditorEncryptionKey` fields rotate a key once the amendment is active, and the current key is refused with `tecDUPLICATE`. `IssuerKeyMirrorEpoch`, `AuditorKeyMirrorEpoch` and `ContractResult` (rippled #7988) are known to the codec but belong to no format yet * `VaultWithdraw` and `LoanBrokerCoverWithdraw` accept `CredentialIDs`, for a `Destination` that requires deposit authorization; validated the way `Payment.CredentialIDs` is * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do - * `Xrpl.BinaryCodec` 11.1.0.0 for the new codec entries. The CI stand (3.3.0) knows none of the new fields, so they are covered by round-trip and validation unit tests rather than integration tests; the nightly stand after #182 has every amendment involved enabled at genesis + * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so they are covered by round-trip and validation unit tests rather than integration tests; the nightly stand after #182 has every amendment involved enabled at genesis ## 11.3.2.0 06/09/2026 From 2f51570e4d455989cef509fd24a863119f4bc464 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 11:39:27 -0300 Subject: [PATCH 03/11] test(vault): drive the closed-ended vault and CredentialIDs against the nightly stand TestIClosedEndedVault, gated on LendingProtocolV1_1: a closed-ended vault through its three phases (deposit in subscription; tecEXPIRED / tecTOO_SOON in investment; withdrawal in redemption), an open-ended vault carrying no VaultKind on the ledger, and a VaultWithdraw to a deposit-authorized destination that is tecNO_PERMISSION without CredentialIDs and succeeds with them. The run caught a wrong constant: kMinInvestmentPeriod is 180 s since rippled #8151, not the 60 s of the original #7921. ValidateVaultCreate, its unit test and the docs now say three minutes. Found on the same stand and left for a follow-up: under LendingProtocolV1_1 LoanBrokerSet refuses an open-ended vault, and the whole Loan integration suite builds on open-ended ones - 18/18 of TestILoan fail there, identically on untouched dev. The CI stand (3.3.0) is unaffected. Verified on xrpld 3.4.0-rc1 (the #182 pin): TestIClosedEndedVault 3/3, TestIVault and TestICredential 25/25; TestU 1290/1290. --- CHANGES.md | 5 +- CLAUDE.md | 2 +- DocFx/Vault-Guide.md | 2 +- DocFx/Vault-Guide.ru.md | 2 +- .../Xrpl.Tests/Integration/AmendmentGuard.cs | 3 + Tests/Xrpl.Tests/Integration/README.md | 4 +- .../transactions/TestIClosedEndedVault.cs | 317 ++++++++++++++++++ .../Models/TestUProtocolCompleteness.cs | 6 +- Xrpl/Models/Transactions/VaultCreate.cs | 8 +- 9 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs diff --git a/CHANGES.md b/CHANGES.md index 3be9b572..427f72a4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,11 +16,12 @@ * pinned by tests that issue the second operation from a callback the first one runs, which lands it inside the first one's yields every time: a `Disconnect()` and a second `ChangeServer` from the session-ended handler of a `ChangeServer`, a `ChangeServer` from the `RestoringConnection` notification of the fast reconnect, a `Disconnect()` from the `OnConnected` handler, a `Connect()` after a `Disconnect()` against a server that comes up later, and a server that closes each connection the moment its handshake completes, so the reconnect loop's success and the close it has to survive arrive together * **The protocol schema follows rippled develop at `e3c8996e`, the 3.4.0-rc1 build the nightly stand is pinned to** (#182 bumps the pin). `definitions.json` had been synced for 3.3.0 and was eight fields behind develop, which definitions-watch had reported as node-only for three weeks. The nightly-pin bump that would have listed them opened with an empty "definitions.json vs the new build" section: the step inherits `bash -e` from the runner, and the diff exits 1 whenever it finds drift, so errexit ended the step at the assignment - before the report was echoed or recorded - in the one case the step exists for. Fixed alongside. - * **closed-ended vaults** (rippled #7921, LendingProtocolV1_1): `VaultCreate` and `LOVault` carry `VaultKind`, `SubscriptionDate` and `RedemptionDate`, and the `VaultKind` enum names the two kinds. `ValidateVaultCreate` pins rippled's preflight: the dates only on a closed-ended vault, both of them, with the redemption at least one minute and less than thirty years after the subscription. Deposits are accepted in the subscription phase only, withdrawals in every phase but investment + * **closed-ended vaults** (rippled #7921, LendingProtocolV1_1): `VaultCreate` and `LOVault` carry `VaultKind`, `SubscriptionDate` and `RedemptionDate`, and the `VaultKind` enum names the two kinds. `ValidateVaultCreate` pins rippled's preflight: the dates only on a closed-ended vault, both of them, with the redemption at least three minutes and less than thirty years after the subscription (rippled #8151 raised the floor from one minute; caught by running the closed-ended flow on the nightly stand). Deposits are accepted in the subscription phase only, withdrawals in every phase but investment * **confidential MPT key rotation** (rippled #7915, ConfidentialMPTKeyRotation): `LOMPTokenIssuance` carries `IssuerKeyEpoch` and `AuditorKeyEpoch`, incremented each time `MPTokenIssuanceSet` replaces the key. The transaction is unchanged - the same `IssuerEncryptionKey`/`AuditorEncryptionKey` fields rotate a key once the amendment is active, and the current key is refused with `tecDUPLICATE`. `IssuerKeyMirrorEpoch`, `AuditorKeyMirrorEpoch` and `ContractResult` (rippled #7988) are known to the codec but belong to no format yet * `VaultWithdraw` and `LoanBrokerCoverWithdraw` accept `CredentialIDs`, for a `Destination` that requires deposit authorization; validated the way `Payment.CredentialIDs` is * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do - * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so they are covered by round-trip and validation unit tests rather than integration tests; the nightly stand after #182 has every amendment involved enabled at genesis + * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and `TestIClosedEndedVault` drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no `VaultKind` on the ledger, and a `VaultWithdraw` to a deposit-authorized destination that is `tecNO_PERMISSION` without `CredentialIDs` and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know + * found on that stand, not fixed here: under LendingProtocolV1_1 `LoanBrokerSet` refuses an open-ended vault (`tecNO_PERMISSION`, "LoanBroker requires a closed-ended Vault"), and every Loan integration test builds its broker on an open-ended one - 18 of 18 in `TestILoan` fail on the nightly stand, identically on untouched `dev`. The CI stand (3.3.0) is unaffected. Before the CI stand moves to a release that carries the amendment, `TestILoanBase` has to create closed-ended vaults and place the loan flow in the investment phase ## 11.3.2.0 06/09/2026 diff --git a/CLAUDE.md b/CLAUDE.md index b6c6792d..fa6b1019 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ Some amendments (e.g. `BatchV1_1`, `PermissionDelegationV1_1`) exist only on the docker compose -f .ci-config/docker-compose.ci.yml down docker compose -f .ci-config/docker-compose.batchv11.yml up -d --build -dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestIBatch|TestIDelegateSet" +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestIBatch|TestIDelegateSet|TestIClosedEndedVault" docker compose -f .ci-config/docker-compose.batchv11.yml down ``` diff --git a/DocFx/Vault-Guide.md b/DocFx/Vault-Guide.md index 196f7b49..9a0a72fc 100644 --- a/DocFx/Vault-Guide.md +++ b/DocFx/Vault-Guide.md @@ -67,7 +67,7 @@ A vault is open-ended by default: deposits and withdrawals are accepted at any t | Investment | `RedemptionDate` | refused (`tecEXPIRED`) | refused (`tecTOO_SOON`) | | Redemption | never | refused (`tecEXPIRED`) | accepted | -`VaultKind`, `SubscriptionDate` and `RedemptionDate` are set on `VaultCreate` and cannot be changed afterwards. A closed-ended vault requires both dates, with the redemption at least one minute and less than thirty years after the subscription; an open-ended vault may carry neither. The dates are `DateTime?` on the models and travel as seconds since the Ripple Epoch. +`VaultKind`, `SubscriptionDate` and `RedemptionDate` are set on `VaultCreate` and cannot be changed afterwards. A closed-ended vault requires both dates, with the redemption at least three minutes and less than thirty years after the subscription; an open-ended vault may carry neither. The dates are `DateTime?` on the models and travel as seconds since the Ripple Epoch. ```csharp using Xrpl.Models.Ledger; // VaultKind diff --git a/DocFx/Vault-Guide.ru.md b/DocFx/Vault-Guide.ru.md index 7f596532..91df1c68 100644 --- a/DocFx/Vault-Guide.ru.md +++ b/DocFx/Vault-Guide.ru.md @@ -67,7 +67,7 @@ Vault — это ledger-структура, которая хранит один | Investment | `RedemptionDate` | отклоняется (`tecEXPIRED`) | отклоняется (`tecTOO_SOON`) | | Redemption | никогда | отклоняется (`tecEXPIRED`) | разрешён | -`VaultKind`, `SubscriptionDate` и `RedemptionDate` задаются в `VaultCreate` и позже не меняются. Закрытому vault нужны обе даты, причём redemption не раньше чем через минуту и строго раньше чем через тридцать лет после subscription; открытый vault не может нести ни одной. В моделях даты представлены как `DateTime?`, по сети передаются секундами от Ripple Epoch. +`VaultKind`, `SubscriptionDate` и `RedemptionDate` задаются в `VaultCreate` и позже не меняются. Закрытому vault нужны обе даты, причём redemption не раньше чем через три минуты и строго раньше чем через тридцать лет после subscription; открытый vault не может нести ни одной. В моделях даты представлены как `DateTime?`, по сети передаются секундами от Ripple Epoch. ```csharp using Xrpl.Models.Ledger; // VaultKind diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index f8601156..b2101c93 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -54,6 +54,9 @@ public static class AmendmentGuard /// public const string MPTokensV2 = "BE2D87DF21B690ED1497B593FDC013CC04276302380B1BD50A033DCF8DEFB2EB"; + /// Amendment id of LendingProtocolV1_1 (sha512half of the name): closed-ended vaults. + public const string LendingProtocolV11 = "A360E2BFD775A5B0DCE1C36C16DF31B72735A57584FD163655D2F9564F8E7AC8"; + /// Amendment id of XChainBridge / XLS-38 (sha512half of the name). public const string XChainBridge = "C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C"; diff --git a/Tests/Xrpl.Tests/Integration/README.md b/Tests/Xrpl.Tests/Integration/README.md index ec83ca89..02a6c817 100644 --- a/Tests/Xrpl.Tests/Integration/README.md +++ b/Tests/Xrpl.Tests/Integration/README.md @@ -13,11 +13,11 @@ dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --fil docker compose -f .ci-config/docker-compose.ci.yml down ``` -Amendment-gated classes (`TestIBatch`, `TestIDelegateSet`) are skipped on the release node and need the nightly-develop environment instead: +Amendment-gated classes (`TestIBatch`, `TestIDelegateSet`, `TestIClosedEndedVault`) are skipped on the release node and need the nightly-develop environment instead: ```bash docker compose -f .ci-config/docker-compose.batchv11.yml up -d --build -dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestIBatch|TestIDelegateSet" +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestIBatch|TestIDelegateSet|TestIClosedEndedVault" docker compose -f .ci-config/docker-compose.batchv11.yml down ``` diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs new file mode 100644 index 00000000..8dd0d17c --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +using static Xrpl.Models.Common.Common; +using Xrpl.Sugar; +using Xrpl.Utils.Hashes; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// The fields rippled develop added to the vault transactions after 3.3.0, driven end to end: +/// a closed-ended vault (rippled #7921, LendingProtocolV1_1) through its three phases, and a +/// VaultWithdraw that proves deposit authorization with CredentialIDs. +/// +/// +/// Gated on LendingProtocolV1_1: the release stand (3.3.0) knows none of these fields, and a +/// node that cannot parse a field answers invalidTransaction rather than a result code. +/// Runs on the nightly stand (.ci-config/docker-compose.batchv11.yml) and on devnet. +/// +[TestClass] +[TestCategory("Vault")] +public class TestIClosedEndedVault : TestIVaultBase +{ + private static IXrplClient client; + private static bool lendingProtocolV11Active; + + protected override IXrplClient GetClient() => client; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + lendingProtocolV11Active = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.LendingProtocolV11); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + [TestInitialize] + public void CheckAmendment() + { + if (!lendingProtocolV11Active) + { + Assert.Inconclusive("LendingProtocolV1_1 is not enabled on the test node; the closed-ended vault fields need the nightly stand (.ci-config/docker-compose.batchv11.yml)."); + } + } + + /// + /// Subscription: deposits and the vault's own fields as written. Investment: deposit is + /// tecEXPIRED, withdrawal is tecTOO_SOON. Redemption: withdrawal succeeds. + /// + /// + /// The phase clock is the parent close time of the ledger a transaction applies in, so the + /// marks are placed relative to the validated close time rather than the wall clock, and the + /// gap between them is rippled's minimum (kMinInvestmentPeriod, three minutes since #8151). + /// + [TestMethod] + public async Task TestClosedEndedVault_Phases() + { + XrplWallet wallet = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + + DateTime closeTime = await ValidatedCloseTimeAsync(); + DateTime subscriptionDate = WholeSeconds(closeTime.AddSeconds(20)); + DateTime redemptionDate = subscriptionDate.AddSeconds(180); + + VaultCreate createTx = new VaultCreate + { + Account = wallet.ClassicAddress, + Asset = new IssuedCurrency { Currency = "XRP" }, + VaultKind = (uint)VaultKind.ClosedEnded, + SubscriptionDate = subscriptionDate, + RedemptionDate = redemptionDate, + }; + createTx = await client.Autofill(createTx); + TransactionSummary createResult = await client.SubmitAndWait(createTx, wallet, true); + ValidateResult(createResult); + + string vaultId = GetCreatedObjectId(createResult); + Assert.IsNotNull(vaultId, "VaultID should be present in metadata"); + + LOVault vault = await ReadVaultAsync(vaultId); + Assert.AreEqual((uint)VaultKind.ClosedEnded, vault.VaultKind, "VaultKind on the ledger object"); + Assert.AreEqual(subscriptionDate, vault.SubscriptionDate, "SubscriptionDate on the ledger object"); + Assert.AreEqual(redemptionDate, vault.RedemptionDate, "RedemptionDate on the ledger object"); + + // Subscription phase: deposits are accepted + ValidateResult(await SubmitAsync(Deposit(wallet, vaultId), wallet)); + + // Investment phase: neither deposits nor withdrawals + await WaitForCloseTimeAsync(subscriptionDate); + await AssertResultAsync("tecEXPIRED", () => SubmitAsync(Deposit(wallet, vaultId), wallet)); + await AssertResultAsync("tecTOO_SOON", () => SubmitAsync(Withdraw(wallet, vaultId), wallet)); + + // Redemption phase: withdrawals are accepted + await WaitForCloseTimeAsync(redemptionDate); + ValidateResult(await SubmitAsync(Withdraw(wallet, vaultId), wallet)); + } + + /// + /// An open-ended vault created after the amendment carries no VaultKind at all: the field is + /// SoeDefault on the ledger object, and the model reads that absence as null. + /// + [TestMethod] + public async Task TestOpenEndedVault_CarriesNoVaultKind() + { + XrplWallet wallet = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + + VaultCreate createTx = new VaultCreate + { + Account = wallet.ClassicAddress, + Asset = new IssuedCurrency { Currency = "XRP" }, + VaultKind = (uint)VaultKind.OpenEnded, + }; + createTx = await client.Autofill(createTx); + TransactionSummary createResult = await client.SubmitAndWait(createTx, wallet, true); + ValidateResult(createResult); + + LOVault vault = await ReadVaultAsync(GetCreatedObjectId(createResult)); + Assert.IsNull(vault.VaultKind, "an open-ended vault has no VaultKind field"); + Assert.IsNull(vault.SubscriptionDate); + Assert.IsNull(vault.RedemptionDate); + } + + /// + /// XLS-70 on a vault withdrawal: the destination requires deposit authorization through a + /// credential, the depositor withdraws to it with CredentialIDs. Without the field the + /// same withdrawal is tecNO_PERMISSION, which is what proves the field reached the node. + /// + [TestMethod] + public async Task TestVaultWithdraw_WithCredentialIDs() + { + XrplWallet walletIssuer = XrplWallet.Generate(); + XrplWallet walletDepositor = XrplWallet.Generate(); + XrplWallet walletRecipient = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, walletIssuer, walletDepositor, walletRecipient); + + string credentialType = ToHex("vault_withdraw_xls70"); + await CreateAndAcceptCredentialAsync(walletIssuer, walletDepositor, credentialType); + + AccountSet enableDepositAuth = new AccountSet + { + Account = walletRecipient.ClassicAddress, + SetFlag = AccountSetAsfFlags.asfDepositAuth, + }; + ValidateResult(await SubmitAsync(enableDepositAuth, walletRecipient)); + + DepositPreauth preauth = new DepositPreauth + { + Account = walletRecipient.ClassicAddress, + AuthorizeCredentials = new List + { + new AuthorizeCredentialEntry + { + Credential = new AuthorizeCredentialBody + { + Issuer = walletIssuer.ClassicAddress, + CredentialType = credentialType, + }, + }, + }, + }; + ValidateResult(await SubmitAsync(preauth, walletRecipient)); + + VaultCreate createTx = new VaultCreate + { + Account = walletDepositor.ClassicAddress, + Asset = new IssuedCurrency { Currency = "XRP" }, + }; + TransactionSummary createResult = await SubmitAsync(createTx, walletDepositor); + ValidateResult(createResult); + string vaultId = GetCreatedObjectId(createResult); + + ValidateResult(await SubmitAsync(Deposit(walletDepositor, vaultId, "3000000"), walletDepositor)); + + // The recipient is behind deposit authorization: no credential, no withdrawal to it + await AssertResultAsync("tecNO_PERMISSION", () => SubmitAsync( + Withdraw(walletDepositor, vaultId, walletRecipient.ClassicAddress), walletDepositor)); + + string credentialId = Hashes.HashCredential( + walletDepositor.ClassicAddress, + walletIssuer.ClassicAddress, + credentialType); + + VaultWithdraw withdraw = Withdraw(walletDepositor, vaultId, walletRecipient.ClassicAddress); + withdraw.CredentialIDs = new List { credentialId }; + ValidateResult(await SubmitAsync(withdraw, walletDepositor)); + } + + private static VaultDeposit Deposit(XrplWallet wallet, string vaultId, string drops = "1000000") => new VaultDeposit + { + Account = wallet.ClassicAddress, + VaultID = vaultId, + Amount = new Currency { Value = drops, CurrencyCode = "XRP" }, + }; + + private static VaultWithdraw Withdraw(XrplWallet wallet, string vaultId, string destination = null) => new VaultWithdraw + { + Account = wallet.ClassicAddress, + VaultID = vaultId, + Amount = new Currency { Value = "1000000", CurrencyCode = "XRP" }, + Destination = destination, + }; + + private static async Task SubmitAsync(T tx, XrplWallet wallet) where T : TransactionRequest + { + tx = await client.Autofill(tx); + return await client.SubmitAndWait(tx, wallet, true); + } + + /// SubmitAndWait throws for tec codes; the code is in the message. + private static async Task AssertResultAsync(string expected, Func> submit) + { + try + { + await submit(); + Assert.Fail($"Expected {expected}, the transaction succeeded"); + } + catch (RippleException ex) + { + Assert.IsTrue(ex.Message.Contains(expected, StringComparison.Ordinal), $"Expected {expected} but got: {ex.Message}"); + } + } + + private static async Task ReadVaultAsync(string vaultId) + { + LedgerEntryResponse entryResponse = await client.LedgerEntry(new LedgerEntryRequest { Index = vaultId }).Typed(); + Assert.IsInstanceOfType(entryResponse?.Node, typeof(LOVault), "ledger_entry should deserialize to LOVault"); + return (LOVault)entryResponse.Node; + } + + private static async Task CreateAndAcceptCredentialAsync(XrplWallet issuer, XrplWallet subject, string credentialType) + { + CredentialCreate create = new CredentialCreate + { + Account = issuer.ClassicAddress, + Subject = subject.ClassicAddress, + CredentialType = credentialType, + }; + ValidateResult(await SubmitAsync(create, issuer)); + + CredentialAccept accept = new CredentialAccept + { + Account = subject.ClassicAddress, + Issuer = issuer.ClassicAddress, + CredentialType = credentialType, + }; + ValidateResult(await SubmitAsync(accept, subject)); + } + + private static string ToHex(string text) => Convert.ToHexString(Encoding.UTF8.GetBytes(text)); + + /// The wire carries whole seconds; a mark with sub-second ticks would never read back equal. + private static DateTime WholeSeconds(DateTime value) => + new DateTime(value.Ticks - value.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc); + + private static async Task ValidatedCloseTimeAsync() + { + LOLedger ledger = await client.Ledger(new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }).Typed(); + LedgerEntity entity = (LedgerEntity)ledger.LedgerEntity; + return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); + } + + /// + /// Waits until the validated close time is strictly past : the phase + /// boundaries are inclusive on the earlier side (a close time equal to SubscriptionDate is + /// still the subscription phase), and the next transaction applies against that close time. + /// + private static async Task WaitForCloseTimeAsync(DateTime target) + { + TimeSpan budget = TimeSpan.FromSeconds(240); + System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew(); + + while (true) + { + DateTime lastSeen = await ValidatedCloseTimeAsync(); + if (lastSeen > target) + return; + + if (elapsed.Elapsed >= budget) + { + Assert.Fail( + $"the validated close time did not pass {target:O} within {budget.TotalSeconds:F0}s; " + + $"last seen {lastSeen:O}, short by {(target - lastSeen).TotalSeconds:F1}s"); + } + + await IntegrationTestConfig.LedgerAcceptAsync(client, nodeType); + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + + private static string GetCreatedObjectId(TransactionSummary result) + { + if (result.Meta?.AffectedNodes == null) return null; + + foreach (AffectedNode node in result.Meta.AffectedNodes) + { + if (node.CreatedNode is { } created && created.LedgerEntryType == LedgerEntryType.Vault) + return created.LedgerIndex; + } + return null; + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 6653d053..d00c8526 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -444,11 +444,11 @@ public async Task TestUVaultCreate_ClosedEndedPreflightRules() tx["VaultKind"] = (uint)Xrpl.Models.Ledger.VaultKind.ClosedEnded; await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); - // kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod - tx["RedemptionDate"] = 800000059u; + // kMinInvestmentPeriod (180 s since rippled #8151) <= gap < kMaxInvestmentPeriod + tx["RedemptionDate"] = 800000179u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); - tx["RedemptionDate"] = 800000060u; + tx["RedemptionDate"] = 800000180u; await Validation.ValidateVaultCreate(tx); tx["RedemptionDate"] = 800000000u + 946708560u; diff --git a/Xrpl/Models/Transactions/VaultCreate.cs b/Xrpl/Models/Transactions/VaultCreate.cs index 4434f7d1..7e4b323f 100644 --- a/Xrpl/Models/Transactions/VaultCreate.cs +++ b/Xrpl/Models/Transactions/VaultCreate.cs @@ -94,7 +94,7 @@ public interface IVaultCreate : ITransactionCommon /// /// LendingProtocolV1_1: the start of a closed-ended vault's redemption phase. Fixed at creation, - /// and must lie at least one minute and less than thirty years after . + /// and must lie at least three minutes and less than thirty years after . /// Serialized as seconds since the Ripple Epoch. /// DateTime? RedemptionDate { get; set; } @@ -211,8 +211,10 @@ public partial class Validation { /// /// rippled kMinInvestmentPeriod: the smallest gap between SubscriptionDate and RedemptionDate. + /// Three minutes since rippled #8151, enough to originate a loan on the minimum payment interval + /// plus the redemption buffer; one minute in the original #7921. /// - private const long MinInvestmentPeriodSeconds = 60; + private const long MinInvestmentPeriodSeconds = 180; /// /// rippled kMaxInvestmentPeriod: thirty Gregorian years; the gap must stay below it. @@ -256,7 +258,7 @@ public static async Task ValidateVaultCreate(Dictionary tx) long gap = (long)redemptionDate - subscriptionDate; if (gap < MinInvestmentPeriodSeconds || gap >= MaxInvestmentPeriodSeconds) - throw new ValidationException("VaultCreate: RedemptionDate must be at least one minute and less than thirty years after SubscriptionDate"); + throw new ValidationException("VaultCreate: RedemptionDate must be at least three minutes and less than thirty years after SubscriptionDate"); } } } From 543f881985aad5cd48b6e33b0320f0785b218753 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 14:29:34 -0300 Subject: [PATCH 04/11] test(loan): build broker vaults closed-ended where LendingProtocolV1_1 asks for it Under the amendment LoanBrokerSet::preclaim refuses an open-ended vault ("LoanBroker requires a closed-ended Vault", tecNO_PERMISSION), and every Loan test built its broker on one: 18 of 18 failed on the nightly stand, identically on untouched dev. TestILoanBase now creates a closed-ended vault when the node has the amendment and waits for the investment phase before returning a broker. rippled originates a loan only in that phase, and takes the vault deposit that funds it only in the subscription phase before it, so the two dates are set from the ledger's close time rather than the machine's clock. A node without the amendment does not know the fields at all and answers invalidTransaction, so the open-ended path stays, selected through AmendmentGuard.LendingProtocolV11. The same helper now builds the vault in TestISponsoredVaultLoan, and the close-time helpers move to IntegrationTestConfig, where three classes can share one copy. Verified on both stands. Nightly (xrpld 3.4.0-rc1): TestILoan 7 of 18, up from 0; every remaining failure is "Counterparty: Invalid signature", the role signing prefixes of fixCleanup3_4_0, which is a separate change this branch does not make. CI stand (3.3.0): TestILoan, TestIVault, TestIClosedEndedVault and TestISponsoredVaultLoan pass 38 of 38, 3 skipped by amendment guard. --- CHANGES.md | 4 +- Tests/Xrpl.Tests/Integration/Utils.cs | 58 ++++++++++ .../transactions/TestIClosedEndedVault.cs | 41 +------ .../Integration/transactions/TestILoanBase.cs | 105 ++++++++++++++++-- .../transactions/TestISponsoredVaultLoan.cs | 9 +- 5 files changed, 163 insertions(+), 54 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 427f72a4..ac1e5e65 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -21,7 +21,9 @@ * `VaultWithdraw` and `LoanBrokerCoverWithdraw` accept `CredentialIDs`, for a `Destination` that requires deposit authorization; validated the way `Payment.CredentialIDs` is * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and `TestIClosedEndedVault` drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no `VaultKind` on the ledger, and a `VaultWithdraw` to a deposit-authorized destination that is `tecNO_PERMISSION` without `CredentialIDs` and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know - * found on that stand, not fixed here: under LendingProtocolV1_1 `LoanBrokerSet` refuses an open-ended vault (`tecNO_PERMISSION`, "LoanBroker requires a closed-ended Vault"), and every Loan integration test builds its broker on an open-ended one - 18 of 18 in `TestILoan` fail on the nightly stand, identically on untouched `dev`. The CI stand (3.3.0) is unaffected. Before the CI stand moves to a release that carries the amendment, `TestILoanBase` has to create closed-ended vaults and place the loan flow in the investment phase + * the Loan integration suite follows the amendment too. Under LendingProtocolV1_1 `LoanBrokerSet` refuses an open-ended vault ("LoanBroker requires a closed-ended Vault", `tecNO_PERMISSION`) and every Loan test built its broker on one, so all 18 of `TestILoan` failed on the nightly stand - identically on untouched `dev`, which is what said it was the node's rule rather than a regression. `TestILoanBase` now creates a closed-ended vault where the node asks for one and waits for the investment phase before handing the broker back: rippled originates a loan only there, while the vault deposit that funds it is taken only in the subscription phase before it, and the two dates are measured from the ledger's close time rather than the machine's clock, which on a standalone stand is a different clock. A node without the amendment does not know the fields at all - it answers `invalidTransaction`, not a result code - so the open-ended path stays for it, chosen through `AmendmentGuard` + * what that unblocks, and what it does not: on the nightly stand `TestILoan` goes from 0 of 18 to 7 of 18, and all 11 that still fail report `Counterparty: Invalid signature` - the role signing prefixes `fixCleanup3_4_0` introduces, which this release does not implement. `TestISponsoredVaultLoan` is blocked by the same thing on 3.4.x, at `Sponsor: Invalid signature`. Neither failure is about vaults any more. On the CI stand (3.3.0) the whole affected set passes, 38 of 38, with the three amendment-gated closed-ended tests skipped + * `ValidatedCloseTimeAsync` and `WaitForCloseTimeAsync` moved to `IntegrationTestConfig`: three test classes now need the ledger clock, and each had been carrying its own copy ## 11.3.2.0 06/09/2026 diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 0a338344..2636d239 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -9,6 +9,7 @@ using Xrpl.Client; using Xrpl.Client.Exceptions; using Xrpl.Models.Common; +using Xrpl.Models.Ledger; using Xrpl.Models.Transactions; using Xrpl.Utils.Hashes; using Xrpl.Wallet; @@ -446,6 +447,63 @@ public static async Task LedgerAcceptAsync(IXrplClient client, TestNodeType? nod await client.AnyRequest(request); } + /// + /// Close time of the latest validated ledger - the clock rippled's own time gates read. + /// + /// + /// Wall-clock time is not a substitute. A standalone stand advances its ledger only when + /// something calls ledger_accept, so its close time trails the machine's clock by + /// however long the last ledger stayed open. + /// + public static async Task ValidatedCloseTimeAsync(IXrplClient client) + { + LOLedger ledger = await client.Ledger(new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }).Typed(); + LedgerEntity entity = (LedgerEntity)ledger.LedgerEntity; + return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); + } + + /// + /// Waits until the validated close time is strictly past . + /// + /// + /// Strictly: rippled's time gates are now > mark (after() in View.cpp), so a + /// close time equal to the mark is still too early, and standalone close times move in + /// coarse steps that land on equality readily. + /// + /// Bounded, because a ledger that stops advancing is a node failure, and a test that waits + /// on it forever reports nothing. The failure names the last close time seen and how far + /// short of the mark it was, which separates a stalled node from a mark set too far ahead. + /// + /// + public static async Task WaitForCloseTimeAsync( + IXrplClient client, + DateTime target, + TestNodeType? nodeType = null, + TimeSpan? budget = null) + { + TimeSpan limit = budget ?? TimeSpan.FromSeconds(120); + System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew(); + + while (true) + { + DateTime lastSeen = await ValidatedCloseTimeAsync(client); + if (lastSeen > target) + return; + + if (elapsed.Elapsed >= limit) + { + Assert.Fail( + $"the validated close time did not pass {target:O} within {limit.TotalSeconds:F0}s; " + + $"last seen {lastSeen:O}, short by {(target - lastSeen).TotalSeconds:F1}s"); + } + + // The standalone stand has a sidecar closing a ledger every few seconds, but a + // stand raised without one would never move; on a public network this is a no-op. + await LedgerAcceptAsync(client, nodeType); + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + /// /// Returns true if running on a public network with faucet support. /// diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs index 8dd0d17c..79f28221 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs @@ -73,7 +73,7 @@ public async Task TestClosedEndedVault_Phases() XrplWallet wallet = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); - DateTime closeTime = await ValidatedCloseTimeAsync(); + DateTime closeTime = await IntegrationTestConfig.ValidatedCloseTimeAsync(client); DateTime subscriptionDate = WholeSeconds(closeTime.AddSeconds(20)); DateTime redemptionDate = subscriptionDate.AddSeconds(180); @@ -101,12 +101,12 @@ public async Task TestClosedEndedVault_Phases() ValidateResult(await SubmitAsync(Deposit(wallet, vaultId), wallet)); // Investment phase: neither deposits nor withdrawals - await WaitForCloseTimeAsync(subscriptionDate); + await IntegrationTestConfig.WaitForCloseTimeAsync(client, subscriptionDate, nodeType); await AssertResultAsync("tecEXPIRED", () => SubmitAsync(Deposit(wallet, vaultId), wallet)); await AssertResultAsync("tecTOO_SOON", () => SubmitAsync(Withdraw(wallet, vaultId), wallet)); // Redemption phase: withdrawals are accepted - await WaitForCloseTimeAsync(redemptionDate); + await IntegrationTestConfig.WaitForCloseTimeAsync(client, redemptionDate, nodeType); ValidateResult(await SubmitAsync(Withdraw(wallet, vaultId), wallet)); } @@ -268,41 +268,6 @@ private static async Task CreateAndAcceptCredentialAsync(XrplWallet issuer, Xrpl private static DateTime WholeSeconds(DateTime value) => new DateTime(value.Ticks - value.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc); - private static async Task ValidatedCloseTimeAsync() - { - LOLedger ledger = await client.Ledger(new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }).Typed(); - LedgerEntity entity = (LedgerEntity)ledger.LedgerEntity; - return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); - } - - /// - /// Waits until the validated close time is strictly past : the phase - /// boundaries are inclusive on the earlier side (a close time equal to SubscriptionDate is - /// still the subscription phase), and the next transaction applies against that close time. - /// - private static async Task WaitForCloseTimeAsync(DateTime target) - { - TimeSpan budget = TimeSpan.FromSeconds(240); - System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew(); - - while (true) - { - DateTime lastSeen = await ValidatedCloseTimeAsync(); - if (lastSeen > target) - return; - - if (elapsed.Elapsed >= budget) - { - Assert.Fail( - $"the validated close time did not pass {target:O} within {budget.TotalSeconds:F0}s; " + - $"last seen {lastSeen:O}, short by {(target - lastSeen).TotalSeconds:F1}s"); - } - - await IntegrationTestConfig.LedgerAcceptAsync(client, nodeType); - await Task.Delay(TimeSpan.FromSeconds(2)); - } - } - private static string GetCreatedObjectId(TransactionSummary result) { if (result.Meta?.AffectedNodes == null) return null; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index 311c7572..0a366463 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Nodes; @@ -11,6 +12,7 @@ using Xrpl.Client.Json; using Xrpl.Models; using Xrpl.Models.Common; +using Xrpl.Models.Ledger; using Xrpl.Models.Methods; using Xrpl.Models.Transactions; using Xrpl.Sugar; @@ -68,17 +70,95 @@ protected static string GetCreatedObjectId(TransactionResponse result, LedgerEnt return null; } + #region Closed-ended vaults (LendingProtocolV1_1) + + /// + /// How much of the subscription phase is left once the vault exists. It has to cover the + /// VaultCreate itself and the deposit that follows: rippled accepts a vault deposit in the + /// subscription phase only - tecEXPIRED afterwards - and each of the two waits for a + /// ledger of its own. + /// + private const int SubscriptionWindowSeconds = 30; + + /// + /// Length of the investment phase, where a loan can be originated. + /// + /// + /// LoanSet refuses a loan whose final payment falls within kLoanRedemptionBuffer (60 s) + /// of the vault's redemption date, and rippled's default schedule is one payment 60 s out, so + /// the loan alone asks for 120 s of room. The window is wider so a test can spend time between + /// the broker and the loan, and it stays far below the 30-year ceiling on the phase. + /// + private const int InvestmentWindowSeconds = 900; + + private static bool? closedEndedRequired; + + /// + /// Whether a LoanBroker on this node requires a closed-ended vault. Since LendingProtocolV1_1 + /// LoanBrokerSet::preclaim refuses an open-ended one with tecNO_PERMISSION, + /// because the lending protocol is written around the subscription / investment / redemption + /// phases. A node without the amendment does not know the fields at all and answers + /// invalidTransaction to a transaction carrying them, so the open-ended path has to stay. + /// + protected static async Task ClosedEndedVaultRequiredAsync(IXrplClient client) + { + closedEndedRequired ??= await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.LendingProtocolV11); + return closedEndedRequired.Value; + } + + /// + /// Builds the VaultCreate a LoanBroker can be attached to on this node, with the phase dates + /// measured from the ledger clock rather than the machine's. + /// + protected static async Task BuildBrokerVaultAsync( + IXrplClient client, + string owner, + IssuedCurrency asset) + { + VaultCreate tx = new VaultCreate + { + Account = owner, + Asset = asset, + }; + + if (!await ClosedEndedVaultRequiredAsync(client)) + return tx; + + DateTime subscriptionDate = (await IntegrationTestConfig.ValidatedCloseTimeAsync(client)) + .AddSeconds(SubscriptionWindowSeconds); + + tx.VaultKind = (uint)VaultKind.ClosedEnded; + tx.SubscriptionDate = subscriptionDate; + tx.RedemptionDate = subscriptionDate.AddSeconds(InvestmentWindowSeconds); + return tx; + } + + /// + /// Waits until the vault has left the subscription phase, which is where LoanSet needs it + /// (tecTOO_SOON before, tecEXPIRED once redemption starts). A vault carrying no + /// SubscriptionDate is open-ended and has no phases at all: there is nothing to wait for. + /// + protected static async Task EnterInvestmentPhaseAsync(IXrplClient client, string vaultId) + { + LedgerEntryResponse entry = await client.LedgerEntry(new LedgerEntryRequest { Index = vaultId }).Typed(); + if (entry?.Node is not LOVault vault || vault.SubscriptionDate is not DateTime subscriptionDate) + return; + + await IntegrationTestConfig.WaitForCloseTimeAsync(client, subscriptionDate, nodeType); + } + + #endregion + /// /// Creates a Vault for the given wallet and returns its VaultID from metadata. /// LoanBrokerSet requires an existing Vault owned by the submitting account. /// protected static async Task CreateVaultForBroker(IXrplClient client, XrplWallet wallet) { - VaultCreate vaultTx = new VaultCreate - { - Account = wallet.ClassicAddress, - Asset = new IssuedCurrency { Currency = "XRP" }, - }; + VaultCreate vaultTx = await BuildBrokerVaultAsync( + client, + wallet.ClassicAddress, + new IssuedCurrency { Currency = "XRP" }); vaultTx = await client.Autofill(vaultTx); TransactionSummary vaultResult = await client.SubmitAndWait(vaultTx, wallet, true); ValidateResult(vaultResult); @@ -134,6 +214,10 @@ protected static async Task CreateBroker(IXrplClient client, XrplWallet TransactionSummary coverResult = await client.SubmitAndWait(coverTx, wallet, true); ValidateResult(coverResult); + // The caller's next move is usually a LoanSet, which rippled only originates in the + // investment phase; the deposit above had to happen before it, in the subscription phase. + await EnterInvestmentPhaseAsync(client, vaultId); + return brokerId; } @@ -318,11 +402,10 @@ protected static async Task SubmitSignedLoanSet(IXrplClient ValidateResult(payResult); // 5. Create MPT-backed vault - VaultCreate vaultCreateTx = new VaultCreate - { - Account = issuerWallet.ClassicAddress, - Asset = new IssuedCurrency { MptIssuanceId = issuanceId }, - }; + VaultCreate vaultCreateTx = await BuildBrokerVaultAsync( + client, + issuerWallet.ClassicAddress, + new IssuedCurrency { MptIssuanceId = issuanceId }); vaultCreateTx = await client.Autofill(vaultCreateTx); TransactionSummary vaultCreateResult = await client.SubmitAndWait(vaultCreateTx, issuerWallet, true); ValidateResult(vaultCreateResult); @@ -373,6 +456,8 @@ protected static async Task SubmitSignedLoanSet(IXrplClient TransactionSummary coverResult = await client.SubmitAndWait(coverTx, issuerWallet, true); ValidateResult(coverResult); + await EnterInvestmentPhaseAsync(client, vaultId); + return (brokerId, issuanceId); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs index 58cac9b5..51db4618 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs @@ -336,11 +336,10 @@ await SubmitPlainAsync(new Payment }, issuer, "issue tokens to the broker"); TransactionSummary vaultResult = await client.SubmitAndWait( - await client.Autofill(new VaultCreate - { - Account = broker.ClassicAddress, - Asset = new IssuedCurrency { Currency = CurrencyCode, Issuer = issuer.ClassicAddress }, - }), broker, true); + await client.Autofill(await BuildBrokerVaultAsync( + client, + broker.ClassicAddress, + new IssuedCurrency { Currency = CurrencyCode, Issuer = issuer.ClassicAddress })), broker, true); ValidateResult(vaultResult); string vaultId = GetCreatedObjectId(vaultResult, LedgerEntryType.Vault); Assert.IsNotNull(vaultId, "the VaultCreate must report the new Vault"); From 3dad0953a1d8404ee40a7bac227bb2e7432bfac6 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 17:44:25 -0300 Subject: [PATCH 05/11] feat(signing)!: a sponsor's and a counterparty's signature cover bytes of their own rippled's fixCleanup3_4_0 gives each signing role its own four-byte hash prefix. Before it every signature on a transaction covered the same bytes, so one could be lifted out of SponsorSignature and pasted into CounterpartySignature, or into TxnSignature, and still verify. - HashPrefix gains the four role prefixes; EncodeForSigning and EncodeForMultiSigning gain overloads taking one. The transaction's own prefixes are untouched, so an ordinary signature is byte-for-byte what it was - the role is not something a caller states: it follows from the method, and for a multi-signature entry from the shape of the transaction. The one ambiguous shape, where the main signature and a co-signature are both multi-signed, gets Sign(tx, multisign, signingFor, SignatureRole) - multi-signature entries are no longer portable between sections, so the composer's premise is documented as the pre-amendment rule it was - the older scheme is not carried and nothing asks the node which one it wants: no public network has Sponsor or LendingProtocol without the amendment Breaking against a private node on a release older than the amendment that has Sponsor or LendingProtocol voted in. That is what this repository's CI stand is, so the integration tests producing a role signature now skip there through AmendmentGuard: 346 integration tests passed on that stand before, 286 pass and 63 skip now. All of them run on the nightly stand. Verified. Unit: 1294 pass, including prefixes checked against rippled's own makeHashPrefix tags and a preimage that differs from the transaction's in four bytes and nothing else. Nightly stand (xrpld 3.4.0-rc1): 87 of 87 across TestILoan, TestILoanMultisig, the sponsorship classes, TestIXChainAttestation and the vault classes, where before every role signature was refused with "Invalid signature". CI stand (3.3.0): 286 pass, 63 skip, 0 fail. Two rules of the same amendment surfaced once the tests could reach them and are handled in the tests: a loan may only be impaired once a payment is late, and a payment on an overdue loan must carry tfLoanLatePayment or it is tecEXPIRED. --- Base/Xrpl.BinaryCodec/Hashing/HashPrefix.cs | 28 +++++ Base/Xrpl.BinaryCodec/XrplBinaryCodec.cs | 44 ++++++- CHANGES.md | 10 ++ DocFx/LendingProtocol-Guide.md | 6 +- DocFx/LendingProtocol-Guide.ru.md | 6 +- DocFx/Sponsorship-Guide.md | 4 +- DocFx/Sponsorship-Guide.ru.md | 4 +- .../Xrpl.Tests/Integration/AmendmentGuard.cs | 29 +++++ .../transactions/TestIBatchSponsorship.cs | 5 +- .../Integration/transactions/TestILoanBase.cs | 3 + .../transactions/TestILoanMultisig.cs | 3 + .../transactions/TestISponsoredTypes.cs | 5 +- .../transactions/TestISponsoredVaultLoan.cs | 19 ++- .../transactions/TestISponsorship.cs | 5 +- .../TestISponsorshipSigningMatrix.cs | 12 +- .../transactions/TestIXChainAttestation.cs | 2 + .../Wallet/TestULoanCounterpartyMultisign.cs | 3 +- .../Wallet/TestURoleSigningPrefixes.cs | 104 +++++++++++++++++ .../Wallet/TestUSignatureComposer.cs | 4 +- Tests/Xrpl.Tests/Wallet/TestUSigningPinned.cs | 23 ++-- .../Xrpl.Tests/Wallet/TestUSponsorSigning.cs | 21 ++-- Xrpl/Wallet/CoSigningEngine.cs | 48 ++++++-- Xrpl/Wallet/LoanSigningHelper.cs | 6 +- Xrpl/Wallet/SignatureComposer.cs | 11 +- Xrpl/Wallet/SignatureRole.cs | 31 +++++ Xrpl/Wallet/SponsorSigningHelper.cs | 8 +- Xrpl/Wallet/XrplWallet.cs | 110 ++++++++++++++++-- 27 files changed, 487 insertions(+), 67 deletions(-) create mode 100644 Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs create mode 100644 Xrpl/Wallet/SignatureRole.cs diff --git a/Base/Xrpl.BinaryCodec/Hashing/HashPrefix.cs b/Base/Xrpl.BinaryCodec/Hashing/HashPrefix.cs index 0618d7d9..9e91e458 100644 --- a/Base/Xrpl.BinaryCodec/Hashing/HashPrefix.cs +++ b/Base/Xrpl.BinaryCodec/Hashing/HashPrefix.cs @@ -34,6 +34,34 @@ public enum HashPrefix : uint /// TransactionMultiSig = 0x534D5400u, /// + /// CounterpartyTransactionSig: the preimage a LoanSet counterparty signs into + /// CounterpartySignature (rippled HashPrefix::CounterpartyTxSign). + /// + /// + /// Before fixCleanup3_4_0 every signature on a transaction covered the same bytes, so a + /// signature could be lifted from one role and pasted into another. Since the amendment + /// each role has its own prefix, and the roles are the only thing that changed: + /// and still cover an + /// ordinary TxnSignature, before and after. + /// + CounterpartyTransactionSig = 0x43505400u, + /// + /// CounterpartyTransactionMultiSig: what a signer on the counterparty's SignerList signs, + /// for a CounterpartySignature carrying Signers rather than one signature + /// (rippled HashPrefix::CounterpartyTxMultiSign). + /// + CounterpartyTransactionMultiSig = 0x43504D00u, + /// + /// SponsorTransactionSig: the preimage a sponsor signs into SponsorSignature + /// (rippled HashPrefix::SponsorTxSign). See . + /// + SponsorTransactionSig = 0x53504E00u, + /// + /// SponsorTransactionMultiSig: what a signer on the sponsor's SignerList signs + /// (rippled HashPrefix::SponsorTxMultiSign). + /// + SponsorTransactionMultiSig = 0x53504D00u, + /// /// Validation /// Validation = 0x56414C00u, diff --git a/Base/Xrpl.BinaryCodec/XrplBinaryCodec.cs b/Base/Xrpl.BinaryCodec/XrplBinaryCodec.cs index c64e7661..19f1ac85 100644 --- a/Base/Xrpl.BinaryCodec/XrplBinaryCodec.cs +++ b/Base/Xrpl.BinaryCodec/XrplBinaryCodec.cs @@ -85,9 +85,28 @@ public static string Encode(object json) /// /// string public static string EncodeForSigning(object json) + { + return EncodeForSigning(json, HashPrefix.TransactionSig); + } + + /// + /// Encode a transaction for signing under an explicit prefix, for a signature that is not + /// the transaction's own: for + /// SponsorSignature, for + /// CounterpartySignature. + /// + /// + /// An overload rather than an optional parameter on the method above: a default value is + /// source-compatible but not binary-compatible, and an assembly built against the + /// one-argument signature would call a method that no longer exists. + /// + /// The transaction. + /// Prefix for the signing role. + /// string + public static string EncodeForSigning(object json, HashPrefix prefix) { JsonNode node = ObjectToJsonNode(json); - return SerializeJson(node, HashPrefix.TransactionSig.Bytes(), null, true); + return SerializeJson(node, prefix.Bytes(), null, true); } /// @@ -118,10 +137,31 @@ public static string EncodeForSigningClaim(object obj) /// /// string public static string EncodeForMultiSigning(object json, string signingAccount) + { + return EncodeForMultiSigning(json, signingAccount, HashPrefix.TransactionMultiSig); + } + + /// + /// Encode a transaction for one multi-signature under an explicit prefix, for a signer on + /// a co-signing account's SignerList: + /// for SponsorSignature.Signers, + /// for CounterpartySignature.Signers. + /// + /// + /// Since fixCleanup3_4_0 a Signer entry is no longer section-agnostic: an entry made for + /// the transaction's own Signers covers different bytes than the same entry inside a role + /// section, so the signer has to know which side it signs for. An overload for the same + /// binary-compatibility reason as . + /// + /// The transaction. + /// The account whose key signs this entry. + /// Prefix for the signing role. + /// string + public static string EncodeForMultiSigning(object json, string signingAccount, HashPrefix prefix) { string accountID = new AccountId(signingAccount).ToHex(); JsonNode token = ObjectToJsonNode(json); - return SerializeJson(token, HashPrefix.TransactionMultiSig.Bytes(), accountID.FromHex(), true); + return SerializeJson(token, prefix.Bytes(), accountID.FromHex(), true); } private static JsonNode ObjectToJsonNode(object obj, bool ignoreNull = false) diff --git a/CHANGES.md b/CHANGES.md index ac1e5e65..85590749 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -25,6 +25,16 @@ * what that unblocks, and what it does not: on the nightly stand `TestILoan` goes from 0 of 18 to 7 of 18, and all 11 that still fail report `Counterparty: Invalid signature` - the role signing prefixes `fixCleanup3_4_0` introduces, which this release does not implement. `TestISponsoredVaultLoan` is blocked by the same thing on 3.4.x, at `Sponsor: Invalid signature`. Neither failure is about vaults any more. On the CI stand (3.3.0) the whole affected set passes, 38 of 38, with the three amendment-gated closed-ended tests skipped * `ValidatedCloseTimeAsync` and `WaitForCloseTimeAsync` moved to `IntegrationTestConfig`: three test classes now need the ledger clock, and each had been carrying its own copy +* **A sponsor's and a counterparty's signature cover bytes of their own** (rippled `fixCleanup3_4_0`, **breaking against nodes without the amendment**). Until it, every signature on a transaction covered the same bytes: the submitter's `TxnSignature`, the sponsor's `SponsorSignature` (XLS-68) and the borrower's `CounterpartySignature` (XLS-66) were all made over one preimage, so a signature could be lifted out of one role and pasted into another and still verify. rippled now gives each role its own four-byte hash prefix, and this release signs that way. + * `HashPrefix` gains `CounterpartyTransactionSig`, `CounterpartyTransactionMultiSig`, `SponsorTransactionSig` and `SponsorTransactionMultiSig`, and `EncodeForSigning` / `EncodeForMultiSigning` gain overloads taking one. Overloads rather than an optional parameter, for the binary compatibility reason the SDK has met before. The transaction's own prefixes are untouched, so an ordinary signature - single or multisig, Batch included - is byte-for-byte what it was + * **the role is not something a caller states.** It follows from the method: a wallet named as the `Sponsor` signs as sponsor, a LoanSet `Counterparty` as counterparty, and a multi-signature entry on a transaction whose main signature is single can only belong to the co-signing side. One shape is genuinely ambiguous, where the main signature and a co-signature are both multi-signed, and only there does the signer have to say: `Sign(tx, multisign, signingFor, SignatureRole.Sponsor)`. Asking for it in a shape that does not have it is refused rather than signed wrongly + * **multi-signature entries are no longer portable between sections.** The composer's premise - that `tx.Signers`, `SponsorSignature.Signers` and `CounterpartySignature.Signers` are identical bytes, so routing could be settled at composition time - held only before the amendment. Routing still happens in the composer, by account, but the signer now has to know its side already + * **what breaks:** a signature this release makes is rejected by a node that has `Sponsor` or `LendingProtocol` enabled but not `fixCleanup3_4_0`. No public network is in that state - on mainnet and testnet none of the three is enabled, on devnet all of them are - so the affected combination is a private node on a release older than the amendment. The older scheme is not carried, and nothing asks the node which one it wants: signing stays offline + * the one place the combination does occur is this repository's own CI stand, a 3.3.0 image with `Sponsor` and `LendingProtocol` voted in at genesis, so the integration tests that produce a role signature now skip there through `AmendmentGuard` and run on the nightly stand instead. Sixty of them, measured: the CI stand ran 346 integration tests before this release and runs 286, with 63 skipped - the sixty plus the three closed-ended vault tests this release adds. It is coverage deferred rather than lost, on a stand rather than in the suite, and the guard turns it back on by itself once the CI stand moves to a release carrying the amendment + * pinned by unit tests that check the prefixes against rippled's own `makeHashPrefix` tags and assert that a role preimage is the transaction's preimage with four bytes changed and nothing else - a pinned blob cannot answer that question, since regenerating it from the same code only agrees with itself. What settles it is the node: on the nightly stand `TestILoan` passes 18 of 18 and the sponsorship classes are green, where before this release every one of them was refused with `Invalid signature` + * two rules of the same amendment surfaced once the tests could reach them, and are in the tests rather than in the SDK: a loan may only be impaired once a payment is actually late, and a payment on an overdue loan must carry `tfLoanLatePayment` or it is `tecEXPIRED` + + ## 11.3.2.0 06/09/2026 * **A request issued while the client is switching servers no longer hangs until `RequestTimeout`** (#177). Every path that retires a connection - `ChangeServer`, the ping-triggered fast reconnect, `Disconnect`, `DisconnectAndWaitAsync`, and the path taken when an `OnConnected` handler fails - rejected the pending requests first and cleared the socket reference afterwards. The rejection resumes the consumer, and a consumer that issues its next request from there - the second value of a page load, read from the response handler of the first - found the retired socket still installed, passed the connectivity check on it, and was written into it after the sweep that would have rejected it. Nothing completed it: the sweep had run, and a failed send is report-only. Forty seconds later it timed out, with the connection healthy for thirty-nine of them. diff --git a/DocFx/LendingProtocol-Guide.md b/DocFx/LendingProtocol-Guide.md index 8cf5001d..4101ea16 100644 --- a/DocFx/LendingProtocol-Guide.md +++ b/DocFx/LendingProtocol-Guide.md @@ -57,7 +57,7 @@ A **Loan** is a ledger object representing an active loan between a broker and a ### CounterpartySignature -`LoanSet` is a special transaction that requires **two signatures**: the broker (submitter) signs the transaction normally, and the borrower (counterparty) provides a `CounterpartySignature`. Both parties sign the same transaction preimage. +`LoanSet` is a special transaction that requires **two signatures**: the broker (submitter) signs the transaction normally, and the borrower (counterparty) provides a `CounterpartySignature`. Both sign the same transaction, each under its own hash prefix since rippled's `fixCleanup3_4_0`. ### Number Type @@ -328,7 +328,7 @@ await client.SubmitRequest(fullySigned.TxBlob); ### Multisig Borrower (Counterparty with a SignerList) -`CounterpartySignature` takes the multisig form when the borrower is a multisig account: an empty `SigningPubKey` and a `Signers` array that the node checks against the **counterparty's** SignerList, over the same multisign preimage as `tx.Signers`. Each signer of the borrower's list signs with the standard multisign call; the composer places the entries. +`CounterpartySignature` takes the multisig form when the borrower is a multisig account: an empty `SigningPubKey` and a `Signers` array that the node checks against the **counterparty's** SignerList, under the counterparty multisign prefix, which since `fixCleanup3_4_0` is not the one `tx.Signers` uses. Each signer of the borrower's list signs with the standard multisign call - the SDK sees that the broker signs single and works the side out for itself - and the composer places the entries. ```csharp // The fee covers one base fee per counterparty signer (rippled LoanSet::calculateBaseFee), @@ -359,7 +359,7 @@ A signer that appears in more than one SignerList (the broker's, the sponsor's, ### Key Points -- Both parties sign the **same** preimage (the transaction serialized for signing, without any signature fields) +- Both parties sign the **same transaction** (serialized for signing, without any signature fields), each under the hash prefix of its role - The signing preimage uses the **broker's** `SigningPubKey` (the submitting account) - `CounterpartySignature` is an STObject with `isSigningField = false` — it is excluded from the signing preimage - `Autofill` automatically calculates the correct fee for LoanSet (includes CounterpartySignature overhead) diff --git a/DocFx/LendingProtocol-Guide.ru.md b/DocFx/LendingProtocol-Guide.ru.md index bdb9a333..6ab7eef5 100644 --- a/DocFx/LendingProtocol-Guide.ru.md +++ b/DocFx/LendingProtocol-Guide.ru.md @@ -57,7 +57,7 @@ ### CounterpartySignature -`LoanSet` — особая транзакция, требующая **двух подписей**: брокер (отправитель) подписывает транзакцию обычным способом, а заёмщик (контрагент) предоставляет `CounterpartySignature`. Обе стороны подписывают одинаковый прообраз подписи. +`LoanSet` — особая транзакция, требующая **двух подписей**: брокер (отправитель) подписывает транзакцию обычным способом, а заёмщик (контрагент) предоставляет `CounterpartySignature`. Обе подписывают одну и ту же транзакцию, но каждая под своим hash-префиксом, начиная с `fixCleanup3_4_0` в rippled. ### Тип Number @@ -328,7 +328,7 @@ await client.SubmitRequest(fullySigned.TxBlob); ### Заёмщик с мультиподписью (Counterparty со SignerList) -Когда заёмщик — мультиподписной аккаунт, `CounterpartySignature` принимает мультиподписную форму: пустой `SigningPubKey` и массив `Signers`, который нода сверяет со SignerList **контрагента** по тому же прообразу мультиподписи, что и `tx.Signers`. Каждый подписант из списка заёмщика подписывает стандартным вызовом мультиподписи; композитор расставляет записи по секциям. +Когда заёмщик — мультиподписной аккаунт, `CounterpartySignature` принимает мультиподписную форму: пустой `SigningPubKey` и массив `Signers`, который нода сверяет со SignerList **контрагента** под мультиподписным префиксом контрагента: начиная с `fixCleanup3_4_0` это не тот префикс, что у `tx.Signers`. Каждый подписант из списка заёмщика подписывает стандартным вызовом мультиподписи — SDK видит, что брокер подписывает одиночно, и определяет сторону сам, — а композитор расставляет записи по секциям. ```csharp // Комиссия покрывает по одной базовой комиссии на каждого подписанта контрагента @@ -359,7 +359,7 @@ await client.SubmitRequest(composed.TxBlob); ### Ключевые моменты -- Обе стороны подписывают **одинаковый** прообраз (транзакция, сериализованная для подписи, без полей подписей) +- Обе стороны подписывают **одну и ту же транзакцию** (сериализованную для подписи, без полей подписей), каждая под hash-префиксом своей роли - Прообраз подписи использует `SigningPubKey` **брокера** (отправляющий аккаунт) - `CounterpartySignature` — это STObject с `isSigningField = false` — он исключён из прообраза подписи - `Autofill` автоматически рассчитывает корректную комиссию для LoanSet (включая overhead CounterpartySignature ~150 байт) diff --git a/DocFx/Sponsorship-Guide.md b/DocFx/Sponsorship-Guide.md index 3163c67b..8365ce13 100644 --- a/DocFx/Sponsorship-Guide.md +++ b/DocFx/Sponsorship-Guide.md @@ -46,7 +46,7 @@ Two independent dimensions can be sponsored: | Fees | `SponsorCoverage.spfSponsorFee` (= 1) | The `Fee` of the sponsee's transactions | | Reserves | `SponsorCoverage.spfSponsorReserve` (= 2) | Reserves on behalf of the sponsee: owner reserves of objects it creates **and** account reserves (including sponsored account creation) | -Every transaction type gains three common fields: `Sponsor`, `SponsorFlags`, and (when the sponsorship demands a co-signature) `SponsorSignature` — an inner not-signing STObject over the **same preimage** as the main signature. It comes in two alternative forms: single-signature (`SigningPubKey` + `TxnSignature`) or sponsor multisig (a nested `Signers` array). +Every transaction type gains three common fields: `Sponsor`, `SponsorFlags`, and (when the sponsorship demands a co-signature) `SponsorSignature` — an inner not-signing STObject over the **same transaction as the main signature, under the sponsor's own hash prefix** (rippled `fixCleanup3_4_0`; before it every role signed identical bytes). It comes in two alternative forms: single-signature (`SigningPubKey` + `TxnSignature`) or sponsor multisig (a nested `Signers` array). --- @@ -177,7 +177,7 @@ If the sponsorship does **not** require a co-signature, sign and submit as usual ## Signing Flows (V1/V2/V3) -`SponsorSignature` is signed over the same preimage as the main signature (analogous to the LoanSet counterparty pattern). +`SponsorSignature` covers the same transaction as the main signature but under the sponsor's own hash prefix, which `fixCleanup3_4_0` introduced (analogous to the LoanSet counterparty pattern). The SDK picks the prefix from the role; nothing to pass. ### The simple path — standard Sign/Submit (10.8.0+) diff --git a/DocFx/Sponsorship-Guide.ru.md b/DocFx/Sponsorship-Guide.ru.md index 40fe6a3c..f59928f4 100644 --- a/DocFx/Sponsorship-Guide.ru.md +++ b/DocFx/Sponsorship-Guide.ru.md @@ -46,7 +46,7 @@ | Комиссии | `SponsorCoverage.spfSponsorFee` (= 1) | `Fee` транзакций спонсируемого | | Резервы | `SponsorCoverage.spfSponsorReserve` (= 2) | Резервы за спонсируемого: owner-резервы создаваемых им объектов **и** резервы аккаунтов (включая спонсируемое создание аккаунта) | -У каждого типа транзакций появляются три общих поля: `Sponsor`, `SponsorFlags` и (когда спонсорство этого требует) `SponsorSignature` — вложенный not-signing STObject поверх **того же преимиджа**, что и основная подпись. Допустимы две альтернативные формы: одиночная подпись (`SigningPubKey` + `TxnSignature`) либо мультисиг спонсора (вложенный массив `Signers`). +У каждого типа транзакций появляются три общих поля: `Sponsor`, `SponsorFlags` и (когда спонсорство этого требует) `SponsorSignature` — вложенный not-signing STObject поверх **той же транзакции, что и основная подпись, но под собственным hash-префиксом спонсора** (rippled `fixCleanup3_4_0`; до него все роли подписывали одинаковые байты). Допустимы две альтернативные формы: одиночная подпись (`SigningPubKey` + `TxnSignature`) либо мультисиг спонсора (вложенный массив `Signers`). --- @@ -177,7 +177,7 @@ payment = await client.Autofill(payment); ## Сценарии подписания (V1/V2/V3) -`SponsorSignature` подписывается над тем же преимиджем, что и основная подпись (аналогично counterparty-паттерну LoanSet). +`SponsorSignature` покрывает ту же транзакцию, что и основная подпись, но под собственным hash-префиксом спонсора, введённым `fixCleanup3_4_0` (аналогично counterparty-паттерну LoanSet). Префикс SDK выбирает по роли, передавать ничего не нужно. ### Простой путь — стандартные Sign/Submit (10.8.0+) diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index b2101c93..391bef14 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -2,6 +2,8 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + using Xrpl.Client; using Xrpl.Client.Exceptions; using Xrpl.Models.Methods; @@ -54,12 +56,39 @@ public static class AmendmentGuard /// public const string MPTokensV2 = "BE2D87DF21B690ED1497B593FDC013CC04276302380B1BD50A033DCF8DEFB2EB"; + /// + /// Amendment id of fixCleanup3_4_0 (sha512half of the name): each signing role gets its own + /// hash prefix, so a SponsorSignature or a CounterpartySignature covers different bytes than + /// the transaction's own signature. + /// + public const string FixCleanup340 = "98433DD001A5737F773D74F8CA2A25A065089C73B2E611C760BAF369E4FECA76"; + /// Amendment id of LendingProtocolV1_1 (sha512half of the name): closed-ended vaults. public const string LendingProtocolV11 = "A360E2BFD775A5B0DCE1C36C16DF31B72735A57584FD163655D2F9564F8E7AC8"; /// Amendment id of XChainBridge / XLS-38 (sha512half of the name). public const string XChainBridge = "C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C"; + private static bool? roleSignatures; + + /// + /// Skips the test when the node still verifies role signatures the pre-fixCleanup3_4_0 way. + /// + /// + /// The SDK signs a SponsorSignature and a CounterpartySignature under the role prefixes the + /// amendment introduced, and does not carry the older scheme: no public network can use either + /// field without the amendment, since Sponsor and LendingProtocol are enabled nowhere it is + /// absent. The CI stand is the one place both are true at once - it runs a release build older + /// than the amendment with both features voted in at genesis - so these tests belong to the + /// nightly stand until that release moves on. + /// + public static async Task RequireRoleSignaturesAsync(IXrplClient client) + { + roleSignatures ??= await IsEnabledAsync(client, FixCleanup340); + if (roleSignatures != true) + Assert.Inconclusive("The node verifies a role signature the pre-fixCleanup3_4_0 way, over the transaction's own prefix; the SDK signs under the role prefix the amendment introduced. Run these on a stand carrying fixCleanup3_4_0 (.ci-config/docker-compose.batchv11.yml) or on devnet."); + } + public static async Task IsEnabledAsync(IXrplClient client, string amendmentId) { try diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 6a43903e..1e2af190 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -44,12 +44,15 @@ public static async Task ClassInitializeAsync(TestContext testContext) } [TestInitialize] - public void CheckSponsorAmendment() + public async Task CheckSponsorAmendment() { if (!sponsorAmendmentActive) { Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); } + + await AmendmentGuard.RequireRoleSignaturesAsync(client); + } [ClassCleanup] diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index 0a366463..912a83f7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs @@ -231,6 +231,9 @@ protected static async Task PrepareLoanSet( LoanSet loanTx, XrplWallet brokerWallet) { + // A LoanSet always carries a CounterpartySignature, which is a role signature + await AmendmentGuard.RequireRoleSignaturesAsync(client); + loanTx = await client.Autofill(loanTx); return LoanSigningHelper.PrepareForSigning(loanTx, brokerWallet); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs index e1d8ea77..9c69a544 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs @@ -47,6 +47,9 @@ private sealed record MultisigLoan(XrplWallet Broker, XrplWallet Signer1, XrplWa /// private static async Task SetupAsync() { + // The borrower's entries land in CounterpartySignature.Signers, a role signature + await AmendmentGuard.RequireRoleSignaturesAsync(client); + XrplWallet broker = XrplWallet.Generate(); XrplWallet borrower = XrplWallet.Generate(); XrplWallet signer1 = XrplWallet.Generate(); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs index 759c6cfd..630a7f4a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs @@ -51,12 +51,15 @@ public static async Task ClassInitializeAsync(TestContext testContext) } [TestInitialize] - public void CheckSponsorAmendment() + public async Task CheckSponsorAmendment() { if (!sponsorAmendmentActive) { Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); } + + await AmendmentGuard.RequireRoleSignaturesAsync(client); + } [ClassCleanup] diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs index 51db4618..0a80ab00 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs @@ -12,6 +12,7 @@ using Xrpl.Client.Json; using Xrpl.Models; using Xrpl.Models.Common; +using Xrpl.Models.Ledger; using Xrpl.Models.Methods; using Xrpl.Models.Transactions; using Xrpl.Sugar; @@ -56,12 +57,15 @@ public static async Task ClassInitializeAsync(TestContext testContext) } [TestInitialize] - public void CheckSponsorAmendment() + public async Task CheckSponsorAmendment() { if (!sponsorAmendmentActive) { Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); } + + await AmendmentGuard.RequireRoleSignaturesAsync(client); + } [ClassCleanup] @@ -448,6 +452,14 @@ public async Task Sponsored_Loan_Manage_Pay_Delete() await OpenSponsorshipAsync(sponsor, borrower); await OpenSponsorshipAsync(sponsor, broker); + // Since fixCleanup3_4_0 a loan may only be impaired once a payment is actually late + // ("Cannot impair a loan that is not late", LoanManage::preclaim), which the default + // schedule puts one payment interval after the loan starts. + LedgerEntryResponse loanEntry = await client.LedgerEntry(new LedgerEntryRequest { Index = loanId }).Typed(); + DateTime paymentDue = (loanEntry?.Node as LOLoan)?.NextPaymentDueDate + ?? throw new RippleException("the Loan carries no NextPaymentDueDate"); + await IntegrationTestConfig.WaitForCloseTimeAsync(client, paymentDue, nodeType); + await SubmitSponsoredAsync(new LoanManage { Account = broker.ClassicAddress, @@ -455,12 +467,15 @@ await SubmitSponsoredAsync(new LoanManage Flags = LoanManageFlags.tfLoanImpair, }, broker, sponsor); - // The full principal: anything less is tecINSUFFICIENT_PAYMENT + // The full principal: anything less is tecINSUFFICIENT_PAYMENT. The flag is not optional + // here: the wait above made the payment late on purpose, and rippled refuses an unflagged + // payment on an overdue loan with tecEXPIRED ("Use the tfLoanLatePayment transaction flag"). await SubmitSponsoredAsync(new LoanPay { Account = borrower.ClassicAddress, LoanID = loanId, Amount = new Currency { Value = "10000000", CurrencyCode = "XRP" }, + Flags = LoanPayFlags.tfLoanLatePayment, }, borrower, sponsor); await SubmitSponsoredAsync(new LoanDelete diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs index 6451a7b8..9d70db61 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -33,12 +33,15 @@ public static async Task ClassInitializeAsync(TestContext testContext) } [TestInitialize] - public void CheckSponsorAmendment() + public async Task CheckSponsorAmendment() { if (!sponsorAmendmentActive) { Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node; bump the nightly in .ci-config/Dockerfile.nightly and uncomment Sponsor in rippled.batchv11.cfg to run these tests."); } + + await AmendmentGuard.RequireRoleSignaturesAsync(client); + } [ClassCleanup] diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs index 50e01cee..9699878e 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs @@ -44,12 +44,15 @@ public static async Task ClassInitializeAsync(TestContext testContext) } [TestInitialize] - public void CheckSponsorAmendment() + public async Task CheckSponsorAmendment() { if (!sponsorAmendmentActive) { Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); } + + await AmendmentGuard.RequireRoleSignaturesAsync(client); + } [ClassCleanup] @@ -207,12 +210,15 @@ public async Task Unified_MultisigBothSides_Compose() Dictionary prepared = await PreparedPaymentAsync(sponsee, destination, sponsor, ""); + // The one shape where the transaction cannot say which signature an entry is: the main + // signature is multi-signed too, so an entry could belong to either side. The account's + // signers take the default role, the sponsor's state theirs. string[] parts = { accSigner1.Sign(new Dictionary(prepared), multisign: true).TxBlob, accSigner2.Sign(new Dictionary(prepared), multisign: true).TxBlob, - spnSigner1.Sign(new Dictionary(prepared), multisign: true).TxBlob, - spnSigner2.Sign(new Dictionary(prepared), multisign: true).TxBlob, + spnSigner1.Sign(new Dictionary(prepared), true, null, SignatureRole.Sponsor).TxBlob, + spnSigner2.Sign(new Dictionary(prepared), true, null, SignatureRole.Sponsor).TxBlob, }; SignatureResult composed = await client.ComposeSignatures(parts); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs index 3c67ea06..e3b1af5d 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs @@ -74,6 +74,8 @@ private static async Task OpenSponsorshipAsync(XrplWallet sponsor, XrplWallet sp Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); } + await AmendmentGuard.RequireRoleSignaturesAsync(client); + await SubmitAsync(new SponsorshipSet { Account = sponsor.ClassicAddress, diff --git a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs index 3650c027..a6147120 100644 --- a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs +++ b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs @@ -73,7 +73,8 @@ public void TestUCombine_MultisigBorrower_EntriesLandInCounterpartySignature() { JsonObject signer = entry["Signer"].AsObject(); string account = signer["Account"].GetValue(); - byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForMultiSigning(forSigning, account)); + byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForMultiSigning( + forSigning, account, global::Xrpl.BinaryCodec.Hashing.HashPrefix.CounterpartyTransactionMultiSig)); Assert.IsTrue( XrplKeypairs.Verify(preimage, signer["TxnSignature"].GetValue(), signer["SigningPubKey"].GetValue()), $"the entry of {account} must verify over the multisign preimage"); diff --git a/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs b/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs new file mode 100644 index 00000000..0958b464 --- /dev/null +++ b/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.BinaryCodec; +using Xrpl.BinaryCodec.Hashing; +using Xrpl.Wallet; + +namespace Xrpl.Tests.Wallet.Tests +{ + /// + /// The four-byte prefixes rippled's fixCleanup3_4_0 gives the signing roles, checked + /// against the protocol rather than against a captured output. + /// + /// + /// A pinned blob cannot say whether a preimage is right: regenerate it from the same code and + /// it agrees with itself. What is checkable without a node is the shape of the change rippled + /// describes: a role preimage is the transaction's own preimage with a different four-byte + /// prefix, and the prefixes are ASCII tags built by makeHashPrefix in + /// include/xrpl/protocol/HashPrefix.h. Everything past those four bytes must be + /// identical, or the roles would be signing different transactions rather than the same one + /// in different capacities. + /// + [TestClass] + public class TestURoleSigningPrefixes + { + private static JsonObject SampleTransaction() + { + XrplWallet submitter = XrplWallet.FromSeed("sEdVJXQmtqNy1pp8uMqsqgxMGL9QdzP"); + XrplWallet other = XrplWallet.FromSeed("sEdTTqBarUA64vciRMqd1KwpBguQuXJ"); + + return new JsonObject + { + ["TransactionType"] = "Payment", + ["Account"] = submitter.ClassicAddress, + ["Destination"] = other.ClassicAddress, + ["Amount"] = "1000000", + ["Fee"] = "12", + ["Sequence"] = 7u, + ["SigningPubKey"] = submitter.PublicKey, + }; + } + + /// rippled makeHashPrefix: three ASCII letters, then a zero byte. + private static uint Tag(char a, char b, char c) => ((uint)a << 24) | ((uint)b << 16) | ((uint)c << 8); + + [TestMethod] + public void TestURolePrefixes_MatchTheProtocolTags() + { + Assert.AreEqual(Tag('S', 'T', 'X'), (uint)HashPrefix.TransactionSig, "TxSign"); + Assert.AreEqual(Tag('S', 'M', 'T'), (uint)HashPrefix.TransactionMultiSig, "TxMultiSign"); + Assert.AreEqual(Tag('C', 'P', 'T'), (uint)HashPrefix.CounterpartyTransactionSig, "CounterpartyTxSign"); + Assert.AreEqual(Tag('C', 'P', 'M'), (uint)HashPrefix.CounterpartyTransactionMultiSig, "CounterpartyTxMultiSign"); + Assert.AreEqual(Tag('S', 'P', 'N'), (uint)HashPrefix.SponsorTransactionSig, "SponsorTxSign"); + Assert.AreEqual(Tag('S', 'P', 'M'), (uint)HashPrefix.SponsorTransactionMultiSig, "SponsorTxMultiSign"); + } + + [TestMethod] + public void TestURolePreimage_DiffersFromTheTransactionOnlyInThePrefix() + { + JsonObject tx = SampleTransaction(); + string baseline = XrplBinaryCodec.EncodeForSigning(tx); + + foreach (HashPrefix prefix in new[] { HashPrefix.SponsorTransactionSig, HashPrefix.CounterpartyTransactionSig }) + { + string role = XrplBinaryCodec.EncodeForSigning(tx, prefix); + + Assert.AreEqual(baseline.Length, role.Length, $"{prefix}: the preimage may only differ in its prefix"); + Assert.AreEqual(baseline.Substring(8), role.Substring(8), $"{prefix}: the transaction bytes must be identical"); + Assert.AreEqual(((uint)prefix).ToString("X8"), role.Substring(0, 8), $"{prefix}: leading four bytes"); + Assert.AreNotEqual(baseline, role, $"{prefix}: a role signature must not cover the transaction's own bytes"); + } + } + + [TestMethod] + public void TestURoleMultiSigningPreimage_DiffersFromTheTransactionOnlyInThePrefix() + { + JsonObject tx = SampleTransaction(); + string signer = XrplWallet.FromSeed("sEdVUGxDJ7sqTupycsVNowrQMeJn7UP").ClassicAddress; + string baseline = XrplBinaryCodec.EncodeForMultiSigning(tx, signer); + + foreach (HashPrefix prefix in new[] { HashPrefix.SponsorTransactionMultiSig, HashPrefix.CounterpartyTransactionMultiSig }) + { + string role = XrplBinaryCodec.EncodeForMultiSigning(tx, signer, prefix); + + Assert.AreEqual(baseline.Substring(8), role.Substring(8), $"{prefix}: the transaction and signer bytes must be identical"); + Assert.AreEqual(((uint)prefix).ToString("X8"), role.Substring(0, 8), $"{prefix}: leading four bytes"); + } + } + + /// + /// Every prefix is distinct: two roles sharing one would be the very substitution the + /// amendment closes, where a signature made for one role is accepted for another. + /// + [TestMethod] + public void TestUEveryPrefix_IsDistinct() + { + uint[] prefixes = Enum.GetValues().Select(p => (uint)p).ToArray(); + Assert.AreEqual(prefixes.Length, prefixes.Distinct().Count(), "hash prefixes must be unique"); + } + } +} diff --git a/Tests/Xrpl.Tests/Wallet/TestUSignatureComposer.cs b/Tests/Xrpl.Tests/Wallet/TestUSignatureComposer.cs index 7ace0563..57c3122a 100644 --- a/Tests/Xrpl.Tests/Wallet/TestUSignatureComposer.cs +++ b/Tests/Xrpl.Tests/Wallet/TestUSignatureComposer.cs @@ -53,7 +53,7 @@ private static Dictionary ToDict(JsonObject json) => private static string SubmitterOnlyBlob() { JsonObject tx = PreparedSponsoredTx(); - byte[] preimage = SponsorSigningHelper.GetSigningPreimage(tx); + byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForSigning(tx)); tx["TxnSignature"] = XrplKeypairs.Sign(preimage, Submitter.PrivateKey); return XrplBinaryCodec.Encode(tx); } @@ -164,7 +164,7 @@ public void TestUCompose_MismatchedBodies_Throws() string sponsorPart = Sponsor.Sign(ToDict(PreparedSponsoredTx())).TxBlob; JsonObject other = PreparedSponsoredTx(); other["Amount"] = "999"; - byte[] preimage = SponsorSigningHelper.GetSigningPreimage(other); + byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForSigning(other)); other["TxnSignature"] = XrplKeypairs.Sign(preimage, Submitter.PrivateKey); string mismatched = XrplBinaryCodec.Encode(other); diff --git a/Tests/Xrpl.Tests/Wallet/TestUSigningPinned.cs b/Tests/Xrpl.Tests/Wallet/TestUSigningPinned.cs index cbe5d874..38601e52 100644 --- a/Tests/Xrpl.Tests/Wallet/TestUSigningPinned.cs +++ b/Tests/Xrpl.Tests/Wallet/TestUSigningPinned.cs @@ -12,10 +12,17 @@ namespace Xrpl.Tests.Wallet.Tests { /// - /// Byte-level pinning of the sponsored (XLS-68) and multisig signing outputs, - /// captured from the pre-refactor implementation with fixed ed25519 seeds. - /// The unified Sign/Submit refactor (issue #43) must keep every blob - /// byte-identical; a diff here means the wire format changed. + /// Byte-level pinning of the sponsored (XLS-68) and multisig signing outputs, with fixed + /// ed25519 seeds. Captured from the pre-refactor implementation, so that the unified + /// Sign/Submit refactor (issue #43) would keep every blob byte-identical; a diff here means + /// the wire format changed. + /// + /// SponsoredBlob was re-pinned once, when fixCleanup3_4_0 gave each signing role its own hash + /// prefix: the sponsor now signs different bytes, so its signature inside the blob changed. + /// Nothing else did - the transaction body and the submitter's own TxnSignature are the bytes + /// captured originally - and what makes the new value right is not this pin but rippled + /// accepting it, which the integration suite checks against a node carrying the amendment. + /// /// [TestClass] public class TestUSigningPinned @@ -31,9 +38,9 @@ public class TestUSigningPinned "89173623C7D093A293C154BFDA1D9A9BA12626E7BD95A07440A23DC3577B6F545B77519D34663F8896F88E682B90E2B1157F" + "E4CECDC2A1FE917EC50731A8C2B776C7BC0A2076CEBC69FFAB5338EDF2E741D261C502E69F4A078114618C7D24D6B77E9F01" + "96A04852B0FD814E96CD9A8314470455C34F3FBC4CE4E46ADE9E1CFB2CE0DD2744801B14F00DA3229BBA108A9EA2BF7D3177" + - "89FBF8E939BCE0267321EDF4DBF4E5536C90D3FB6709A3FDBC3FBF6E06E0D06BED3F10B45733496E1E5F907440E973DF2132" + - "AE3C5E87694CFE9314AA3129B67C08553CD2A4BFE3390523A65346AA71D5E66207FF0AEB9E39685753DE8733539A5DE9ED76" + - "C8ABE97D209476F60DE1"; + "89FBF8E939BCE0267321EDF4DBF4E5536C90D3FB6709A3FDBC3FBF6E06E0D06BED3F10B45733496E1E5F907440E57E3AD595" + + "4890DAE08FB85B342BC04E3CA377D50983DDDE328C86E592F836E5CDF8124CF8AD23807663B744774E5870C6DAE593D23F43" + + "85481E1995C076ED0EE1"; private const string MultisigBlob = "1200002400000008201B007A12016140000000001E848068400000000000001873008114618C7D24D6B77E9F0196A04852B0" + @@ -77,7 +84,7 @@ public void TestUPinned_V2_Combine() var sponsorPart = Sponsor.SignAsSponsor(preparedDict); JsonObject submitterTx = prepared.DeepClone().AsObject(); - byte[] preimage = SponsorSigningHelper.GetSigningPreimage(submitterTx); + byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForSigning(submitterTx)); submitterTx["TxnSignature"] = XrplKeypairs.Sign(preimage, Submitter.PrivateKey); string submitterBlob = XrplBinaryCodec.Encode(submitterTx); diff --git a/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs b/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs index 541e79a0..caee0cbe 100644 --- a/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs +++ b/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs @@ -33,6 +33,13 @@ public class TestUSponsorSigning ["SponsorFlags"] = SpfSponsorFee | SpfSponsorReserve, }; + /// + /// What the submitter's own TxnSignature covers. Since fixCleanup3_4_0 that is no longer + /// what the sponsor signs: the two preimages differ in their four-byte prefix. + /// + private static byte[] SubmitterPreimage(JsonObject tx) => + global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForSigning(tx)); + [TestMethod] public void TestUSignSponsored_V1_BothSignaturesVerify() { @@ -57,9 +64,9 @@ public void TestUSignSponsored_V1_BothSignaturesVerify() byte[] preimage = SponsorSigningHelper.GetSigningPreimage(preimageTx); Assert.IsTrue(XrplKeypairs.Verify(preimage, sponsorSig["TxnSignature"]!.GetValue(), sponsor.PublicKey), - "SponsorSignature must verify over the transaction preimage."); - Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["TxnSignature"]!.GetValue(), submitter.PublicKey), - "Submitter TxnSignature must verify over the same preimage."); + "SponsorSignature must verify over the sponsor preimage."); + Assert.IsTrue(XrplKeypairs.Verify(SubmitterPreimage(preimageTx), decoded["TxnSignature"]!.GetValue(), submitter.PublicKey), + "Submitter TxnSignature must verify over the transaction preimage."); } [TestMethod] @@ -89,9 +96,9 @@ public void TestUSignSponsored_V2_CombineParallelSignatures() byte[] preimage = SponsorSigningHelper.GetSigningPreimage(preimageTx); Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["SponsorSignature"]!["TxnSignature"]!.GetValue(), sponsor.PublicKey), - "Combined SponsorSignature must verify over the shared preimage."); - Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["TxnSignature"]!.GetValue(), submitter.PublicKey), - "Combined TxnSignature must verify over the shared preimage."); + "Combined SponsorSignature must verify over the sponsor preimage."); + Assert.IsTrue(XrplKeypairs.Verify(SubmitterPreimage(preimageTx), decoded["TxnSignature"]!.GetValue(), submitter.PublicKey), + "Combined TxnSignature must verify over the transaction preimage."); } [TestMethod] @@ -116,7 +123,7 @@ public void TestUSignSponsored_V3_SequentialSigning() byte[] preimage = SponsorSigningHelper.GetSigningPreimage(preimageTx); Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["SponsorSignature"]!["TxnSignature"]!.GetValue(), sponsor.PublicKey)); - Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["TxnSignature"]!.GetValue(), submitter.PublicKey)); + Assert.IsTrue(XrplKeypairs.Verify(SubmitterPreimage(preimageTx), decoded["TxnSignature"]!.GetValue(), submitter.PublicKey)); } [TestMethod] diff --git a/Xrpl/Wallet/CoSigningEngine.cs b/Xrpl/Wallet/CoSigningEngine.cs index b0f2d0ba..999a176f 100644 --- a/Xrpl/Wallet/CoSigningEngine.cs +++ b/Xrpl/Wallet/CoSigningEngine.cs @@ -8,29 +8,52 @@ using Xrpl.Models.Transactions; using Xrpl.Utils.Hashes; +// Xrpl.Utils.Hashes declares a HashPrefix of its own; the codec's is the one that prefixes a signing preimage. +using HashPrefix = Xrpl.BinaryCodec.Hashing.HashPrefix; + namespace Xrpl.Wallet { /// /// Shared engine behind the inner co-signature helpers. SponsorSignature /// (XLS-68) and CounterpartySignature (XLS-66) follow one protocol shape — - /// an inner not-signing STObject signed over the same preimage as the main - /// signature — so the V1/V2/V3 flows differ only by the field name and the - /// wording of their errors. SponsorSigningHelper and LoanSigningHelper are - /// thin facades over this class. + /// an inner not-signing STObject over the same transaction body as the main + /// signature — so the V1/V2/V3 flows differ only by the field name, the hash + /// prefix of the role and the wording of their errors. SponsorSigningHelper + /// and LoanSigningHelper are thin facades over this class. /// internal static class CoSigningEngine { /// - /// Computes the signing preimage bytes. The submitter and every - /// co-signer sign these same bytes (inner signature objects are - /// kNotSigning and never enter the preimage). + /// Computes the signing preimage bytes for the transaction's own signature. /// + /// + /// Inner signature objects are kNotSigning and never enter the preimage, so the bytes + /// depend only on the transaction body and the prefix. Since fixCleanup3_4_0 the prefix + /// differs by role, so the submitter and a co-signer no longer sign the same bytes. + /// internal static byte[] GetSigningPreimage(JsonObject txJson) + => GetSigningPreimage(txJson, HashPrefix.TransactionSig); + + /// + /// Computes the signing preimage bytes under an explicit role prefix. + /// + internal static byte[] GetSigningPreimage(JsonObject txJson, HashPrefix prefix) { - string signingHex = XrplBinaryCodec.EncodeForSigning(txJson); + string signingHex = XrplBinaryCodec.EncodeForSigning(txJson, prefix); return AddressCodec.Utils.FromHex(signingHex); } + /// + /// The prefix a co-signature field is signed under, mirroring rippled's + /// signatureRole(SField const&) and signingPrefix. + /// + internal static HashPrefix PrefixFor(string coSignatureField, bool multiSigning = false) => coSignatureField switch + { + "SponsorSignature" => multiSigning ? HashPrefix.SponsorTransactionMultiSig : HashPrefix.SponsorTransactionSig, + "CounterpartySignature" => multiSigning ? HashPrefix.CounterpartyTransactionMultiSig : HashPrefix.CounterpartyTransactionSig, + _ => throw new ValidationException($"{coSignatureField} is not a co-signature field."), + }; + /// Removes the signature-bearing fields for body comparison. internal static JsonObject Canonicalize(JsonObject tx, string coSignatureField) => tx.WithoutFields("TxnSignature", "SigningPubKey", coSignatureField); @@ -50,12 +73,15 @@ internal static SignatureResult SignBoth( tx.Remove(coSignatureField); tx.Remove("TxnSignature"); - byte[] signingBytes = GetSigningPreimage(tx); + // Two preimages, not one: the co-signer signs under its role prefix and the + // submitter under the transaction's own, and they have differed since fixCleanup3_4_0 + byte[] coSignerBytes = GetSigningPreimage(tx, PrefixFor(coSignatureField)); + byte[] submitterBytes = GetSigningPreimage(tx); - string coSignature = XrplKeypairs.Sign(signingBytes, coSignerWallet.PrivateKey); + string coSignature = XrplKeypairs.Sign(coSignerBytes, coSignerWallet.PrivateKey); tx[coSignatureField] = SignatureObject.Single(coSignerWallet.PublicKey, coSignature).ToJsonObject(); - tx["TxnSignature"] = XrplKeypairs.Sign(signingBytes, submitterWallet.PrivateKey); + tx["TxnSignature"] = XrplKeypairs.Sign(submitterBytes, submitterWallet.PrivateKey); return Encode(tx); } diff --git a/Xrpl/Wallet/LoanSigningHelper.cs b/Xrpl/Wallet/LoanSigningHelper.cs index 7ed3c5f0..5ff62e11 100644 --- a/Xrpl/Wallet/LoanSigningHelper.cs +++ b/Xrpl/Wallet/LoanSigningHelper.cs @@ -196,11 +196,11 @@ private static void RequireLoanSet(string blob, string label) } /// - /// Computes the signing preimage bytes for a LoanSet transaction. - /// Both broker and borrower sign the same preimage. + /// Computes the bytes the borrower signs into CounterpartySignature: the transaction + /// under the counterparty prefix, which since fixCleanup3_4_0 is not what the broker signs. /// internal static byte[] GetSigningPreimage(JsonObject txJson) - => CoSigningEngine.GetSigningPreimage(txJson); + => CoSigningEngine.GetSigningPreimage(txJson, CoSigningEngine.PrefixFor("CounterpartySignature")); } } diff --git a/Xrpl/Wallet/SignatureComposer.cs b/Xrpl/Wallet/SignatureComposer.cs index 8ae30155..e75d6067 100644 --- a/Xrpl/Wallet/SignatureComposer.cs +++ b/Xrpl/Wallet/SignatureComposer.cs @@ -16,10 +16,13 @@ namespace Xrpl.Wallet /// Devices sign with whatever keys they hold — single main signature, /// sponsor or counterparty co-signature, or portable multisig Signer /// entries — and the composer routes everything into the right sections. - /// Signer entries are section-agnostic by protocol (identical preimage for - /// tx.Signers, SponsorSignature.Signers and CounterpartySignature.Signers, - /// see rippled STTx::checkMultiSign), so only the composer needs to know - /// which signer belongs to which side. + /// Which section an entry belongs to is still decided here, by account, but it is no longer + /// only the composer's business: since rippled's fixCleanup3_4_0 an entry in + /// SponsorSignature.Signers or CounterpartySignature.Signers covers different bytes than one + /// in tx.Signers, so the signer had to know its side already. It works that out from the + /// transaction in every shape but one - see - and routing an entry + /// into a section its signer did not sign for now produces a signature the node rejects + /// rather than a portable one. /// public static class SignatureComposer { diff --git a/Xrpl/Wallet/SignatureRole.cs b/Xrpl/Wallet/SignatureRole.cs new file mode 100644 index 00000000..6d49d00d --- /dev/null +++ b/Xrpl/Wallet/SignatureRole.cs @@ -0,0 +1,31 @@ +namespace Xrpl.Wallet +{ + /// + /// Which signature of a transaction a key is producing. + /// + /// + /// Before rippled's fixCleanup3_4_0 every signature on a transaction covered the same + /// bytes, so the role was decided when the parts were composed rather than when they were + /// signed, and a signature could be lifted from one role into another. Since the amendment + /// each role signs under its own hash prefix, so a signer has to know its role before signing. + /// Mirrors rippled's SignatureRole in include/xrpl/protocol/Sign.h. + /// + /// The SDK works the role out on its own wherever the transaction says it: a wallet named as + /// the Sponsor signs as sponsor, a LoanSet Counterparty as counterparty, and a + /// multi-signature entry on a transaction whose main signature is single can only belong to + /// the co-signing side. It is genuinely ambiguous in one shape only, where the main signature + /// and a co-signature are both multi-signed, and there the role has to be passed in. + /// + /// + public enum SignatureRole + { + /// The transaction's own signature: TxnSignature, or an entry in Signers. + Transaction, + + /// The sponsor's signature (XLS-68), in SponsorSignature. + Sponsor, + + /// The counterparty's signature (XLS-66 LoanSet), in CounterpartySignature. + Counterparty, + } +} diff --git a/Xrpl/Wallet/SponsorSigningHelper.cs b/Xrpl/Wallet/SponsorSigningHelper.cs index fc863ea0..9796905a 100644 --- a/Xrpl/Wallet/SponsorSigningHelper.cs +++ b/Xrpl/Wallet/SponsorSigningHelper.cs @@ -14,7 +14,7 @@ namespace Xrpl.Wallet /// A sponsored transaction carries the common fields Sponsor and SponsorFlags /// (spfSponsorFee = 1, spfSponsorReserve = 2) and, when the sponsorship requires it, /// the sponsor's co-signature: SponsorSignature (inner STObject with - /// SigningPubKey + TxnSignature over the same preimage as the main signature). + /// SigningPubKey + TxnSignature over the same transaction, under the sponsor's own prefix). /// /// Signing patterns (analogous to LoanSet broker/counterparty): /// @@ -89,11 +89,11 @@ public static SignatureResult SubmitterSign(string partiallySignedBlob, XrplWall => CoSigningEngine.FinalizeAsSubmitter(partiallySignedBlob, submitterWallet, "SponsorSignature"); /// - /// Computes the signing preimage bytes for a sponsored transaction. - /// Both the submitter and the sponsor sign the same preimage. + /// Computes the bytes the sponsor signs into SponsorSignature: the transaction under the + /// sponsor prefix, which since fixCleanup3_4_0 is not what the submitter signs. /// public static byte[] GetSigningPreimage(JsonObject txJson) - => CoSigningEngine.GetSigningPreimage(txJson); + => CoSigningEngine.GetSigningPreimage(txJson, CoSigningEngine.PrefixFor("SponsorSignature")); internal static void VerifySponsorMatches(JsonObject tx, XrplWallet sponsorWallet) { diff --git a/Xrpl/Wallet/XrplWallet.cs b/Xrpl/Wallet/XrplWallet.cs index 29e593ea..b4522118 100644 --- a/Xrpl/Wallet/XrplWallet.cs +++ b/Xrpl/Wallet/XrplWallet.cs @@ -18,6 +18,9 @@ using Xrpl.Models.Utils; using Xrpl.Utils.Hashes; +// Xrpl.Utils.Hashes declares a HashPrefix of its own; the codec's is the one that prefixes a signing preimage. +using HashPrefix = Xrpl.BinaryCodec.Hashing.HashPrefix; + // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/Wallet/index.ts namespace Xrpl.Wallet @@ -632,6 +635,41 @@ private static void GuardMemos(Dictionary transaction) MemoRules.Validate(memos); } + /// + /// Signs a transaction offline in a stated role, for the one shape where the transaction + /// does not say which signature this is: a multi-signature entry on a transaction whose + /// main signature is multi-signed too, or which names both a Sponsor and a Counterparty. + /// + /// + /// Everywhere else the role follows from the transaction and this overload is not needed; + /// see . An overload rather than a fourth optional parameter, + /// which would be source-compatible but not binary-compatible. + /// + /// A transaction to be signed offline. + /// True to produce a multi-signature entry rather than a single signature. + /// The signing account (classic or X-address); this wallet's own by default. + /// Which signature of the transaction this key is producing. + public SignatureResult Sign( + Dictionary transaction, + bool multisign, + string? signingFor, + SignatureRole role) + { + GuardMemos(transaction); + + if (!multisign) + { + return role switch + { + SignatureRole.Sponsor => SignAsSponsor(transaction), + SignatureRole.Counterparty => SignAsLoanCounterparty(transaction), + _ => Sign(transaction, false, signingFor), + }; + } + + return SignMulti(transaction, NormalizeClassic(signingFor), role); + } + /// /// Signs a transaction offline. /// @@ -675,9 +713,9 @@ public SignatureResult Sign(Dictionary transaction, bool multisi } // 2) XLS-68: when this wallet is the transaction's Sponsor, route to the // sponsor co-signature path automatically (same pattern as the Batch - // inner-signer routing above). Multisig signing is exempt: a Signer - // entry is section-agnostic (identical preimage for tx.Signers and - // SponsorSignature.Signers), so the role is decided at composition time. + // inner-signer routing above). Multisig signing is exempt here because + // the sponsor's own key is not what signs then: the entries come from the + // sponsor's SignerList, and SignMulti works their role out for itself. if (!multisign && transaction.TryGetValue("Sponsor", out var sponsorField) && sponsorField is string sponsorAddress @@ -748,6 +786,61 @@ private string NormalizeClassic(string? signingFor) private SignatureResult SignMulti(Dictionary transaction, string signerAccount) + => SignMulti(transaction, signerAccount, null); + + /// + /// The prefix a multi-signature entry is signed under. + /// + /// + /// Until fixCleanup3_4_0 an entry was section-agnostic: tx.Signers, + /// SponsorSignature.Signers and CounterpartySignature.Signers all covered + /// the same bytes, and which section an entry belonged to was settled when the parts were + /// composed. Since the amendment the section is part of what is signed, so it has to be + /// known here. + /// + /// It is worked out from the transaction wherever the transaction says it. A main + /// signature that is itself multi-signed leaves SigningPubKey empty, so a + /// non-empty one on a transaction naming a Sponsor or a Counterparty means the entry can + /// only belong to that co-signing side. When both a Sponsor and a Counterparty are named + /// on such a transaction, or when the main signature is multi-signed as well, the + /// transaction does not say, and the caller has to pass + /// through the + /// overload. + /// + /// + private static HashPrefix MultiSigningPrefix(JsonObject txBase, bool coSigningSide, SignatureRole? role) + { + bool hasSponsor = txBase["Sponsor"] is not null; + bool hasCounterparty = txBase["Counterparty"] is not null; + + if (role is SignatureRole.Sponsor) + { + if (!hasSponsor) + throw new ValidationException("Cannot sign as the sponsor: the transaction names no Sponsor."); + return HashPrefix.SponsorTransactionMultiSig; + } + + if (role is SignatureRole.Counterparty) + { + if (!hasCounterparty) + throw new ValidationException("Cannot sign as the counterparty: the transaction names no Counterparty."); + return HashPrefix.CounterpartyTransactionMultiSig; + } + + if (role is SignatureRole.Transaction || !coSigningSide) + return HashPrefix.TransactionMultiSig; + + if (hasSponsor && hasCounterparty) + { + throw new ValidationException( + "The transaction names both a Sponsor and a Counterparty, so a multi-signature entry could belong to either. " + + "Pass SignatureRole.Sponsor or SignatureRole.Counterparty to Sign."); + } + + return hasSponsor ? HashPrefix.SponsorTransactionMultiSig : HashPrefix.CounterpartyTransactionMultiSig; + } + + private SignatureResult SignMulti(Dictionary transaction, string signerAccount, SignatureRole? role) { // txBase is what finally goes out; it accumulates Signers. var txBase = JsonNode.Parse(JsonSerializer.Serialize(transaction, XrplJsonOptions.Default))?.AsObject(); @@ -769,7 +862,10 @@ private SignatureResult SignMulti(Dictionary transaction, string txForSign.Remove("TxnSignature"); txForSign.Remove("Signers"); - string preimageHex = XrplBinaryCodec.EncodeForMultiSigning(txForSign, signerAccount); + string preimageHex = XrplBinaryCodec.EncodeForMultiSigning( + txForSign, + signerAccount, + MultiSigningPrefix(txBase, sponsoredSingleMain, role)); var preimage = Xrpl.AddressCodec.Utils.FromHex(preimageHex); string sig = Xrpl.Keypairs.XrplKeypairs.Sign(preimage, this.PrivateKey); @@ -1197,7 +1293,7 @@ public SignatureResult SignAsLoanCounterparty(Dictionary transac if (!string.Equals(txType, "LoanSet", StringComparison.OrdinalIgnoreCase)) throw new ValidationException($"SignAsLoanCounterparty requires TransactionType=LoanSet, got: {txType}"); - // Verify broker's SigningPubKey is present — counterparty must sign the same preimage + // Verify broker's SigningPubKey is present - it is part of what the counterparty signs string brokerSigningPubKey = tx["SigningPubKey"]?.GetValue(); if (string.IsNullOrWhiteSpace(brokerSigningPubKey)) throw new ValidationException("LoanSet must include broker SigningPubKey before counterparty signing."); @@ -1206,7 +1302,7 @@ public SignatureResult SignAsLoanCounterparty(Dictionary transac tx.Remove("CounterpartySignature"); tx.Remove("TxnSignature"); - // Compute signing preimage (same preimage broker will sign) + // The counterparty's preimage: the same transaction as the broker's, under its own prefix byte[] signingBytes = LoanSigningHelper.GetSigningPreimage(tx); // Sign the preimage with this wallet's key @@ -1224,7 +1320,7 @@ public SignatureResult SignAsLoanCounterparty(Dictionary transac /// /// Signs a sponsored transaction as the sponsor (XLS-68). /// Computes the signing preimage and adds SponsorSignature (inner STObject - /// with this wallet's SigningPubKey and TxnSignature over the same preimage + /// with this wallet's SigningPubKey and TxnSignature over the sponsor preimage /// the submitter signs). The transaction must carry Sponsor = this wallet's address. /// /// V3 (sequential) — sponsor signs first, passes to submitter: From 594f16c21d28c607cd3a9567b222568a396cc6d9 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 18:20:33 -0300 Subject: [PATCH 06/11] fix(review): a failed definitions diff no longer reads as no drift, and a typed role overload From the CodeRabbit review of #183, four findings, all confirmed against the code. - nightly-pin-watch: the diff tool exits 2 when it cannot run at all, and the grep then found nothing and reported "no differences reported" to the PR body - the reassuring answer for the one case that has no answer. Only 0 and 1 are read as a verdict now; anything else says the diff did not run, and carries the output. The step stays informational and still opens the PR: a diff tool that fell over is not a reason to withhold a pin bump, which is what the step's own comment says and why the reviewer's second suggestion is not taken - XrplWallet: a typed Sign overload carrying the role, so a caller holding an ITransactionRequest does not have to convert to a dictionary to sign as a sponsor or a counterparty in the one ambiguous shape - XrplWallet: the sponsor XML doc said the sponsor signs "the sponsor preimage the submitter signs", a sentence left half-rewritten when the preimages parted - CHANGES: the vault entry described the signature failures as something this release does not implement, while the entry above it implements them. It now reads as the intermediate result it was, and its stale CI-stand count is gone - the signing entry carries the measurement that survived Unit suite 1294 pass; the workflow parses. --- .github/workflows/nightly-pin-watch.yml | 10 +++++++++- CHANGES.md | 2 +- Xrpl/Wallet/XrplWallet.cs | 20 ++++++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml index e1dcc3a2..75e1567a 100644 --- a/.github/workflows/nightly-pin-watch.yml +++ b/.github/workflows/nightly-pin-watch.yml @@ -195,9 +195,17 @@ jobs: out=$(dotnet run --project Tools/GenerateEnums -- diff http://localhost:5005 2>&1) status=$? echo "$out" + # 0 is in sync and 1 is drift; anything else is the tool failing to answer at all, + # and an empty grep would then reach the PR body as "no differences reported". + if [ "$status" -eq 0 ] || [ "$status" -eq 1 ]; then + summary=$(printf '%s\n' "$out" | grep -E 'node-only|mismatch|^Summary') + [ -n "$summary" ] || summary='no differences reported' + else + summary=$(printf 'the definitions diff did not run (exit %s), so this section says nothing about drift:\n%s' "$status" "$out") + fi { echo 'summary<> "$GITHUB_OUTPUT" diff --git a/CHANGES.md b/CHANGES.md index 85590749..6ba3dd50 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,7 +22,7 @@ * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and `TestIClosedEndedVault` drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no `VaultKind` on the ledger, and a `VaultWithdraw` to a deposit-authorized destination that is `tecNO_PERMISSION` without `CredentialIDs` and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know * the Loan integration suite follows the amendment too. Under LendingProtocolV1_1 `LoanBrokerSet` refuses an open-ended vault ("LoanBroker requires a closed-ended Vault", `tecNO_PERMISSION`) and every Loan test built its broker on one, so all 18 of `TestILoan` failed on the nightly stand - identically on untouched `dev`, which is what said it was the node's rule rather than a regression. `TestILoanBase` now creates a closed-ended vault where the node asks for one and waits for the investment phase before handing the broker back: rippled originates a loan only there, while the vault deposit that funds it is taken only in the subscription phase before it, and the two dates are measured from the ledger's close time rather than the machine's clock, which on a standalone stand is a different clock. A node without the amendment does not know the fields at all - it answers `invalidTransaction`, not a result code - so the open-ended path stays for it, chosen through `AmendmentGuard` - * what that unblocks, and what it does not: on the nightly stand `TestILoan` goes from 0 of 18 to 7 of 18, and all 11 that still fail report `Counterparty: Invalid signature` - the role signing prefixes `fixCleanup3_4_0` introduces, which this release does not implement. `TestISponsoredVaultLoan` is blocked by the same thing on 3.4.x, at `Sponsor: Invalid signature`. Neither failure is about vaults any more. On the CI stand (3.3.0) the whole affected set passes, 38 of 38, with the three amendment-gated closed-ended tests skipped + * what that unblocks, and what it does not: on the nightly stand `TestILoan` goes from 0 of 18 to 7 of 18, and all 11 that still failed reported `Counterparty: Invalid signature` - the role signing prefixes `fixCleanup3_4_0` introduces, which is what the entry above this one goes on to implement, and which was the whole of what remained. `TestISponsoredVaultLoan` is blocked by the same thing on 3.4.x, at `Sponsor: Invalid signature`. Neither failure was about vaults any more, which is what this entry set out to establish; the signatures are the subject of the entry above, and are green there. The CI stand (3.3.0) is untouched by any of it; what the suite looks like there once the signing change lands is counted in the entry above * `ValidatedCloseTimeAsync` and `WaitForCloseTimeAsync` moved to `IntegrationTestConfig`: three test classes now need the ledger clock, and each had been carrying its own copy * **A sponsor's and a counterparty's signature cover bytes of their own** (rippled `fixCleanup3_4_0`, **breaking against nodes without the amendment**). Until it, every signature on a transaction covered the same bytes: the submitter's `TxnSignature`, the sponsor's `SponsorSignature` (XLS-68) and the borrower's `CounterpartySignature` (XLS-66) were all made over one preimage, so a signature could be lifted out of one role and pasted into another and still verify. rippled now gives each role its own four-byte hash prefix, and this release signs that way. diff --git a/Xrpl/Wallet/XrplWallet.cs b/Xrpl/Wallet/XrplWallet.cs index b4522118..2ff366de 100644 --- a/Xrpl/Wallet/XrplWallet.cs +++ b/Xrpl/Wallet/XrplWallet.cs @@ -1219,6 +1219,21 @@ public SignatureResult Sign(ITransactionRequest tx, bool multisign = false, stri return Sign(txJson, multisign, signingFor); } + /// + /// Signs a transaction offline in a stated role: the typed counterpart of + /// , for the one + /// shape where the transaction does not say which of its signatures this is. + /// + /// A transaction to be signed offline. + /// True to produce a multi-signature entry rather than a single signature. + /// The signing account (classic or X-address); this wallet's own by default. + /// Which signature of the transaction this key is producing. + public SignatureResult Sign(ITransactionRequest tx, bool multisign, string? signingFor, SignatureRole role) + { + Dictionary txJson = JsonSerializer.Deserialize>(tx.ToJson(), XrplJsonOptions.Default); + return Sign(txJson, multisign, signingFor, role); + } + /// /// Verifies a signed transaction offline. /// @@ -1320,8 +1335,9 @@ public SignatureResult SignAsLoanCounterparty(Dictionary transac /// /// Signs a sponsored transaction as the sponsor (XLS-68). /// Computes the signing preimage and adds SponsorSignature (inner STObject - /// with this wallet's SigningPubKey and TxnSignature over the sponsor preimage - /// the submitter signs). The transaction must carry Sponsor = this wallet's address. + /// with this wallet's SigningPubKey and TxnSignature over the sponsor preimage, which + /// since fixCleanup3_4_0 is not the one the submitter signs). The transaction must carry + /// Sponsor = this wallet's address. /// /// V3 (sequential) — sponsor signs first, passes to submitter: /// From 86298ede3fc1fa07cf38503fd8f1d54ee220e38c Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 7 Sep 2026 21:48:18 -0300 Subject: [PATCH 07/11] fix(signing): a composed transaction is checked against the bytes it ships From a cold review of this branch: three reviewers on two models, one pass, none of them told what the change was for. Eleven findings survived verification. The two the models found independently are one defect seen from both ends. Where the main signature is multi-signed as well, the transaction cannot say which side a multi-signature entry belongs to; the signer chose the transaction's own prefix whatever the answer, and the composer routed the entry by account without noticing. The role parameter added for that shape did not close it either: it corrected the prefix while the preimage kept a SigningPubKey the composed transaction does not ship. - SignatureComposer verifies every signature in the finished transaction against the bytes that transaction ships, under the prefix of the section the signature landed in, and names the account and the section when one does not verify. It found two mis-signed compositions in this repository's own unit tests - a multi-signature entry with SignatureRole.Transaction on a transaction that has a single main signature is a contradiction and is refused - the role overload no longer skips the Batch inner-signer routing, and no longer discards an explicit transaction role into the co-signature path - SponsorSigningHelper.GetSigningPreimage is now GetSponsorPreimage: the name returned both signatures' bytes while they were the same, and keeping it would have changed what a call means without changing how it compiles Two of the findings are about how this branch was verified, and one of them is the sharper. TestClosedEndedVault_Phases waited 180 s for the redemption phase under a 120 s budget: it had passed only because the earlier runs were parallel and the load ate the wait. Run alone it fails, short by 56 s. The budget now follows the distance to the mark. The subscription window goes to 30 s, the value the sibling helper already justifies. - the role-signature guard moved off the sponsorship classes onto the tests that actually make one, giving the CI stand back its three SponsorshipSet tests - the nightly-pin workflow no longer puts raw tool output into a step output that a later step pastes into its own shell source: a substituted $(id -u) executed - the prefix test reads rippled's HashPrefix.h, vendored as a fixture, instead of restating the constants it was meant to check Verified. Unit 1294 pass. Nightly stand (xrpld 3.4.0-rc1) 87 of 87, and the phase test alone 3 m 30 s. CI stand (3.3.0) 289 pass, 60 skip, 0 fail, up from 286/63. --- .github/workflows/nightly-pin-watch.yml | 5 +- CHANGES.md | 6 +- Tests/Xrpl.Tests/Fixtures/HashPrefix.h | 125 ++++++++++++++++++ Tests/Xrpl.Tests/Fixtures/HashPrefix.h.ref | 22 +++ Tests/Xrpl.Tests/Integration/Utils.cs | 10 +- .../transactions/TestIClosedEndedVault.cs | 5 +- .../transactions/TestISponsorship.cs | 19 ++- .../Wallet/TestULoanCounterpartyMultisign.cs | 11 +- .../Wallet/TestURoleSigningPrefixes.cs | 93 +++++++++---- .../Xrpl.Tests/Wallet/TestUSponsorSigning.cs | 10 +- Tests/Xrpl.Tests/Xrpl.Tests.csproj | 3 + Xrpl/Wallet/LoanSigningHelper.cs | 2 +- Xrpl/Wallet/SignatureComposer.cs | 84 ++++++++++++ Xrpl/Wallet/SponsorSigningHelper.cs | 8 +- Xrpl/Wallet/XrplWallet.cs | 96 ++++++++------ 15 files changed, 420 insertions(+), 79 deletions(-) create mode 100644 Tests/Xrpl.Tests/Fixtures/HashPrefix.h create mode 100644 Tests/Xrpl.Tests/Fixtures/HashPrefix.h.ref diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml index 75e1567a..fe140625 100644 --- a/.github/workflows/nightly-pin-watch.yml +++ b/.github/workflows/nightly-pin-watch.yml @@ -197,11 +197,14 @@ jobs: echo "$out" # 0 is in sync and 1 is drift; anything else is the tool failing to answer at all, # and an empty grep would then reach the PR body as "no differences reported". + # What goes into the output stays text this file chose: the PR-body step pastes it + # into its own shell source, where a quote from a stack trace ends the string and a + # $(...) in it runs. The tool's own output is above, in this step's log. if [ "$status" -eq 0 ] || [ "$status" -eq 1 ]; then summary=$(printf '%s\n' "$out" | grep -E 'node-only|mismatch|^Summary') [ -n "$summary" ] || summary='no differences reported' else - summary=$(printf 'the definitions diff did not run (exit %s), so this section says nothing about drift:\n%s' "$status" "$out") + summary=$(printf 'the definitions diff did not run (exit %s), so this section says nothing about drift; the step log above has the output' "$status") fi { echo 'summary< + +#include + +namespace xrpl { + +namespace detail { + +constexpr std::uint32_t +makeHashPrefix(char a, char b, char c) +{ + return (static_cast(a) << 24) + (static_cast(b) << 16) + + (static_cast(c) << 8); +} + +} // namespace detail + +/** + * Prefix for hashing functions. + * + * These prefixes are inserted before the source material used to generate + * various hashes. This is done to put each hash in its own "space." This way, + * two different types of objects with the same binary data will produce + * different hashes. + * + * Each prefix is a 4-byte value with the last byte set to zero and the first + * three bytes formed from the ASCII equivalent of some arbitrary string. For + * example "TXN". + * + * @note Hash prefixes are part of the protocol; you cannot, arbitrarily, + * change the type or the value of any of these without causing breakage. + */ +enum class HashPrefix : std::uint32_t { + /** + * transaction plus signature to give transaction ID + */ + TransactionId = detail::makeHashPrefix('T', 'X', 'N'), + + /** + * transaction plus metadata + */ + TxNode = detail::makeHashPrefix('S', 'N', 'D'), + + /** + * account state + */ + LeafNode = detail::makeHashPrefix('M', 'L', 'N'), + + /** + * inner node in V1 tree + */ + InnerNode = detail::makeHashPrefix('M', 'I', 'N'), + + /** + * ledger master data for signing + */ + LedgerMaster = detail::makeHashPrefix('L', 'W', 'R'), + + /** + * inner transaction to sign + */ + TxSign = detail::makeHashPrefix('S', 'T', 'X'), + + /** + * inner transaction to multi-sign + */ + TxMultiSign = detail::makeHashPrefix('S', 'M', 'T'), + + /** + * validation for signing + */ + Validation = detail::makeHashPrefix('V', 'A', 'L'), + + /** + * proposal for signing + */ + Proposal = detail::makeHashPrefix('P', 'R', 'P'), + + /** + * Manifest + */ + Manifest = detail::makeHashPrefix('M', 'A', 'N'), + + /** + * Payment Channel Claim + */ + PaymentChannelClaim = detail::makeHashPrefix('C', 'L', 'M'), + + /** + * Batch + */ + Batch = detail::makeHashPrefix('B', 'C', 'H'), + + /** + * inner transaction to sign as the counterparty + */ + CounterpartyTxSign = detail::makeHashPrefix('C', 'P', 'T'), + + /** + * inner transaction to multi-sign as the counterparty + */ + CounterpartyTxMultiSign = detail::makeHashPrefix('C', 'P', 'M'), + + /** + * inner transaction to sign as the sponsor + */ + SponsorTxSign = detail::makeHashPrefix('S', 'P', 'N'), + + /** + * inner transaction to multi-sign as the sponsor + */ + SponsorTxMultiSign = detail::makeHashPrefix('S', 'P', 'M'), +}; + +template +void +hash_append(Hasher& h, HashPrefix const& hp) noexcept +{ + using beast::hash_append; + hash_append(h, static_cast(hp)); +} + +} // namespace xrpl diff --git a/Tests/Xrpl.Tests/Fixtures/HashPrefix.h.ref b/Tests/Xrpl.Tests/Fixtures/HashPrefix.h.ref new file mode 100644 index 00000000..2717631f --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/HashPrefix.h.ref @@ -0,0 +1,22 @@ +https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/HashPrefix.h +sha e3c8996e44921fe3b4e02c65cb41948848bcc7c5 +date 2026-09-05T00:06:14Z + +HashPrefix.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/e3c8996e44921fe3b4e02c65cb41948848bcc7c5/include/xrpl/protocol/HashPrefix.h \ + | diff - Tests/Xrpl.Tests/Fixtures/HashPrefix.h + +This is where the protocol states the four bytes that go in front of a signing +preimage. A wrong value there is invisible to every local test: the SDK signs and +verifies with the same constant, agrees with itself, and only a node refuses the +signature. Reading the values out of this file is what makes +TestURoleSigningPrefixes a check rather than a restatement. + +Pinned to a develop commit rather than to a release tag, like ledger_entries.macro +and transactions.macro: the role prefixes exist only after fixCleanup3_4_0, and a +3.3.0 tag would report them as values the SDK invented. + +Do not hand-edit it. When protocol-watch reports a change to this file upstream, +replace it wholesale and update the sha above. diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 2636d239..071af6cd 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -481,8 +481,16 @@ public static async Task WaitForCloseTimeAsync( TestNodeType? nodeType = null, TimeSpan? budget = null) { - TimeSpan limit = budget ?? TimeSpan.FromSeconds(120); System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew(); + DateTime seenFirst = await ValidatedCloseTimeAsync(client); + + // The budget follows the wait rather than a constant, because the two are the same + // quantity seen from opposite ends: a mark 180 s away cannot be reached inside 120 s, + // and a fixed default turns that into "the node stalled". The slack covers the ledger + // the mark has to be strictly passed by, plus the poll interval. + TimeSpan limit = budget ?? (seenFirst >= target + ? TimeSpan.FromSeconds(30) + : target - seenFirst + TimeSpan.FromSeconds(60)); while (true) { diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs index 79f28221..c3792c6a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs @@ -73,8 +73,11 @@ public async Task TestClosedEndedVault_Phases() XrplWallet wallet = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + // 30 s of subscription phase, because the window has to cover the VaultCreate and the + // deposit that follows, and each waits for a validated ledger of its own; the same pair + // is budgeted the same way in TestILoanBase. 180 s of investment is rippled's minimum. DateTime closeTime = await IntegrationTestConfig.ValidatedCloseTimeAsync(client); - DateTime subscriptionDate = WholeSeconds(closeTime.AddSeconds(20)); + DateTime subscriptionDate = WholeSeconds(closeTime.AddSeconds(30)); DateTime redemptionDate = subscriptionDate.AddSeconds(180); VaultCreate createTx = new VaultCreate diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs index 9d70db61..edc124be 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -33,14 +33,12 @@ public static async Task ClassInitializeAsync(TestContext testContext) } [TestInitialize] - public async Task CheckSponsorAmendment() + public void CheckSponsorAmendment() { if (!sponsorAmendmentActive) { Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node; bump the nightly in .ci-config/Dockerfile.nightly and uncomment Sponsor in rippled.batchv11.cfg to run these tests."); } - - await AmendmentGuard.RequireRoleSignaturesAsync(client); } @@ -131,6 +129,9 @@ public async Task TestSponsorshipSet_NegativeDeltas_ReduceTheBudget() [TestMethod] public async Task TestSponsoredPayment_SponsorPaysFee() { + // This one carries a SponsorSignature; the SponsorshipSet tests in this class do not + await AmendmentGuard.RequireRoleSignaturesAsync(client); + XrplWallet sponsor = XrplWallet.Generate(); XrplWallet sponsee = XrplWallet.Generate(); XrplWallet destination = XrplWallet.Generate(); @@ -202,6 +203,9 @@ public async Task TestSponsoredPayment_SponsorPaysFee() [TestMethod] public async Task Unified_StandardSignBothSides_V3() { + // This one carries a SponsorSignature; the SponsorshipSet tests in this class do not + await AmendmentGuard.RequireRoleSignaturesAsync(client); + var (sponsor, sponsee, destination) = await SetupSponsorshipAsync(); Payment payment = await client.Autofill(SponsoredPayment(sponsee, destination, sponsor)); @@ -230,6 +234,9 @@ public async Task Unified_StandardSignBothSides_V3() [TestMethod] public async Task Unified_SmartSubmit_SponsorFinalizes() { + // This one carries a SponsorSignature; the SponsorshipSet tests in this class do not + await AmendmentGuard.RequireRoleSignaturesAsync(client); + var (sponsor, sponsee, destination) = await SetupSponsorshipAsync(); Payment payment = await client.Autofill(SponsoredPayment(sponsee, destination, sponsor)); @@ -254,6 +261,9 @@ public async Task Unified_SmartSubmit_SponsorFinalizes() [TestMethod] public async Task Unified_SubmitAndWaitSponsored_OneCall() { + // This one carries a SponsorSignature; the SponsorshipSet tests in this class do not + await AmendmentGuard.RequireRoleSignaturesAsync(client); + var (sponsor, sponsee, destination) = await SetupSponsorshipAsync(); Payment payment = SponsoredPayment(sponsee, destination, sponsor); @@ -268,6 +278,9 @@ public async Task Unified_SubmitAndWaitSponsored_OneCall() [TestMethod] public async Task Unified_SmartSubmit_RequireSign_FailsFastWithoutSponsorSignature() { + // This one carries a SponsorSignature; the SponsorshipSet tests in this class do not + await AmendmentGuard.RequireRoleSignaturesAsync(client); + var (sponsor, sponsee, destination) = await SetupSponsorshipAsync( SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee); diff --git a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs index a6147120..27dfe9a0 100644 --- a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs +++ b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs @@ -25,6 +25,7 @@ public class TestULoanCounterpartyMultisign private static readonly XrplWallet Signer1 = XrplWallet.Generate(); private static readonly XrplWallet Signer2 = XrplWallet.Generate("secp256k1"); private static readonly XrplWallet Stranger = XrplWallet.Generate(); + private static readonly XrplWallet Sponsor = XrplWallet.Generate(); private const string BrokerId = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -92,9 +93,12 @@ public void TestUCombine_MultisigBorrower_EntriesLandInCounterpartySignature() public void TestUCompose_UnlistedSigner_StaysOnTheBrokerSide() { Dictionary prepared = Prepared(); + // The broker signs multisig too, so the transaction cannot say which side an entry + // belongs to and each signer states it: the borrower's signer names the counterparty + // role, the broker's takes the default. prepared["SigningPubKey"] = ""; SignatureResult brokerSigner = Stranger.Sign(new Dictionary(prepared), multisign: true); - SignatureResult borrowerSigner = Signer1.Sign(new Dictionary(prepared), multisign: true); + SignatureResult borrowerSigner = Signer1.Sign(new Dictionary(prepared), true, null, SignatureRole.Counterparty); SignatureResult composed = SignatureComposer.ComposeSignatures( new[] { brokerSigner.TxBlob, borrowerSigner.TxBlob }, @@ -131,8 +135,11 @@ public void TestUCompose_SignerOnSponsorAndCounterpartySides_Throws() public void TestUCompose_TwoArgumentOverload_RoutesSponsorSideOnly() { Dictionary prepared = Prepared(); + // A sponsor to route to, and a main signature that is multi-signed as well, so the + // sponsor's signer has to name its role. + prepared["Sponsor"] = Sponsor.ClassicAddress; prepared["SigningPubKey"] = ""; - SignatureResult sponsorSigner = Signer1.Sign(new Dictionary(prepared), multisign: true); + SignatureResult sponsorSigner = Signer1.Sign(new Dictionary(prepared), true, null, SignatureRole.Sponsor); SignatureResult otherSigner = Stranger.Sign(new Dictionary(prepared), multisign: true); SignatureResult composed = SignatureComposer.ComposeSignatures( diff --git a/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs b/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs index 0958b464..b90dc5ff 100644 --- a/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs +++ b/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -11,21 +14,79 @@ namespace Xrpl.Tests.Wallet.Tests { /// - /// The four-byte prefixes rippled's fixCleanup3_4_0 gives the signing roles, checked - /// against the protocol rather than against a captured output. + /// The four-byte prefixes that go in front of a signing preimage, read out of rippled's own + /// HashPrefix.h rather than restated. /// /// - /// A pinned blob cannot say whether a preimage is right: regenerate it from the same code and - /// it agrees with itself. What is checkable without a node is the shape of the change rippled - /// describes: a role preimage is the transaction's own preimage with a different four-byte - /// prefix, and the prefixes are ASCII tags built by makeHashPrefix in - /// include/xrpl/protocol/HashPrefix.h. Everything past those four bytes must be - /// identical, or the roles would be signing different transactions rather than the same one - /// in different capacities. + /// A wrong prefix is invisible to a test that names it: the SDK signs and verifies with the + /// same constant, agrees with itself, and only a node refuses the signature. So the values + /// come from the vendored header (Fixtures/HashPrefix.h, see its .ref) and the + /// shape of a role preimage is checked against the protocol's own rule - the transaction's + /// preimage with four bytes in front of it, and nothing else different. /// [TestClass] public class TestURoleSigningPrefixes { + /// rippled makeHashPrefix: three ASCII letters, then a zero byte. + private static readonly Regex Declaration = new Regex( + @"(?\w+)\s*=\s*detail::makeHashPrefix\('(?.)',\s*'(?.)',\s*'(?.)'\)", + RegexOptions.Compiled); + + private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "HashPrefix.h"); + + /// rippled's name for each prefix -> the name this SDK gives the same value. + private static readonly Dictionary Mapping = new(StringComparer.Ordinal) + { + ["TxSign"] = HashPrefix.TransactionSig, + ["TxMultiSign"] = HashPrefix.TransactionMultiSig, + ["CounterpartyTxSign"] = HashPrefix.CounterpartyTransactionSig, + ["CounterpartyTxMultiSign"] = HashPrefix.CounterpartyTransactionMultiSig, + ["SponsorTxSign"] = HashPrefix.SponsorTransactionSig, + ["SponsorTxMultiSign"] = HashPrefix.SponsorTransactionMultiSig, + ["Batch"] = HashPrefix.Batch, + ["PaymentChannelClaim"] = HashPrefix.PaymentChannelClaim, + }; + + private static Dictionary ParseUpstream() + { + if (!File.Exists(FixturePath)) + throw new InvalidOperationException($"Vendored HashPrefix.h not found at {FixturePath}"); + + string header = File.ReadAllText(FixturePath); + Dictionary values = new(StringComparer.Ordinal); + + foreach (Match match in Declaration.Matches(header)) + { + uint value = ((uint)match.Groups["a"].Value[0] << 24) + | ((uint)match.Groups["b"].Value[0] << 16) + | ((uint)match.Groups["c"].Value[0] << 8); + values[match.Groups["name"].Value] = value; + } + + // Guards the guard: a regex that stopped matching would make every assertion below + // pass over an empty table. + if (values.Count < Mapping.Count) + { + throw new InvalidOperationException( + $"Parsed only {values.Count} prefixes from the vendored HashPrefix.h; the header layout changed, " + + "update the parser before trusting this test"); + } + + return values; + } + + [TestMethod] + public void TestUPrefixes_MatchTheVendoredProtocolHeader() + { + Dictionary upstream = ParseUpstream(); + + foreach (KeyValuePair pair in Mapping) + { + Assert.IsTrue(upstream.ContainsKey(pair.Key), $"rippled declares no prefix named {pair.Key}"); + Assert.AreEqual(upstream[pair.Key], (uint)pair.Value, $"{pair.Key} (rippled) vs {pair.Value} (SDK)"); + } + } + private static JsonObject SampleTransaction() { XrplWallet submitter = XrplWallet.FromSeed("sEdVJXQmtqNy1pp8uMqsqgxMGL9QdzP"); @@ -43,20 +104,6 @@ private static JsonObject SampleTransaction() }; } - /// rippled makeHashPrefix: three ASCII letters, then a zero byte. - private static uint Tag(char a, char b, char c) => ((uint)a << 24) | ((uint)b << 16) | ((uint)c << 8); - - [TestMethod] - public void TestURolePrefixes_MatchTheProtocolTags() - { - Assert.AreEqual(Tag('S', 'T', 'X'), (uint)HashPrefix.TransactionSig, "TxSign"); - Assert.AreEqual(Tag('S', 'M', 'T'), (uint)HashPrefix.TransactionMultiSig, "TxMultiSign"); - Assert.AreEqual(Tag('C', 'P', 'T'), (uint)HashPrefix.CounterpartyTransactionSig, "CounterpartyTxSign"); - Assert.AreEqual(Tag('C', 'P', 'M'), (uint)HashPrefix.CounterpartyTransactionMultiSig, "CounterpartyTxMultiSign"); - Assert.AreEqual(Tag('S', 'P', 'N'), (uint)HashPrefix.SponsorTransactionSig, "SponsorTxSign"); - Assert.AreEqual(Tag('S', 'P', 'M'), (uint)HashPrefix.SponsorTransactionMultiSig, "SponsorTxMultiSign"); - } - [TestMethod] public void TestURolePreimage_DiffersFromTheTransactionOnlyInThePrefix() { diff --git a/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs b/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs index caee0cbe..cd122853 100644 --- a/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs +++ b/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs @@ -61,7 +61,7 @@ public void TestUSignSponsored_V1_BothSignaturesVerify() JsonObject preimageTx = decoded.DeepClone().AsObject(); preimageTx.Remove("SponsorSignature"); preimageTx.Remove("TxnSignature"); - byte[] preimage = SponsorSigningHelper.GetSigningPreimage(preimageTx); + byte[] preimage = SponsorSigningHelper.GetSponsorPreimage(preimageTx); Assert.IsTrue(XrplKeypairs.Verify(preimage, sponsorSig["TxnSignature"]!.GetValue(), sponsor.PublicKey), "SponsorSignature must verify over the sponsor preimage."); @@ -93,7 +93,7 @@ public void TestUSignSponsored_V2_CombineParallelSignatures() JsonObject preimageTx = decoded.DeepClone().AsObject(); preimageTx.Remove("SponsorSignature"); preimageTx.Remove("TxnSignature"); - byte[] preimage = SponsorSigningHelper.GetSigningPreimage(preimageTx); + byte[] preimage = SponsorSigningHelper.GetSponsorPreimage(preimageTx); Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["SponsorSignature"]!["TxnSignature"]!.GetValue(), sponsor.PublicKey), "Combined SponsorSignature must verify over the sponsor preimage."); @@ -120,7 +120,7 @@ public void TestUSignSponsored_V3_SequentialSigning() JsonObject preimageTx = decoded.DeepClone().AsObject(); preimageTx.Remove("SponsorSignature"); preimageTx.Remove("TxnSignature"); - byte[] preimage = SponsorSigningHelper.GetSigningPreimage(preimageTx); + byte[] preimage = SponsorSigningHelper.GetSponsorPreimage(preimageTx); Assert.IsTrue(XrplKeypairs.Verify(preimage, decoded["SponsorSignature"]!["TxnSignature"]!.GetValue(), sponsor.PublicKey)); Assert.IsTrue(XrplKeypairs.Verify(SubmitterPreimage(preimageTx), decoded["TxnSignature"]!.GetValue(), submitter.PublicKey)); @@ -159,8 +159,8 @@ public void TestUSponsorSignature_ExcludedFromPreimage_IncludedInBlob() withoutSig.Remove("SponsorSignature"); CollectionAssert.AreEqual( - SponsorSigningHelper.GetSigningPreimage(withoutSig), - SponsorSigningHelper.GetSigningPreimage(withSig), + SponsorSigningHelper.GetSponsorPreimage(withoutSig), + SponsorSigningHelper.GetSponsorPreimage(withSig), "SponsorSignature must not affect the signing preimage (kNotSigning)."); // ...but must round-trip through the binary encoding diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index e9770dca..10e7ba7f 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -36,6 +36,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/Xrpl/Wallet/LoanSigningHelper.cs b/Xrpl/Wallet/LoanSigningHelper.cs index 5ff62e11..d19ce636 100644 --- a/Xrpl/Wallet/LoanSigningHelper.cs +++ b/Xrpl/Wallet/LoanSigningHelper.cs @@ -199,7 +199,7 @@ private static void RequireLoanSet(string blob, string label) /// Computes the bytes the borrower signs into CounterpartySignature: the transaction /// under the counterparty prefix, which since fixCleanup3_4_0 is not what the broker signs. /// - internal static byte[] GetSigningPreimage(JsonObject txJson) + internal static byte[] GetCounterpartyPreimage(JsonObject txJson) => CoSigningEngine.GetSigningPreimage(txJson, CoSigningEngine.PrefixFor("CounterpartySignature")); } diff --git a/Xrpl/Wallet/SignatureComposer.cs b/Xrpl/Wallet/SignatureComposer.cs index e75d6067..6a190aed 100644 --- a/Xrpl/Wallet/SignatureComposer.cs +++ b/Xrpl/Wallet/SignatureComposer.cs @@ -5,10 +5,14 @@ using System.Text.Json.Nodes; using Xrpl.BinaryCodec; +using Xrpl.Keypairs; using Xrpl.Client.Exceptions; using Xrpl.Models.Transactions; using Xrpl.Utils.Hashes; +// Xrpl.Utils.Hashes declares a HashPrefix of its own; the codec's is the one that prefixes a signing preimage. +using HashPrefix = Xrpl.BinaryCodec.Hashing.HashPrefix; + namespace Xrpl.Wallet { /// @@ -196,10 +200,90 @@ void RouteEntry(JsonNode entry) } } + VerifyEverySignature(result, sections); + string txBlob = XrplBinaryCodec.Encode(result); return new SignatureResult(txBlob, HashLedger.HashSignedTx(txBlob)); } + /// + /// Checks every signature in the composed transaction against the bytes that transaction + /// actually ships, under the prefix of the section it ended up in. + /// + /// + /// Routing by account is a guess about what a signer meant, and since rippled's + /// fixCleanup3_4_0 a wrong guess is no longer harmless: an entry made for the transaction + /// itself covers different bytes than one made for a co-signing account, and only the node + /// used to notice. Where the transaction cannot say which side a multi-signature entry + /// belongs to - the main signature multi-signed as well - the signer chooses a default, + /// and this is where choosing wrong stops being silent. It catches the other direction + /// too: a part signed over a SigningPubKey the composed transaction does not ship. + /// + /// The preimage is taken from itself. TxnSignature, Signers and + /// both co-signature objects are not signing fields, so the codec drops them and what is + /// left is exactly what each participant signed. + /// + /// + private static void VerifyEverySignature(JsonObject result, IEnumerable sections) + { + if (result["TxnSignature"]?.GetValue() is { Length: > 0 } mainSignature) + { + RequireSingle(result, HashPrefix.TransactionSig, result["SigningPubKey"]?.GetValue(), mainSignature, + "the transaction's own signature"); + } + + foreach (JsonNode? entry in result["Signers"] as JsonArray ?? new JsonArray()) + RequireEntry(result, entry, HashPrefix.TransactionMultiSig, "Signers"); + + foreach (InnerSection section in sections) + { + if (result[section.Field] is not JsonObject inner) + continue; + + if (inner["Signers"] is JsonArray entries) + { + foreach (JsonNode? entry in entries) + RequireEntry(result, entry, CoSigningEngine.PrefixFor(section.Field, multiSigning: true), $"{section.Field}.Signers"); + } + else if (inner["TxnSignature"]?.GetValue() is { Length: > 0 } coSignature) + { + RequireSingle(result, CoSigningEngine.PrefixFor(section.Field), inner["SigningPubKey"]?.GetValue(), coSignature, section.Field); + } + } + } + + private static void RequireSingle(JsonObject result, HashPrefix prefix, string? publicKey, string signature, string what) + { + if (string.IsNullOrEmpty(publicKey)) + throw new ValidationException($"{what} carries a signature with no SigningPubKey."); + + byte[] preimage = AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForSigning(result, prefix)); + if (!XrplKeypairs.Verify(preimage, signature, publicKey)) + throw new ValidationException( + $"{what} does not verify over the composed transaction. It was signed over different bytes - a different transaction body, a different SigningPubKey, or another signing role."); + } + + private static void RequireEntry(JsonObject result, JsonNode? entry, HashPrefix prefix, string section) + { + JsonObject signer = entry?["Signer"]?.AsObject() + ?? throw new ValidationException($"A {section} entry is missing its Signer object."); + string account = signer["Account"]?.GetValue() + ?? throw new ValidationException($"A {section} entry is missing the Account field."); + string? publicKey = signer["SigningPubKey"]?.GetValue(); + string signature = signer["TxnSignature"]?.GetValue() + ?? throw new ValidationException($"The {section} entry of {account} is missing its TxnSignature."); + + if (string.IsNullOrEmpty(publicKey)) + throw new ValidationException($"The {section} entry of {account} carries no SigningPubKey."); + + byte[] preimage = AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForMultiSigning(result, account, prefix)); + if (!XrplKeypairs.Verify(preimage, signature, publicKey)) + throw new ValidationException( + $"The {section} entry of {account} does not verify over the composed transaction. " + + "A multi-signature entry covers the section it belongs to, so an entry signed for one side cannot be routed into another; " + + "where the transaction does not say which side, pass the role to Sign."); + } + /// /// Folds one part's inner signature object into its section. Entries /// pre-placed under the section's Signers keep their explicit role: the diff --git a/Xrpl/Wallet/SponsorSigningHelper.cs b/Xrpl/Wallet/SponsorSigningHelper.cs index 9796905a..2a1cd9bf 100644 --- a/Xrpl/Wallet/SponsorSigningHelper.cs +++ b/Xrpl/Wallet/SponsorSigningHelper.cs @@ -92,7 +92,13 @@ public static SignatureResult SubmitterSign(string partiallySignedBlob, XrplWall /// Computes the bytes the sponsor signs into SponsorSignature: the transaction under the /// sponsor prefix, which since fixCleanup3_4_0 is not what the submitter signs. /// - public static byte[] GetSigningPreimage(JsonObject txJson) + /// + /// Named for the role rather than kept as GetSigningPreimage: that name returned the + /// bytes of both signatures while they were the same, and leaving it would have changed + /// what a call means without changing how it compiles. The submitter's own bytes come from + /// . + /// + public static byte[] GetSponsorPreimage(JsonObject txJson) => CoSigningEngine.GetSigningPreimage(txJson, CoSigningEngine.PrefixFor("SponsorSignature")); internal static void VerifySponsorMatches(JsonObject tx, XrplWallet sponsorWallet) diff --git a/Xrpl/Wallet/XrplWallet.cs b/Xrpl/Wallet/XrplWallet.cs index 2ff366de..8b040c6d 100644 --- a/Xrpl/Wallet/XrplWallet.cs +++ b/Xrpl/Wallet/XrplWallet.cs @@ -657,17 +657,18 @@ public SignatureResult Sign( { GuardMemos(transaction); - if (!multisign) - { - return role switch - { - SignatureRole.Sponsor => SignAsSponsor(transaction), - SignatureRole.Counterparty => SignAsLoanCounterparty(transaction), - _ => Sign(transaction, false, signingFor), - }; - } + if (role is SignatureRole.Sponsor) + return multisign ? SignMulti(transaction, NormalizeClassic(signingFor), role) : SignAsSponsor(transaction); - return SignMulti(transaction, NormalizeClassic(signingFor), role); + if (role is SignatureRole.Counterparty) + return multisign ? SignMulti(transaction, NormalizeClassic(signingFor), role) : SignAsLoanCounterparty(transaction); + + // The transaction's own signature. A single one is produced directly rather than + // through the routing overload, which would send a wallet the transaction names as + // its Sponsor or Counterparty to the co-signature path - the opposite of what was + // asked for. A multi-signature entry goes back through that overload on purpose: it + // carries the Batch inner-signer routing, which this overload must not skip. + return multisign ? Sign(transaction, true, signingFor) : SignPlain(transaction); } /// @@ -741,41 +742,46 @@ public SignatureResult Sign(Dictionary transaction, bool multisi var signerAccount = NormalizeClassic(signingFor); return SignMulti(transaction, signerAccount); } - else + return SignPlain(transaction); + } + + /// + /// The transaction's own single signature, with none of the routing above: this is what + /// SignatureRole.Transaction asks for, and a wallet that a transaction names as its + /// Sponsor or Counterparty must be able to reach it. + /// + private SignatureResult SignPlain(Dictionary transaction) + { + if (transaction.ContainsKey("TxnSignature") || transaction.ContainsKey("Signers")) { - Dictionary tx = transaction; + throw new ValidationException("txJSON must not contain `TxnSignature` or `Signers` properties"); + } - if (tx.ContainsKey("TxnSignature") || tx.ContainsKey("Signers")) + JsonObject txToSignAndEncode = JsonNode.Parse(JsonSerializer.Serialize(transaction, XrplJsonOptions.Default))?.AsObject(); + + // A present inner co-signature (SponsorSignature / CounterpartySignature) + // was computed over a preimage that already carried the submitter's + // SigningPubKey — refuse to silently invalidate it + if (txToSignAndEncode.ContainsKey("SponsorSignature") || txToSignAndEncode.ContainsKey("CounterpartySignature")) + { + string existingPubKey = txToSignAndEncode["SigningPubKey"]?.GetValue(); + if (string.IsNullOrEmpty(existingPubKey)) { - throw new ValidationException("txJSON must not contain `TxnSignature` or `Signers` properties"); + throw new ValidationException("The co-signature was made over a multisig submitter form (empty SigningPubKey); a single main signature would invalidate it. Sign with multisign: true instead."); } - - JsonObject txToSignAndEncode = JsonNode.Parse(JsonSerializer.Serialize(transaction, XrplJsonOptions.Default))?.AsObject(); - - // A present inner co-signature (SponsorSignature / CounterpartySignature) - // was computed over a preimage that already carried the submitter's - // SigningPubKey — refuse to silently invalidate it - if (txToSignAndEncode.ContainsKey("SponsorSignature") || txToSignAndEncode.ContainsKey("CounterpartySignature")) + if (!string.Equals(existingPubKey, this.PublicKey, StringComparison.Ordinal)) { - string existingPubKey = txToSignAndEncode["SigningPubKey"]?.GetValue(); - if (string.IsNullOrEmpty(existingPubKey)) - { - throw new ValidationException("The co-signature was made over a multisig submitter form (empty SigningPubKey); a single main signature would invalidate it. Sign with multisign: true instead."); - } - if (!string.Equals(existingPubKey, this.PublicKey, StringComparison.Ordinal)) - { - throw new ValidationException("Transaction SigningPubKey does not match this wallet; the co-signer signed a different submitter's preimage."); - } + throw new ValidationException("Transaction SigningPubKey does not match this wallet; the co-signer signed a different submitter's preimage."); } + } - txToSignAndEncode["SigningPubKey"] = this.PublicKey; + txToSignAndEncode["SigningPubKey"] = this.PublicKey; - string signature = ComputeSignature(JsonSerializer.Deserialize>(txToSignAndEncode.ToJsonString(), XrplJsonOptions.Default), this.PrivateKey); - txToSignAndEncode["TxnSignature"] = signature; + string signature = ComputeSignature(JsonSerializer.Deserialize>(txToSignAndEncode.ToJsonString(), XrplJsonOptions.Default), this.PrivateKey); + txToSignAndEncode["TxnSignature"] = signature; - string serialized = XrplBinaryCodec.Encode(txToSignAndEncode); - return new SignatureResult(serialized, HashLedger.HashSignedTx(serialized)); - } + string serialized = XrplBinaryCodec.Encode(txToSignAndEncode); + return new SignatureResult(serialized, HashLedger.HashSignedTx(serialized)); } private string NormalizeClassic(string? signingFor) @@ -827,7 +833,19 @@ private static HashPrefix MultiSigningPrefix(JsonObject txBase, bool coSigningSi return HashPrefix.CounterpartyTransactionMultiSig; } - if (role is SignatureRole.Transaction || !coSigningSide) + if (role is SignatureRole.Transaction) + { + if (coSigningSide) + { + throw new ValidationException( + "The transaction carries a single main signature - SigningPubKey is set - so it has no Signers of its own, " + + "and a multi-signature entry on it can only belong to the Sponsor or the Counterparty."); + } + + return HashPrefix.TransactionMultiSig; + } + + if (!coSigningSide) return HashPrefix.TransactionMultiSig; if (hasSponsor && hasCounterparty) @@ -1318,7 +1336,7 @@ public SignatureResult SignAsLoanCounterparty(Dictionary transac tx.Remove("TxnSignature"); // The counterparty's preimage: the same transaction as the broker's, under its own prefix - byte[] signingBytes = LoanSigningHelper.GetSigningPreimage(tx); + byte[] signingBytes = LoanSigningHelper.GetCounterpartyPreimage(tx); // Sign the preimage with this wallet's key string sig = XrplKeypairs.Sign(signingBytes, this.PrivateKey); @@ -1382,7 +1400,7 @@ public SignatureResult SignAsSponsor(Dictionary transaction) tx.Remove("SponsorSignature"); tx.Remove("TxnSignature"); - byte[] signingBytes = SponsorSigningHelper.GetSigningPreimage(tx); + byte[] signingBytes = SponsorSigningHelper.GetSponsorPreimage(tx); string sig = XrplKeypairs.Sign(signingBytes, this.PrivateKey); tx["SponsorSignature"] = SignatureObject.Single(this.PublicKey, sig).ToJsonObject(); From f59126639b8e4e0c0a6883686925d081bb6ceda9 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Tue, 8 Sep 2026 14:36:49 -0300 Subject: [PATCH 08/11] fix(signing): a stated signing role survives the routing From a second opinion by another model on the signing subsystem, given the diff and nothing else. The role-aware overload dropped the role it exists to carry. Fixing the earlier finding that it skipped the Batch inner-signer routing, it delegated to the overload that has no role parameter - which then inferred the side from the transaction. On a sponsored transaction with a single main signature, a caller asking for the transaction's own entry got a sponsor-side one, with no error, and the guard written for exactly that contradiction was unreachable through the public API. The routing is now a private method both overloads share, and the role travels through it. - SponsorSigningHelper.GetSigningPreimage comes back as a member that refuses: [Obsolete(error: true)] plus a throw. Deleted outright it was MissingMethodException for a consumer built against the old assembly and "no such member" for one rebuilding; kept and returning the sponsor's bytes it would have compiled and produced signatures the node refuses - two XML comments promised that a caller must state the role where the main signature is multi-signed as well. The code takes the entry as the transaction's own there, which is the common case, and the composer catches a wrong choice. The comments now say what the code does Pinned by a test that fails on the previous code: a stated Transaction role on a sponsored transaction with a single main signature is refused rather than answered with a sponsor entry, and on a multi-signed one it produces an entry that verifies under the transaction's own prefix. Unit suite 1295 pass. --- .../Xrpl.Tests/Wallet/TestUSponsorSigning.cs | 42 +++++++++++++++++++ Xrpl/Wallet/SignatureRole.cs | 6 ++- Xrpl/Wallet/SponsorSigningHelper.cs | 19 +++++++++ Xrpl/Wallet/XrplWallet.cs | 40 +++++++++++++----- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs b/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs index cd122853..d9f550fd 100644 --- a/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs +++ b/Tests/Xrpl.Tests/Wallet/TestUSponsorSigning.cs @@ -126,6 +126,48 @@ public void TestUSignSponsored_V3_SequentialSigning() Assert.IsTrue(XrplKeypairs.Verify(SubmitterPreimage(preimageTx), decoded["TxnSignature"]!.GetValue(), submitter.PublicKey)); } + /// + /// A stated role reaches the prefix. It used to be dropped for a multi-signature entry: + /// the role-aware overload delegated to the one without a role, which then inferred the + /// side from the transaction and produced a sponsor entry for a caller who had asked for + /// the transaction's own. + /// + [TestMethod] + public void TestUStatedTransactionRole_IsNotSilentlyTurnedIntoASponsorEntry() + { + XrplWallet submitter = XrplWallet.Generate(); + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet destination = XrplWallet.Generate(); + XrplWallet signer = XrplWallet.Generate(); + + JsonObject tx = BuildSponsoredPayment(submitter, sponsor, destination); + System.Collections.Generic.Dictionary txDict = + System.Text.Json.JsonSerializer.Deserialize>( + tx.ToJsonString(), Xrpl.Client.Json.XrplJsonOptions.Default); + + // The transaction carries a single main signature, so it has no Signers of its own and + // the ask is a contradiction. Refusing is the point: the old path answered it with a + // sponsor-side entry and no error. + ValidationException refused = Assert.ThrowsExactly( + () => signer.Sign(txDict, true, signer.ClassicAddress, SignatureRole.Transaction)); + StringAssert.Contains(refused.Message, "no Signers of its own"); + + // With the main signature multi-signed the same ask is legitimate, and the entry is + // signed under the transaction's own prefix rather than the sponsor's. + txDict["SigningPubKey"] = ""; + SignatureResult entry = signer.Sign(txDict, true, signer.ClassicAddress, SignatureRole.Transaction); + + JsonObject decoded = XrplBinaryCodec.Decode(entry.TxBlob).AsObject(); + JsonObject forSigning = decoded.WithoutFields("TxnSignature", "Signers", "SponsorSignature"); + byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex( + XrplBinaryCodec.EncodeForMultiSigning(forSigning, signer.ClassicAddress)); + JsonObject placed = decoded["Signers"]!.AsArray()[0]!["Signer"]!.AsObject(); + + Assert.IsTrue( + XrplKeypairs.Verify(preimage, placed["TxnSignature"]!.GetValue(), placed["SigningPubKey"]!.GetValue()), + "the entry must verify under the transaction's own multisign prefix"); + } + [TestMethod] public void TestUSignAsSponsor_WrongSponsorAccount_Throws() { diff --git a/Xrpl/Wallet/SignatureRole.cs b/Xrpl/Wallet/SignatureRole.cs index 6d49d00d..fbf8abb1 100644 --- a/Xrpl/Wallet/SignatureRole.cs +++ b/Xrpl/Wallet/SignatureRole.cs @@ -13,8 +13,10 @@ namespace Xrpl.Wallet /// The SDK works the role out on its own wherever the transaction says it: a wallet named as /// the Sponsor signs as sponsor, a LoanSet Counterparty as counterparty, and a /// multi-signature entry on a transaction whose main signature is single can only belong to - /// the co-signing side. It is genuinely ambiguous in one shape only, where the main signature - /// and a co-signature are both multi-signed, and there the role has to be passed in. + /// the co-signing side. One shape it cannot read: where the main signature and a co-signature + /// are both multi-signed, an entry is taken as the transaction's own, and a signer on a + /// co-signing account's list states its role here. Choosing wrong is not silent - + /// verifies each entry against the section it lands in. /// /// public enum SignatureRole diff --git a/Xrpl/Wallet/SponsorSigningHelper.cs b/Xrpl/Wallet/SponsorSigningHelper.cs index 2a1cd9bf..2f9822d1 100644 --- a/Xrpl/Wallet/SponsorSigningHelper.cs +++ b/Xrpl/Wallet/SponsorSigningHelper.cs @@ -101,6 +101,25 @@ public static SignatureResult SubmitterSign(string partiallySignedBlob, XrplWall public static byte[] GetSponsorPreimage(JsonObject txJson) => CoSigningEngine.GetSigningPreimage(txJson, CoSigningEngine.PrefixFor("SponsorSignature")); + /// + /// Removed: the sponsor and the submitter no longer sign the same bytes, so one method + /// cannot mean both. Use for the sponsor's bytes and + /// for the submitter's. + /// + /// + /// Kept as a member that refuses rather than deleted outright. Deleted, a consumer built + /// against the old assembly binds to a method that is gone and fails with + /// MissingMethodException, and one rebuilding from source gets "no such member" with + /// nothing to act on. Kept and returning the sponsor bytes, a consumer using it for the + /// submitter's signature would keep compiling and start producing signatures the node + /// refuses - the silent outcome this whole change exists to remove. + /// + [Obsolete("Signing roles cover different bytes since rippled's fixCleanup3_4_0. Use GetSponsorPreimage() for the sponsor's signature, or XrplBinaryCodec.EncodeForSigning(tx) for the submitter's own.", error: true)] + public static byte[] GetSigningPreimage(JsonObject txJson) + => throw new NotSupportedException( + "SponsorSigningHelper.GetSigningPreimage returned the bytes of both the sponsor's and the submitter's signature while they were the same. " + + "They differ since rippled's fixCleanup3_4_0: use GetSponsorPreimage() for the sponsor, or XrplBinaryCodec.EncodeForSigning(tx) for the submitter."); + internal static void VerifySponsorMatches(JsonObject tx, XrplWallet sponsorWallet) { string sponsor = tx["Sponsor"]?.GetValue(); diff --git a/Xrpl/Wallet/XrplWallet.cs b/Xrpl/Wallet/XrplWallet.cs index 8b040c6d..7e8e3296 100644 --- a/Xrpl/Wallet/XrplWallet.cs +++ b/Xrpl/Wallet/XrplWallet.cs @@ -664,11 +664,14 @@ public SignatureResult Sign( return multisign ? SignMulti(transaction, NormalizeClassic(signingFor), role) : SignAsLoanCounterparty(transaction); // The transaction's own signature. A single one is produced directly rather than - // through the routing overload, which would send a wallet the transaction names as - // its Sponsor or Counterparty to the co-signature path - the opposite of what was - // asked for. A multi-signature entry goes back through that overload on purpose: it - // carries the Batch inner-signer routing, which this overload must not skip. - return multisign ? Sign(transaction, true, signingFor) : SignPlain(transaction); + // through the routing above, which would send a wallet the transaction names as its + // Sponsor or Counterparty to the co-signature path - the opposite of what was asked + // for. A multi-signature entry keeps that routing, because a Batch inner signer needs + // it, and carries the role with it: delegating to the overload that has no role would + // hand the entry back to the inference this argument exists to override. + return multisign + ? SignRouted(transaction, true, signingFor, SignatureRole.Transaction) + : SignPlain(transaction); } /// @@ -685,6 +688,18 @@ public SignatureResult Sign( public SignatureResult Sign(Dictionary transaction, bool multisign = false, string? signingFor = null) { GuardMemos(transaction); + return SignRouted(transaction, multisign, signingFor, null); + } + + /// + /// The routing every signature goes through: Batch inner signers, the sponsor and + /// counterparty co-signature paths, then the multi-signature or single-signature form. + /// + /// + /// The role stated by the caller, or null to work it out from the transaction. + /// + private SignatureResult SignRouted(Dictionary transaction, bool multisign, string? signingFor, SignatureRole? role) + { // 1) special case: Batch inner part if (string.Equals($"{transaction[nameof(ITransactionCommon.TransactionType)]}", "Batch", StringComparison.OrdinalIgnoreCase)) @@ -740,7 +755,7 @@ public SignatureResult Sign(Dictionary transaction, bool multisi { // The SIGNER's address, not the owner's. Convert an X-address if one arrived. var signerAccount = NormalizeClassic(signingFor); - return SignMulti(transaction, signerAccount); + return SignMulti(transaction, signerAccount, role); } return SignPlain(transaction); } @@ -807,11 +822,14 @@ private SignatureResult SignMulti(Dictionary transaction, string /// It is worked out from the transaction wherever the transaction says it. A main /// signature that is itself multi-signed leaves SigningPubKey empty, so a /// non-empty one on a transaction naming a Sponsor or a Counterparty means the entry can - /// only belong to that co-signing side. When both a Sponsor and a Counterparty are named - /// on such a transaction, or when the main signature is multi-signed as well, the - /// transaction does not say, and the caller has to pass - /// through the - /// overload. + /// only belong to that co-signing side. Two shapes it cannot read, and they end + /// differently. Both a Sponsor and a Counterparty named on such a transaction is refused, + /// because either answer would be a guess. A main signature that is multi-signed as well + /// leaves no marker at all, so the entry is taken as the transaction's own - the common + /// case - and a signer on a co-signing account's list has to say so through the + /// overload. + /// Choosing wrong there is not silent: verifies each + /// entry against the section it is routed into. /// /// private static HashPrefix MultiSigningPrefix(JsonObject txBase, bool coSigningSide, SignatureRole? role) From 392704b5846ba1438b0ebcccf7ad2f783c3f74c3 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Tue, 8 Sep 2026 14:44:05 -0300 Subject: [PATCH 09/11] refactor(signing): drop the refusing GetSigningPreimage stub The renamed method is removed outright rather than kept as an [Obsolete] member that throws. That stub was out of step with this repository's own policy, stated twice in the changelog for earlier breaks: no [Obsolete] bridges. A member that refuses is still a member on the public surface, and the compile error a removal gives is the migration notice. Unit suite 1295 pass; no caller of the old name remains. --- CHANGES.md | 2 +- Xrpl/Wallet/SponsorSigningHelper.cs | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index dd6bb55e..00742739 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,7 +32,7 @@ * **what breaks:** a signature this release makes is rejected by a node that has `Sponsor` or `LendingProtocol` enabled but not `fixCleanup3_4_0`. No public network is in that state - on mainnet and testnet none of the three is enabled, on devnet all of them are - so the affected combination is a private node on a release older than the amendment. The older scheme is not carried, and nothing asks the node which one it wants: signing stays offline * the one place the combination does occur is this repository's own CI stand, a 3.3.0 image with `Sponsor` and `LendingProtocol` voted in at genesis, so the integration tests that produce a role signature now skip there through `AmendmentGuard` and run on the nightly stand instead. Sixty of them, measured: the CI stand ran 346 integration tests before this release and runs 289, with 60 skipped. The guard sits on the tests that actually make a role signature, not on their classes - the `SponsorshipSet` tests carry none and keep running there. It is coverage deferred rather than lost, on a stand rather than in the suite, and the guard turns it back on by itself once the CI stand moves to a release carrying the amendment * **the composer now checks what it composes.** Routing an entry by account is a guess about what its signer meant, and since the amendment a wrong guess is no longer harmless. `ComposeSignatures` verifies every signature in the finished transaction against the bytes that transaction ships, under the prefix of the section the signature landed in, and names the account and the section when one does not verify. It catches the shape the transaction cannot express - the main signature multi-signed as well, so an entry could belong to either side - and the other direction too, a part signed over a `SigningPubKey` the composed transaction does not carry. Both were found by a cold review of this branch, on two models independently, and both used to reach the node as a transaction the caller believed was signed - * `SponsorSigningHelper.GetSigningPreimage` is now `GetSponsorPreimage`, and the internal loan one `GetCounterpartyPreimage`. The name returned the bytes of both signatures while they were the same; keeping it would have changed what a call means without changing how it compiles + * `SponsorSigningHelper.GetSigningPreimage` is now `GetSponsorPreimage`, and the internal loan one `GetCounterpartyPreimage`. The name returned the bytes of both signatures while they were the same; keeping it would have changed what a call means without changing how it compiles. No `[Obsolete]` bridge, the same policy the breaks above it follow: a member that refuses is still a member on the public surface, and the compile error a removal gives is the migration notice * pinned by unit tests that read the prefixes out of rippled's own `HashPrefix.h`, vendored as a fixture beside the other protocol files, and assert that a role preimage is the transaction's preimage with four bytes changed and nothing else - a pinned blob cannot answer that question, since regenerating it from the same code only agrees with itself. What settles it is the node: on the nightly stand `TestILoan` passes 18 of 18 and the sponsorship classes are green, where before this release every one of them was refused with `Invalid signature` * two rules of the same amendment surfaced once the tests could reach them, and are in the tests rather than in the SDK: a loan may only be impaired once a payment is actually late, and a payment on an overdue loan must carry `tfLoanLatePayment` or it is `tecEXPIRED` diff --git a/Xrpl/Wallet/SponsorSigningHelper.cs b/Xrpl/Wallet/SponsorSigningHelper.cs index 2f9822d1..dd506b10 100644 --- a/Xrpl/Wallet/SponsorSigningHelper.cs +++ b/Xrpl/Wallet/SponsorSigningHelper.cs @@ -101,24 +101,6 @@ public static SignatureResult SubmitterSign(string partiallySignedBlob, XrplWall public static byte[] GetSponsorPreimage(JsonObject txJson) => CoSigningEngine.GetSigningPreimage(txJson, CoSigningEngine.PrefixFor("SponsorSignature")); - /// - /// Removed: the sponsor and the submitter no longer sign the same bytes, so one method - /// cannot mean both. Use for the sponsor's bytes and - /// for the submitter's. - /// - /// - /// Kept as a member that refuses rather than deleted outright. Deleted, a consumer built - /// against the old assembly binds to a method that is gone and fails with - /// MissingMethodException, and one rebuilding from source gets "no such member" with - /// nothing to act on. Kept and returning the sponsor bytes, a consumer using it for the - /// submitter's signature would keep compiling and start producing signatures the node - /// refuses - the silent outcome this whole change exists to remove. - /// - [Obsolete("Signing roles cover different bytes since rippled's fixCleanup3_4_0. Use GetSponsorPreimage() for the sponsor's signature, or XrplBinaryCodec.EncodeForSigning(tx) for the submitter's own.", error: true)] - public static byte[] GetSigningPreimage(JsonObject txJson) - => throw new NotSupportedException( - "SponsorSigningHelper.GetSigningPreimage returned the bytes of both the sponsor's and the submitter's signature while they were the same. " + - "They differ since rippled's fixCleanup3_4_0: use GetSponsorPreimage() for the sponsor, or XrplBinaryCodec.EncodeForSigning(tx) for the submitter."); internal static void VerifySponsorMatches(JsonObject tx, XrplWallet sponsorWallet) { From fd4738ef8a6ce7811101ef8a4ef9decd6e0a7a1f Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Wed, 9 Sep 2026 09:58:17 -0300 Subject: [PATCH 10/11] fix(ci,test): a diff that never ran is not a verdict, and a cached refusal is not an answer Second opinion from another model on the parts of this branch it had not seen: the integration tests, the guards and the nightly-pin workflow. The workflow finding is the same defect twice, and the second half was mine. Its verdict was the exit code of `dotnet run`, which answers 1 both for drift and for a build that never produced a diff, so a failed build with no matching output still reached the PR body as "no differences reported". The verdict is now the tool's own Summary line: no Summary, no answer. And the comment claiming the output "stays text this file chose" was false for the whole success branch - the matched lines carry field names the node chose. Every step output now reaches the PR body through the environment instead of a ${{ }} pasted into the shell source, which also closes the same hole on the two amendment lists that predate this branch. - both amendment guards cached a negative. IsEnabledAsync answers false for "the node does not have it" and for "the node refused the question" alike, so one transient error made a whole run report as skipped, or sent every later broker to the open-ended path the amendment refuses. Only a yes is remembered now - EnterInvestmentPhaseAsync treated a ledger_entry that came back as anything else as an open-ended vault and skipped the wait. It now says so - past the subscription date is not the same as inside the investment phase. Overshooting into redemption made the LoanSet that follows fail with tecEXPIRED, reading as a protocol refusal rather than a missed window; the phase test would have reported it as its own tecTOO_SOON assertion failing. Both now check the upper bound and say which happened - the comment on the subscription window described time left after the vault exists; the clock starts at the close-time read before it Not changed, with the reason: adding a member to IVaultCreate, IVaultWithdraw and ILoanBrokerCoverWithdraw does break an outside implementation of those interfaces, and that is in CHANGES now. A default body is not the fix - on a data contract it would have to accept a value and drop it. The claim that WaitForCloseTimeAsync can wait forever does not hold either: the client's RequestTimeout is 40 s, so a silent node ends the wait with its own exception; the budget can be overshot by one request, and the remark now says so. Verified. Unit 1295. Nightly stand (xrpld 3.4.0-rc1) 87 of 87. CI stand (3.3.0) 289 pass, 60 skip, 0 fail. --- .github/workflows/nightly-pin-watch.yml | 26 ++++++++------ CHANGES.md | 1 + .../Xrpl.Tests/Integration/AmendmentGuard.cs | 7 +++- Tests/Xrpl.Tests/Integration/Utils.cs | 3 ++ .../transactions/TestIBatchSponsorship.cs | 1 - .../transactions/TestIClosedEndedVault.cs | 9 ++++- .../Integration/transactions/TestILoanBase.cs | 35 +++++++++++++++---- 7 files changed, 63 insertions(+), 19 deletions(-) diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml index fe140625..a2517894 100644 --- a/.github/workflows/nightly-pin-watch.yml +++ b/.github/workflows/nightly-pin-watch.yml @@ -195,14 +195,13 @@ jobs: out=$(dotnet run --project Tools/GenerateEnums -- diff http://localhost:5005 2>&1) status=$? echo "$out" - # 0 is in sync and 1 is drift; anything else is the tool failing to answer at all, - # and an empty grep would then reach the PR body as "no differences reported". - # What goes into the output stays text this file chose: the PR-body step pastes it - # into its own shell source, where a quote from a stack trace ends the string and a - # $(...) in it runs. The tool's own output is above, in this step's log. - if [ "$status" -eq 0 ] || [ "$status" -eq 1 ]; then + # The verdict is the tool's own Summary line, not the exit code: `dotnet run` answers 1 + # both for drift and for a build that never produced a diff, and reading the code alone + # would report the second as "no differences" - the reassuring answer for the one case + # that has no answer. No Summary line means the tool did not get that far. + verdict=$(printf '%s\n' "$out" | grep -E '^Summary') + if [ -n "$verdict" ]; then summary=$(printf '%s\n' "$out" | grep -E 'node-only|mismatch|^Summary') - [ -n "$summary" ] || summary='no differences reported' else summary=$(printf 'the definitions diff did not run (exit %s), so this section says nothing about drift; the step log above has the output' "$status") fi @@ -227,6 +226,13 @@ jobs: NEW_REF: ${{ steps.check.outputs.new_ref }} AGE_DAYS: ${{ steps.check.outputs.age_days }} BRANCH: ${{ steps.decide.outputs.branch }} + # Through the environment rather than a ${{ }} in the script below: an expression is + # pasted into the shell source before bash sees it, so a quote in the value ends the + # string and a $(...) in it runs. AMENDMENTS_* hold generated names and DEFINITIONS_SUMMARY + # carries field names the node chose, which is not text this file gets to vouch for. + AMENDMENTS_ADDED: ${{ steps.bump.outputs.added }} + AMENDMENTS_REMOVED: ${{ steps.bump.outputs.removed }} + DEFINITIONS_SUMMARY: ${{ steps.definitions.outputs.summary }} run: | set -euo pipefail git config user.name "${{ github.actor }}" @@ -242,10 +248,10 @@ jobs: 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 'Added:\n```\n%s\n```\n\n' "$AMENDMENTS_ADDED" + printf 'Removed:\n```\n%s\n```\n\n' "$AMENDMENTS_REMOVED" printf '## definitions.json vs the new build\n\n' - printf '```\n%s\n```\n\n' "${{ steps.definitions.outputs.summary }}" + printf '```\n%s\n```\n\n' "$DEFINITIONS_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' diff --git a/CHANGES.md b/CHANGES.md index 00742739..1d75e64b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,6 +19,7 @@ * **closed-ended vaults** (rippled #7921, LendingProtocolV1_1): `VaultCreate` and `LOVault` carry `VaultKind`, `SubscriptionDate` and `RedemptionDate`, and the `VaultKind` enum names the two kinds. `ValidateVaultCreate` pins rippled's preflight: the dates only on a closed-ended vault, both of them, with the redemption at least three minutes and less than thirty years after the subscription (rippled #8151 raised the floor from one minute; caught by running the closed-ended flow on the nightly stand). Deposits are accepted in the subscription phase only, withdrawals in every phase but investment * **confidential MPT key rotation** (rippled #7915, ConfidentialMPTKeyRotation): `LOMPTokenIssuance` carries `IssuerKeyEpoch` and `AuditorKeyEpoch`, incremented each time `MPTokenIssuanceSet` replaces the key. The transaction is unchanged - the same `IssuerEncryptionKey`/`AuditorEncryptionKey` fields rotate a key once the amendment is active, and the current key is refused with `tecDUPLICATE`. `IssuerKeyMirrorEpoch`, `AuditorKeyMirrorEpoch` and `ContractResult` (rippled #7988) are known to the codec but belong to no format yet * `VaultWithdraw` and `LoanBrokerCoverWithdraw` accept `CredentialIDs`, for a `Destination` that requires deposit authorization; validated the way `Payment.CredentialIDs` is + * a protocol field is a member on the transaction's interface as well as on its classes, so `IVaultCreate`, `IVaultWithdraw` and `ILoanBrokerCoverWithdraw` each gained one. Anything outside the SDK that implements one of those interfaces - an adapter, a test double - stops compiling until it declares the new member. No default bodies: on a data contract a default would have to accept a value and drop it, which is the silent outcome these interfaces exist to avoid, and unlike `IXrplClient` nothing implements them to add behaviour * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and `TestIClosedEndedVault` drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no `VaultKind` on the ledger, and a `VaultWithdraw` to a deposit-authorized destination that is `tecNO_PERMISSION` without `CredentialIDs` and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know * the Loan integration suite follows the amendment too. Under LendingProtocolV1_1 `LoanBrokerSet` refuses an open-ended vault ("LoanBroker requires a closed-ended Vault", `tecNO_PERMISSION`) and every Loan test built its broker on one, so all 18 of `TestILoan` failed on the nightly stand - identically on untouched `dev`, which is what said it was the node's rule rather than a regression. `TestILoanBase` now creates a closed-ended vault where the node asks for one and waits for the investment phase before handing the broker back: rippled originates a loan only there, while the vault deposit that funds it is taken only in the subscription phase before it, and the two dates are measured from the ledger's close time rather than the machine's clock, which on a standalone stand is a different clock. A node without the amendment does not know the fields at all - it answers `invalidTransaction`, not a result code - so the open-ended path stays for it, chosen through `AmendmentGuard` diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index 391bef14..0d544a6e 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -84,7 +84,12 @@ public static class AmendmentGuard /// public static async Task RequireRoleSignaturesAsync(IXrplClient client) { - roleSignatures ??= await IsEnabledAsync(client, FixCleanup340); + // Only a yes is remembered. IsEnabledAsync answers false both for "the node does not have + // it" and for "the node refused the question", and caching the second would turn one + // transient error into a whole run reported as skipped. + if (roleSignatures != true) + roleSignatures = await IsEnabledAsync(client, FixCleanup340); + if (roleSignatures != true) Assert.Inconclusive("The node verifies a role signature the pre-fixCleanup3_4_0 way, over the transaction's own prefix; the SDK signs under the role prefix the amendment introduced. Run these on a stand carrying fixCleanup3_4_0 (.ci-config/docker-compose.batchv11.yml) or on devnet."); } diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 071af6cd..9c9d9480 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -473,6 +473,9 @@ public static async Task ValidatedCloseTimeAsync(IXrplClient client) /// Bounded, because a ledger that stops advancing is a node failure, and a test that waits /// on it forever reports nothing. The failure names the last close time seen and how far /// short of the mark it was, which separates a stalled node from a mark set too far ahead. + /// The budget is checked between requests, so a node that answers slowly can overshoot it + /// by one request; a node that stops answering ends the wait through the client's own + /// RequestTimeout instead, and with its exception rather than this diagnostic. /// /// public static async Task WaitForCloseTimeAsync( diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 1e2af190..0686e6f8 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -52,7 +52,6 @@ public async Task CheckSponsorAmendment() } await AmendmentGuard.RequireRoleSignaturesAsync(client); - } [ClassCleanup] diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs index c3792c6a..83a8942c 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs @@ -103,8 +103,15 @@ public async Task TestClosedEndedVault_Phases() // Subscription phase: deposits are accepted ValidateResult(await SubmitAsync(Deposit(wallet, vaultId), wallet)); - // Investment phase: neither deposits nor withdrawals + // Investment phase: neither deposits nor withdrawals. Past the subscription date is not + // the same as inside the phase - overshooting into redemption would let the withdrawal + // through and report it as the assertion failing, so the position is checked, not assumed. await IntegrationTestConfig.WaitForCloseTimeAsync(client, subscriptionDate, nodeType); + DateTime investmentNow = await IntegrationTestConfig.ValidatedCloseTimeAsync(client); + Assert.IsTrue( + investmentNow < redemptionDate, + $"the investment phase was missed: close time {investmentNow:O} is already at or past the redemption date {redemptionDate:O}"); + await AssertResultAsync("tecEXPIRED", () => SubmitAsync(Deposit(wallet, vaultId), wallet)); await AssertResultAsync("tecTOO_SOON", () => SubmitAsync(Withdraw(wallet, vaultId), wallet)); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index 912a83f7..dbeaef95 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs @@ -73,10 +73,10 @@ protected static string GetCreatedObjectId(TransactionResponse result, LedgerEnt #region Closed-ended vaults (LendingProtocolV1_1) /// - /// How much of the subscription phase is left once the vault exists. It has to cover the - /// VaultCreate itself and the deposit that follows: rippled accepts a vault deposit in the - /// subscription phase only - tecEXPIRED afterwards - and each of the two waits for a - /// ledger of its own. + /// How much subscription phase to buy, counted from the close time read while the VaultCreate + /// is still being built. It has to cover that read, the create, and the deposit that follows: + /// rippled accepts a vault deposit in the subscription phase only - tecEXPIRED + /// afterwards - and each of the two transactions waits for a ledger of its own. /// private const int SubscriptionWindowSeconds = 30; @@ -102,7 +102,12 @@ protected static string GetCreatedObjectId(TransactionResponse result, LedgerEnt /// protected static async Task ClosedEndedVaultRequiredAsync(IXrplClient client) { - closedEndedRequired ??= await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.LendingProtocolV11); + // Only a yes is remembered: a node that refused the question answers the same false as a + // node without the amendment, and caching that would send every later broker to the + // open-ended path the amendment refuses, one transient error turning into a red suite. + if (closedEndedRequired != true) + closedEndedRequired = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.LendingProtocolV11); + return closedEndedRequired.Value; } @@ -141,10 +146,28 @@ protected static async Task BuildBrokerVaultAsync( protected static async Task EnterInvestmentPhaseAsync(IXrplClient client, string vaultId) { LedgerEntryResponse entry = await client.LedgerEntry(new LedgerEntryRequest { Index = vaultId }).Typed(); - if (entry?.Node is not LOVault vault || vault.SubscriptionDate is not DateTime subscriptionDate) + if (entry?.Node is not LOVault vault) + throw new RippleException($"ledger_entry for vault {vaultId} did not come back as a Vault: {entry?.Node?.GetType().Name ?? "nothing"}"); + + // No SubscriptionDate is an open-ended vault, which has no phases to wait for + if (vault.SubscriptionDate is not DateTime subscriptionDate) return; await IntegrationTestConfig.WaitForCloseTimeAsync(client, subscriptionDate, nodeType); + + // Past the start of the phase is not the same as inside it. Landing past the redemption + // date instead would make the LoanSet that follows fail with tecEXPIRED, which reads as a + // protocol refusal rather than as this wait having missed its window. + if (vault.RedemptionDate is DateTime redemptionDate) + { + DateTime now = await IntegrationTestConfig.ValidatedCloseTimeAsync(client); + if (now >= redemptionDate) + { + throw new RippleException( + $"vault {vaultId} reached its redemption phase before a loan could be originated: " + + $"close time {now:O}, redemption {redemptionDate:O}. The investment window was too short for this run."); + } + } } #endregion From 91177eae5c953ed3b5193913834e68c6205107db Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Wed, 9 Sep 2026 14:17:53 -0300 Subject: [PATCH 11/11] build: this work becomes 11.5.0.0, out of the way of the release shipping today 11.4.0.0 is being published from dev today, so the entries written under that heading move to their own section and the two packages this branch changes go with them. The 11.4.0.0 section is byte-identical to dev's again, checked rather than eyeballed. Xrpl and Xrpl.BinaryCodec take the same number, as they do whenever both move. Xrpl.AddressCodec and Xrpl.Keypairs are untouched here and stay at 10.9.0.0. --- Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj | 2 +- CHANGES.md | 31 ++++++++++--------- Xrpl/Xrpl.csproj | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj index 61d1e738..f47c810f 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 - 11.4.0.0 + 11.5.0.0 diff --git a/CHANGES.md b/CHANGES.md index 1d75e64b..a2f4a73d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,19 +1,6 @@ # Changes -## 11.4.0.0 07/09/2026 - -* **A transition of the connection has one owner** (#179, the follow-up to #178). Every operation that moves the connection - `ChangeServer`, `Connect`, `Disconnect`, `DisconnectAndWaitAsync`, the health check's fast reconnect, the reconnect loop and the path taken when an `OnConnected` handler fails - used to decide for itself what happened to the socket, and two of them running at once were reconciled by `ReferenceEquals(ws, ...)` checks placed after whichever await somebody had noticed. #178 added three such checks and its review found the next window each time. The checks were right where they were; the pattern was what did not scale. - * the connection now carries a generation. A consumer command and the fast reconnect begin one, taking the session, the socket, the reconnect loop, the ping timer and the message processor out of their fields in a single critical section; the socket callbacks, the loop and the handler-failure path continue the generation of the socket they run for. An operation that finds the generation moved on stands down - after every await and after every consumer callback - and the one that moved it owns the rest. `Disconnect()` wins against anything in flight, and an attempt it overtook closes the socket it opened, whether the takeover found that socket installed or the socket came into being afterwards - * the four windows the issue lists are closed by that one mechanism. A `Disconnect()` landing in one of `ChangeServer`'s yields no longer gets overridden by the switch resetting it and connecting - the client was online after the consumer took it down. A status handler that answers `RestoringConnection` with a `ChangeServer` no longer has its replacement session marked retiring by the fast reconnect that ran the handler. The reconnect loop releases its claim under the same lock the close callback asks under, with the socket re-checked there, so a close processed as the loop exits either sees the loop released or is handled by it - nobody reconnecting is no longer an outcome. And a request is written under the lock the retirement takes the socket under, so a retirement finds it either not yet sent, and refused, or already handed to the socket - it no longer reaches a server the client has left - * for consumers: a `ChangeServer` that a later operation overtook reports it instead of returning success from a server the client is not on - `NotConnectedException` when a `Disconnect()` won, `OperationCanceledException` when another `ChangeServer` or a `Connect()` did. `Connect()` keeps its contract: it returns when the client is connected, wherever a concurrent switch took it, and `OperationCanceledException` still means the caller's own token. Options handed to `ChangeServer` are validated before the old connection is torn down rather than after - * the two loose ends from #178 are tied. `NotConnectedException` thrown bare carries a message that says what it is, and the immediate refusal under `RequestFailurePolicy.ImmediateFail` names the policy - since #178 that is the exception a request issued during a switch gets, where it used to get a `TimeoutException` with "Timeout" in it, and a consumer classifying by text had nothing to recognise. `WebSocketClient.SendMessage` no longer answers a socket that is not open with a `Connect()` - `ConnectAsync` on an already used `ClientWebSocket` throws, the catch disposed the socket and raised `OnConnectionError`, and the send went ahead regardless - and `SendMessageAsync` returns a task that faults when the message could not be written, so the request that owns it is rejected at once rather than left to `RequestTimeout`. Messages are serialized whole on the socket; two concurrent messages larger than the send chunk could interleave their frames before - * `OnSessionEnded` is owed whatever wins. A `ChangeServer` or fast reconnect that a `Disconnect()` overtakes before it announced the session it retired still announces it - the retirement silenced the socket's own close callback, and nothing else knows the session. `Disconnect()` announces `UserDisconnected` itself rather than leaving it to the close callback alone: a `Connect()` issued right after it installs a new session before the old socket's close is processed, and the callback then filed the close as a stale session and said nothing. And a takeover that finds no socket takes no session either - the session belongs to whoever took the socket, and a `Connect()` after a `Disconnect()` used to announce a loss of its own for a session the disconnect was about to announce - * a fast reconnect no longer runs a second full series after the loop gave up. With `StopAfterMaxAttempts`, the loop the fast reconnect's failure started ran out of attempts, reported `Disconnected` and released its source; the fast reconnect's own wait then failed with "failed permanently", which its catch read as one more failure to retry. And a `Disconnect()` that took a handshake still in flight no longer installs a completion source nobody completes - the cancelled handshake reports no close - so the next `DisconnectAndWaitAsync` returns at once instead of waiting out its timeout - * six older defects on the same paths, found by the cold review of this change and fixed with it because the change rewrites the code they live on. `OnceOpen` reported `Connected` and started a ping timer nothing would stop after a `Disconnect()` from inside the `OnConnected` handler. `Connect()` after a `Disconnect()` ran with the intentional-disconnect flag still set, so a server that was down read as "closed permanently" and nothing reconnected - `ChangeServer` was the only path that cleared it, and the flag now follows the generation. A handshake cancelled by a takeover reported nothing, so its attempt timer went on firing `OnConnectionFailed` for the dead socket at every `ConnectionAttemptTimeout`. And `Connect()` over a socket that was closing announced no session end and swept no requests, both of which the close callback would have done had `Connect()` not retired the session underneath it - * a failure of an established connection is reported once. The receive loop routed a failure that was not a network error - a frame the protocol forbids, or in the browser any failure at all, since its `ClientWebSocket` says nothing recognisable - through the handshake-failure callback as well as the close callback. The first announced "Initial connection failed" for a connection that had been up and in use, with an `OnDisconnect` that carried no code, and the second reported the real close. Now the close callback is the only reporter: it classifies the failure, announces the session end once and starts the reconnect. In the browser a `WebSocketException` on an open socket is classified as a network drop - the transport going away is the one failure it has - and an exception with no message is described by its error code - * a handshake this side cancelled is not reported a second time. The connect-attempt timer and a takeover cancel the socket after reporting, and in the browser the cancelled `ConnectAsync` throws `WebSocketException` ("ConnectFailure") rather than `OperationCanceledException`, which reached the connection-error callback as a second failure of the same attempt. Both found by driving the Blazor test client through a dropped connection and a connect timeout; neither is reachable from the .NET unit suite, where a cancelled handshake throws `OperationCanceledException` and a dropped connection arrives as a network error - * the documentation of `UseCheckHealth` and `InactivityTimeout` promised more than the code does: it said the health check on its own reconnects after sixty seconds without inbound data, while the inactivity check has always run only with `UseCustomPing` enabled - and deliberately so, since an idle connection with no subscriptions receives nothing by design, and silence without keepalive pings would declare a healthy socket dead every minute. The behaviour stays; the docs now say what it is. Found by driving the Blazor test client through a connection that stayed open and went silent - * pinned by tests that issue the second operation from a callback the first one runs, which lands it inside the first one's yields every time: a `Disconnect()` and a second `ChangeServer` from the session-ended handler of a `ChangeServer`, a `ChangeServer` from the `RestoringConnection` notification of the fast reconnect, a `Disconnect()` from the `OnConnected` handler, a `Connect()` after a `Disconnect()` against a server that comes up later, and a server that closes each connection the moment its handshake completes, so the reconnect loop's success and the close it has to survive arrive together +## 11.5.0.0 09/09/2026 * **The protocol schema follows rippled develop at `e3c8996e`, the 3.4.0-rc1 build the nightly stand is pinned to** (#182 bumps the pin). `definitions.json` had been synced for 3.3.0 and was eight fields behind develop, which definitions-watch had reported as node-only for three weeks. The nightly-pin bump that would have listed them opened with an empty "definitions.json vs the new build" section: the step inherits `bash -e` from the runner, and the diff exits 1 whenever it finds drift, so errexit ended the step at the assignment - before the report was echoed or recorded - in the one case the step exists for. Fixed alongside. * **closed-ended vaults** (rippled #7921, LendingProtocolV1_1): `VaultCreate` and `LOVault` carry `VaultKind`, `SubscriptionDate` and `RedemptionDate`, and the `VaultKind` enum names the two kinds. `ValidateVaultCreate` pins rippled's preflight: the dates only on a closed-ended vault, both of them, with the redemption at least three minutes and less than thirty years after the subscription (rippled #8151 raised the floor from one minute; caught by running the closed-ended flow on the nightly stand). Deposits are accepted in the subscription phase only, withdrawals in every phase but investment @@ -21,7 +8,7 @@ * `VaultWithdraw` and `LoanBrokerCoverWithdraw` accept `CredentialIDs`, for a `Destination` that requires deposit authorization; validated the way `Payment.CredentialIDs` is * a protocol field is a member on the transaction's interface as well as on its classes, so `IVaultCreate`, `IVaultWithdraw` and `ILoanBrokerCoverWithdraw` each gained one. Anything outside the SDK that implements one of those interfaces - an adapter, a test double - stops compiling until it declares the new member. No default bodies: on a data contract a default would have to accept a value and drop it, which is the silent outcome these interfaces exist to avoid, and unlike `IXrplClient` nothing implements them to add behaviour * the vendored `transactions.macro` is pinned to the same develop commit as `ledger_entries.macro` instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do - * `Xrpl.BinaryCodec` 11.4.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and `TestIClosedEndedVault` drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no `VaultKind` on the ledger, and a `VaultWithdraw` to a deposit-authorized destination that is `tecNO_PERMISSION` without `CredentialIDs` and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know + * `Xrpl.BinaryCodec` 11.5.0.0 for the new codec entries, numbered with `Xrpl` since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and `TestIClosedEndedVault` drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no `VaultKind` on the ledger, and a `VaultWithdraw` to a deposit-authorized destination that is `tecNO_PERMISSION` without `CredentialIDs` and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know * the Loan integration suite follows the amendment too. Under LendingProtocolV1_1 `LoanBrokerSet` refuses an open-ended vault ("LoanBroker requires a closed-ended Vault", `tecNO_PERMISSION`) and every Loan test built its broker on one, so all 18 of `TestILoan` failed on the nightly stand - identically on untouched `dev`, which is what said it was the node's rule rather than a regression. `TestILoanBase` now creates a closed-ended vault where the node asks for one and waits for the investment phase before handing the broker back: rippled originates a loan only there, while the vault deposit that funds it is taken only in the subscription phase before it, and the two dates are measured from the ledger's close time rather than the machine's clock, which on a standalone stand is a different clock. A node without the amendment does not know the fields at all - it answers `invalidTransaction`, not a result code - so the open-ended path stays for it, chosen through `AmendmentGuard` * what that unblocks, and what it does not: on the nightly stand `TestILoan` goes from 0 of 18 to 7 of 18, and all 11 that still failed reported `Counterparty: Invalid signature` - the role signing prefixes `fixCleanup3_4_0` introduces, which is what the entry above this one goes on to implement, and which was the whole of what remained. `TestISponsoredVaultLoan` is blocked by the same thing on 3.4.x, at `Sponsor: Invalid signature`. Neither failure was about vaults any more, which is what this entry set out to establish; the signatures are the subject of the entry above, and are green there. The CI stand (3.3.0) is untouched by any of it; what the suite looks like there once the signing change lands is counted in the entry above * `ValidatedCloseTimeAsync` and `WaitForCloseTimeAsync` moved to `IntegrationTestConfig`: three test classes now need the ledger clock, and each had been carrying its own copy @@ -37,6 +24,20 @@ * pinned by unit tests that read the prefixes out of rippled's own `HashPrefix.h`, vendored as a fixture beside the other protocol files, and assert that a role preimage is the transaction's preimage with four bytes changed and nothing else - a pinned blob cannot answer that question, since regenerating it from the same code only agrees with itself. What settles it is the node: on the nightly stand `TestILoan` passes 18 of 18 and the sponsorship classes are green, where before this release every one of them was refused with `Invalid signature` * two rules of the same amendment surfaced once the tests could reach them, and are in the tests rather than in the SDK: a loan may only be impaired once a payment is actually late, and a payment on an overdue loan must carry `tfLoanLatePayment` or it is `tecEXPIRED` +## 11.4.0.0 07/09/2026 + +* **A transition of the connection has one owner** (#179, the follow-up to #178). Every operation that moves the connection - `ChangeServer`, `Connect`, `Disconnect`, `DisconnectAndWaitAsync`, the health check's fast reconnect, the reconnect loop and the path taken when an `OnConnected` handler fails - used to decide for itself what happened to the socket, and two of them running at once were reconciled by `ReferenceEquals(ws, ...)` checks placed after whichever await somebody had noticed. #178 added three such checks and its review found the next window each time. The checks were right where they were; the pattern was what did not scale. + * the connection now carries a generation. A consumer command and the fast reconnect begin one, taking the session, the socket, the reconnect loop, the ping timer and the message processor out of their fields in a single critical section; the socket callbacks, the loop and the handler-failure path continue the generation of the socket they run for. An operation that finds the generation moved on stands down - after every await and after every consumer callback - and the one that moved it owns the rest. `Disconnect()` wins against anything in flight, and an attempt it overtook closes the socket it opened, whether the takeover found that socket installed or the socket came into being afterwards + * the four windows the issue lists are closed by that one mechanism. A `Disconnect()` landing in one of `ChangeServer`'s yields no longer gets overridden by the switch resetting it and connecting - the client was online after the consumer took it down. A status handler that answers `RestoringConnection` with a `ChangeServer` no longer has its replacement session marked retiring by the fast reconnect that ran the handler. The reconnect loop releases its claim under the same lock the close callback asks under, with the socket re-checked there, so a close processed as the loop exits either sees the loop released or is handled by it - nobody reconnecting is no longer an outcome. And a request is written under the lock the retirement takes the socket under, so a retirement finds it either not yet sent, and refused, or already handed to the socket - it no longer reaches a server the client has left + * for consumers: a `ChangeServer` that a later operation overtook reports it instead of returning success from a server the client is not on - `NotConnectedException` when a `Disconnect()` won, `OperationCanceledException` when another `ChangeServer` or a `Connect()` did. `Connect()` keeps its contract: it returns when the client is connected, wherever a concurrent switch took it, and `OperationCanceledException` still means the caller's own token. Options handed to `ChangeServer` are validated before the old connection is torn down rather than after + * the two loose ends from #178 are tied. `NotConnectedException` thrown bare carries a message that says what it is, and the immediate refusal under `RequestFailurePolicy.ImmediateFail` names the policy - since #178 that is the exception a request issued during a switch gets, where it used to get a `TimeoutException` with "Timeout" in it, and a consumer classifying by text had nothing to recognise. `WebSocketClient.SendMessage` no longer answers a socket that is not open with a `Connect()` - `ConnectAsync` on an already used `ClientWebSocket` throws, the catch disposed the socket and raised `OnConnectionError`, and the send went ahead regardless - and `SendMessageAsync` returns a task that faults when the message could not be written, so the request that owns it is rejected at once rather than left to `RequestTimeout`. Messages are serialized whole on the socket; two concurrent messages larger than the send chunk could interleave their frames before + * `OnSessionEnded` is owed whatever wins. A `ChangeServer` or fast reconnect that a `Disconnect()` overtakes before it announced the session it retired still announces it - the retirement silenced the socket's own close callback, and nothing else knows the session. `Disconnect()` announces `UserDisconnected` itself rather than leaving it to the close callback alone: a `Connect()` issued right after it installs a new session before the old socket's close is processed, and the callback then filed the close as a stale session and said nothing. And a takeover that finds no socket takes no session either - the session belongs to whoever took the socket, and a `Connect()` after a `Disconnect()` used to announce a loss of its own for a session the disconnect was about to announce + * a fast reconnect no longer runs a second full series after the loop gave up. With `StopAfterMaxAttempts`, the loop the fast reconnect's failure started ran out of attempts, reported `Disconnected` and released its source; the fast reconnect's own wait then failed with "failed permanently", which its catch read as one more failure to retry. And a `Disconnect()` that took a handshake still in flight no longer installs a completion source nobody completes - the cancelled handshake reports no close - so the next `DisconnectAndWaitAsync` returns at once instead of waiting out its timeout + * six older defects on the same paths, found by the cold review of this change and fixed with it because the change rewrites the code they live on. `OnceOpen` reported `Connected` and started a ping timer nothing would stop after a `Disconnect()` from inside the `OnConnected` handler. `Connect()` after a `Disconnect()` ran with the intentional-disconnect flag still set, so a server that was down read as "closed permanently" and nothing reconnected - `ChangeServer` was the only path that cleared it, and the flag now follows the generation. A handshake cancelled by a takeover reported nothing, so its attempt timer went on firing `OnConnectionFailed` for the dead socket at every `ConnectionAttemptTimeout`. And `Connect()` over a socket that was closing announced no session end and swept no requests, both of which the close callback would have done had `Connect()` not retired the session underneath it + * a failure of an established connection is reported once. The receive loop routed a failure that was not a network error - a frame the protocol forbids, or in the browser any failure at all, since its `ClientWebSocket` says nothing recognisable - through the handshake-failure callback as well as the close callback. The first announced "Initial connection failed" for a connection that had been up and in use, with an `OnDisconnect` that carried no code, and the second reported the real close. Now the close callback is the only reporter: it classifies the failure, announces the session end once and starts the reconnect. In the browser a `WebSocketException` on an open socket is classified as a network drop - the transport going away is the one failure it has - and an exception with no message is described by its error code + * a handshake this side cancelled is not reported a second time. The connect-attempt timer and a takeover cancel the socket after reporting, and in the browser the cancelled `ConnectAsync` throws `WebSocketException` ("ConnectFailure") rather than `OperationCanceledException`, which reached the connection-error callback as a second failure of the same attempt. Both found by driving the Blazor test client through a dropped connection and a connect timeout; neither is reachable from the .NET unit suite, where a cancelled handshake throws `OperationCanceledException` and a dropped connection arrives as a network error + * the documentation of `UseCheckHealth` and `InactivityTimeout` promised more than the code does: it said the health check on its own reconnects after sixty seconds without inbound data, while the inactivity check has always run only with `UseCustomPing` enabled - and deliberately so, since an idle connection with no subscriptions receives nothing by design, and silence without keepalive pings would declare a healthy socket dead every minute. The behaviour stays; the docs now say what it is. Found by driving the Blazor test client through a connection that stayed open and went silent + * pinned by tests that issue the second operation from a callback the first one runs, which lands it inside the first one's yields every time: a `Disconnect()` and a second `ChangeServer` from the session-ended handler of a `ChangeServer`, a `ChangeServer` from the `RestoringConnection` notification of the fast reconnect, a `Disconnect()` from the `OnConnected` handler, a `Connect()` after a `Disconnect()` against a server that comes up later, and a server that closes each connection the moment its handshake completes, so the reconnect loop's success and the close it has to survive arrive together ## 11.3.2.0 06/09/2026 diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 0c70dc24..d3e48208 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 11.4.0.0 + 11.5.0.0