diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml index 8b8b8459..a2517894 100644 --- a/.github/workflows/nightly-pin-watch.yml +++ b/.github/workflows/nightly-pin-watch.yml @@ -187,13 +187,27 @@ 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=$? echo "$out" + # 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') + 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 { echo 'summary<> "$GITHUB_OUTPUT" @@ -212,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 }}" @@ -227,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/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/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/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj index e6711263..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.0.1.0 + 11.5.0.0 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 6153cefc..a2f4a73d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,29 @@ # Changes +## 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 + * **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.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 + +* **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 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. 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` + ## 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. 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/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/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/DocFx/Vault-Guide.md b/DocFx/Vault-Guide.md index efd7c0c8..9a0a72fc 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 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 + +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..91df1c68 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/HashPrefix.h b/Tests/Xrpl.Tests/Fixtures/HashPrefix.h new file mode 100644 index 00000000..e77e891b --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/HashPrefix.h @@ -0,0 +1,125 @@ +#pragma once + +#include + +#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/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/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index f8601156..0d544a6e 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,9 +56,44 @@ 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) + { + // 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."); + } + public static async Task IsEnabledAsync(IXrplClient client, string amendmentId) { try 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/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 0a338344..9c9d9480 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,74 @@ 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. + /// 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( + IXrplClient client, + DateTime target, + TestNodeType? nodeType = null, + TimeSpan? budget = null) + { + 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) + { + 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/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 6a43903e..0686e6f8 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -44,12 +44,14 @@ 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/TestIClosedEndedVault.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs new file mode 100644 index 00000000..83a8942c --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClosedEndedVault.cs @@ -0,0 +1,292 @@ +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); + + // 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(30)); + 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. 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)); + + // Redemption phase: withdrawals are accepted + await IntegrationTestConfig.WaitForCloseTimeAsync(client, redemptionDate, nodeType); + 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 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/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index 311c7572..dbeaef95 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,118 @@ protected static string GetCreatedObjectId(TransactionResponse result, LedgerEnt return null; } + #region Closed-ended vaults (LendingProtocolV1_1) + + /// + /// 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; + + /// + /// 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) + { + // 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; + } + + /// + /// 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) + 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 + /// /// 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 +237,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; } @@ -147,6 +254,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); } @@ -318,11 +428,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 +482,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/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 58cac9b5..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] @@ -336,11 +340,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"); @@ -449,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, @@ -456,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..edc124be 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -39,6 +39,7 @@ public void CheckSponsorAmendment() { 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."); } + } [ClassCleanup] @@ -128,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(); @@ -199,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)); @@ -227,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)); @@ -251,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); @@ -265,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/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/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 5d62982c..d00c8526 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 (180 s since rippled #8151) <= gap < kMaxInvestmentPeriod + tx["RedemptionDate"] = 800000179u; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateVaultCreate(tx)); + + tx["RedemptionDate"] = 800000180u; + 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/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs index 3650c027..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"; @@ -73,7 +74,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"); @@ -91,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 }, @@ -130,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 new file mode 100644 index 00000000..b90dc5ff --- /dev/null +++ b/Tests/Xrpl.Tests/Wallet/TestURoleSigningPrefixes.cs @@ -0,0 +1,151 @@ +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; + +using Xrpl.BinaryCodec; +using Xrpl.BinaryCodec.Hashing; +using Xrpl.Wallet; + +namespace Xrpl.Tests.Wallet.Tests +{ + /// + /// The four-byte prefixes that go in front of a signing preimage, read out of rippled's own + /// HashPrefix.h rather than restated. + /// + /// + /// 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"); + 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, + }; + } + + [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..d9f550fd 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() { @@ -54,12 +61,12 @@ 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 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] @@ -86,12 +93,12 @@ 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 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] @@ -113,10 +120,52 @@ 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(preimage, decoded["TxnSignature"]!.GetValue(), submitter.PublicKey)); + 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] @@ -152,8 +201,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/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..7e4b323f 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 three minutes 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,74 @@ 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. + /// 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 = 180; + + /// + /// 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 three minutes 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); + } } /// 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..d19ce636 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); + 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 8ae30155..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 { /// @@ -16,10 +20,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 { @@ -193,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/SignatureRole.cs b/Xrpl/Wallet/SignatureRole.cs new file mode 100644 index 00000000..fbf8abb1 --- /dev/null +++ b/Xrpl/Wallet/SignatureRole.cs @@ -0,0 +1,33 @@ +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. 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 + { + /// 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..dd506b10 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,18 @@ 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); + /// + /// 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 29e593ea..7e8e3296 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,45 @@ 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 (role is SignatureRole.Sponsor) + return multisign ? SignMulti(transaction, NormalizeClassic(signingFor), role) : SignAsSponsor(transaction); + + 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 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); + } + /// /// Signs a transaction offline. /// @@ -646,6 +688,18 @@ private static void GuardMemos(Dictionary transaction) 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)) @@ -675,9 +729,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 @@ -701,43 +755,48 @@ 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); } - 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"); + } + + JsonObject txToSignAndEncode = JsonNode.Parse(JsonSerializer.Serialize(transaction, XrplJsonOptions.Default))?.AsObject(); - if (tx.ContainsKey("TxnSignature") || tx.ContainsKey("Signers")) + // 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) @@ -748,6 +807,76 @@ 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. 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) + { + 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) + { + 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) + { + 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 +898,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); @@ -1123,6 +1255,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. /// @@ -1197,7 +1344,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,8 +1353,8 @@ public SignatureResult SignAsLoanCounterparty(Dictionary transac tx.Remove("CounterpartySignature"); tx.Remove("TxnSignature"); - // Compute signing preimage (same preimage broker will sign) - byte[] signingBytes = LoanSigningHelper.GetSigningPreimage(tx); + // The counterparty's preimage: the same transaction as the broker's, under its own prefix + byte[] signingBytes = LoanSigningHelper.GetCounterpartyPreimage(tx); // Sign the preimage with this wallet's key string sig = XrplKeypairs.Sign(signingBytes, this.PrivateKey); @@ -1224,8 +1371,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 same 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: /// @@ -1270,7 +1418,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(); 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