diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3b3d6bec..64ab526b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -5,7 +5,10 @@ reviews: review_status: true auto_review: - enabled: true + # Automatic review is off: reviews are requested on demand with + # "@coderabbitai review" in a PR comment. The settings below still apply + # whenever auto_review is switched back on. + enabled: false auto_incremental_review: true auto_pause_after_reviewed_commits: 0 drafts: false diff --git a/.github/workflows/dotnet.test.yml b/.github/workflows/dotnet.test.yml index 8520d260..9c75f70c 100644 --- a/.github/workflows/dotnet.test.yml +++ b/.github/workflows/dotnet.test.yml @@ -101,11 +101,14 @@ jobs: HOST: localhost PORT: 6006 - # x402 integration tests: hermetic E2E (against the standalone rippled above) plus the - # live t54 interop tests (TestILive*), which require the public XRPL testnet faucet and - # the t54 hosted facilitator. + # x402 integration tests: hermetic E2E against the standalone rippled above. + # The live t54 interop tests (TestCategory=Live) are excluded — they are the only tests + # in the suite that reach outside, needing the public XRPL testnet faucet and the hosted + # t54 facilitator, so leaving them in made a green build depend on two third-party + # services. Run them deliberately: + # dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --filter "TestCategory=Live" - name: Test Integration (Xrpl.X402) - run: dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --verbosity normal --settings test.runsettings --filter "TestI" + run: dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --verbosity normal --settings test.runsettings --filter "TestI&TestCategory!=Live" env: HOST: localhost PORT: 6006 diff --git a/CHANGES.md b/CHANGES.md index 067f3086..54ff0f36 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,27 @@ # Changes -### 10.9.1.0 07/27/2026 +## 10.10.0.0 07/28/2026 +* **Transaction fields declared by the protocol but missing from the models** — `TxFormat` listed them and the binary codec knew them, so the values travelled fine through `Dictionary`, but the typed models had no property: reading silently dropped them and the typed API could not set them at all. A field-level diff of `TxFormat` against the transaction models found four such names; the earlier 10.7.0.0 completeness pass had closed the ledger-object side (`LOAccountRoot.WalletLocator`/`WalletSize`) but not the transaction side: + * `TransactionRequest`/`TransactionResponse` + **`Delegate`** and **`OperationLimit`** — both are rippled *common* fields (`TxFormats.cpp` `commonFields`), valid on every transaction type, so they belong on the shared base rather than on individual transactions. `Delegate` identifies a transaction submitted under DelegateSet permissions (previously readable only from raw JSON, though `BatchUtils` already honored it when collecting required batch signers). `OperationLimit` is inert on XRPL but is the marker Xahau's Burn-2-Mint reads on a burn — consumers no longer need to build the burn as a dictionary to get it onto the wire, nor read raw JSON to tell a burn from a plain `AccountSet` + * `AccountSet`/`AccountSetResponse` + **`WalletLocator`** and **`WalletSize`** — both still stand in rippled's AccountSet format (`transactions.macro`). `WalletSize` is legacy and not acted on by the transactor; it is exposed so a transaction carrying it survives a round trip + * `ValidateBaseTransaction` type-checks the two new common fields, as it already does for every other common field; `ValidateAccountSet` does the same for the two new AccountSet fields — `WalletSize` as a UInt32, and `WalletLocator` as a 256-bit hex value, which is the rule `sfWalletLocator`'s `Hash256` type implies and the one the SignerListSet validator already applies to a `SignerEntry`'s WalletLocator + * **`Target` deliberately not added** — it is not a protocol field: `sfTarget` is retired (AccountID nth 7 is marked unused in `sfields.macro`, and the name is absent from `definitions.json`), and since the TicketBatch amendment rippled's TicketCreate carries only `sfTicketCount`. The stale `Target`/`Expiration` entries were removed from `TicketCreate` in `TxFormat`; `Field.Target` stays in the codec so historical blobs still decode + * `TestUTransactionProtocolFields` pins the whole cycle — deserialization, `ToJson`/`ToDictionary` round trip, typed-vs-dictionary signing parity byte for byte, and, as the regression guard for touching the common base, blobs of transactions that set none of the new fields against signatures captured from 10.9.1.0 + +* **`TxFormat` brought into full conformance with rippled, and held there** — the table is inert at runtime (`TxFormat.Validate` is not on the signing path; the codec serializes from `definitions.json`), so wrong entries produced no symptom and nothing in the suite noticed. A field-by-field diff against rippled `transactions.macro` found seven wrong formats out of 82; all are corrected and the table now matches upstream exactly: + * `CheckCreate`, `CheckCash`, `CheckCancel` — all three were a verbatim copy of the `PaymentChannelClaim` entry above them (`Channel`/`Amount`/`Balance`/`Signature`/`PublicKey`). Now `CheckCreate` = `Destination`+`SendMax` required, `Expiration`/`DestinationTag`/`InvoiceID` optional; `CheckCash` = `CheckID` required, `Amount`/`DeliverMin` optional; `CheckCancel` = `CheckID` required + * `NFTokenMint` — was missing `Amount`/`Destination`/`Expiration`; the NFTokenMintOffer fields reached the *model* in 10.7.0.0 but the format never followed, so the two had silently drifted apart from each other + * `OracleSet` — dropped `BaseAsset`/`QuoteAsset`/`AssetPrice`/`Scale`, and `SignerListSet` dropped `WalletLocator`: in both cases these are members of a nested object (`PriceDataSeries` entries, `SignerEntry`) that had been hoisted to the top level + * `VaultCreate` — dropped `Amount`, which is not a field of that transaction + * **`TestUTxFormatConformance`** now diffs every one of the 82 formats against a vendored, ref-pinned copy of `transactions.macro` (`Tests/Xrpl.Tests/Fixtures/`) and reports each divergence by name. Pinned rather than live on purpose: upstream drift is already protocol-watch's job (`transactions.macro` is in its watch list), and a network-backed test would go red on Ripple's release schedule instead of ours. The parser fails loudly on an unknown `Soe*` keyword or a short parse, so a macro-layout change cannot turn the guard green on an empty table +* **Fix `CheckCreate.InvoiceID`: `uint?` → `string`** (**breaking signature change**, though nothing could have depended on it) — `sfInvoiceID` is a `Hash256`, `Payment.InvoiceID` was already `string`, and `ValidateCheckCreate` already rejected anything but a string. The typed property was `uint?`, so every non-null value threw at signing time (``Can't decode `InvoiceID` from `123` ``): the field was unusable through the typed API in any release that had it. Found while writing the integration coverage for the corrected `CheckCreate` format +* **Integration coverage for the corrected field sets** (`TestIProtocolFieldSets`, standalone stand) — `TxFormat` itself cannot be exercised end-to-end, so these pin the claim underneath it against a real node: `CheckCreate` carrying `Expiration`/`DestinationTag`/`InvoiceID` lands and the `Check` object reads them back; `CheckCash` settles through the previously untested `DeliverMin` branch; `NFTokenMint` with `Amount`/`Destination`/`Expiration` creates the mint-time sell offer; and an `AccountSet` with `WalletLocator`/`WalletSize`/`OperationLimit` survives a full ledger round trip back into the typed `AccountSetResponse` — the end-to-end proof for the model work above. `Delegate` is covered by `TestDelegatedPayment_DelegateFieldSurvivesTheLedgerRoundTrip` (amendment-gated on `PermissionDelegationV1_1`, so it runs on the nightly stand): the owner grants the Payment permission, the delegate signs a Payment whose `Account` is the owner and whose `Delegate` is itself — without the field rippled would reject the signature outright — and the transaction reads back into the typed model with `Delegate` set, both directly and through `ITransactionCommon` + +* **Integration suite no longer reaches outside the standalone stand** — two places still went over the public internet, so a green build depended on third-party availability: + * `TestIConnectionStates` (7 tests) pointed at the public testnet and devnet. Nothing in them is specific to a public network — every assertion is about the client's own state machine — so they now run against the local node. The bogus-hostname case that tested reconnect exhaustion used a DNS lookup; it now uses a closed loopback port, which refuses immediately and involves no resolver. Fixed `Task.Delay` sleeps were the other half of the flakiness (one of these tests failed a full run and passed on retry) and are replaced by waiting for the expected state with a timeout: the class went from ~40 s of sleeping to sub-second assertions + * the x402 live t54 interop tests need the public testnet faucet *and* a hosted third-party facilitator. They are now `[TestCategory("Live")]` and excluded from CI (`--filter "TestI&TestCategory!=Live"`), leaving the six hermetic x402 E2E tests in the run. Invoke them deliberately with `--filter "TestCategory=Live"` + +## 10.9.1.0 07/27/2026 * **Fix `account_tx` losing the payment amount and, on API v1, the whole transaction** — a silent regression introduced by the 10.3.0.0 `Newtonsoft.Json` → `System.Text.Json` migration; affects every release from 10.3.0.0 on: * `Payment`/`PaymentResponse.DeliverMax` — the private set-only alias that maps API v2's `DeliverMax` onto `Amount` was carried over from Newtonsoft (which deserializes attributed non-public members) but `System.Text.Json` skips non-public members without `[JsonInclude]`. Every Payment read through `AccountTransactions`, `TxV2` or the transaction streams came back with `Amount = null` — no exception, no diagnostic. `Tx()` was unaffected because it pins `ApiVersion = 1`, and `meta.delivered_amount` kept parsing correctly, which is why the loss went unnoticed. The alias stays set-only, so `DeliverMax` is still never serialized back out * `TransactionSummary` now accepts both envelopes: rippled wraps the transaction in `tx_json` under API v2 and in `tx` under API v1 — only `tx_json` was mapped, so `Transaction` was `null` for the entire history whenever `ApiVersion = 1` was requested. `Hash` and `LedgerIndex` live inside the envelope under API v1 and fall back to it accordingly (previously `Hash` came back empty, breaking hash-based lookups over the returned list) @@ -8,7 +29,7 @@ * **`GetDomainAccess` sugar helper** — client-side implementation of the `domain_access` check proposed in [XRPLF/rippled#7743](https://github.com/XRPLF/rippled/issues/7743): answers whether an account can use a permissioned domain (permissioned DEX, vaults) and why not. One `ledger_entry` domain lookup plus up to 10 parallel keylet `ledger_entry` credential lookups, all pinned to the same validated ledger; result mirrors the proposed API (`HasAccess` + `InvalidCredentials` with `Accepted`/`Expired` diagnostics, empty list = no matching credential). Semantics match rippled `credentials::validDomain`/`checkExpired`: lsfAccepted required, expired only when close time is strictly past `Expiration`, no owner shortcut, client-side expiry check (rippled deletes expired credentials lazily) -### 10.9.0.0 07/16/2026 +## 10.9.0.0 07/16/2026 * **Unified hex helpers ([#40](https://github.com/StaticBit-io/XrplCSharp/issues/40))** — seven overlapping implementations consolidated into two canonical utilities; **breaking removals** (no `[Obsolete]` grace period): * Canonical byte-level pair: `Xrpl.AddressCodec.Utils.ToHex(byte[])` / `FromHex(string)` (renamed from `FromBytesToHex`/`FromHexToBytes`); canonical string-level: `Xrpl.Utils.StringConversion` (+`Xrpl.Models.Utils.HexStringHelper` for validated/padded VL fields) * Removed: the global-namespace `ExtensionHelpers` class from `Xrpl.AddressCodec` (leaked `ToHex`/`FromHex` into every consumer's scope), the byte-identical `Xrpl.Client.Extensions.ExtensionHelpers` duplicate (the CS0121 ambiguity trap with `StringConversion`), dead internal copies in `Xrpl.Keypairs`/`Xrpl.BinaryCodec` @@ -17,7 +38,7 @@ * Fix `IsHexCurrencyCode`: the regex lacked `^…$` anchors — any longer string containing 40 consecutive hex chars passed as a currency code * Pinning suite `TestUHexHelpers` locks the unified behavior (case, null-trim, anchoring, round-trips) -### 10.8.0.0 07/14/2026 +## 10.8.0.0 07/14/2026 * **Unified signing & submission for sponsored transactions ([#43](https://github.com/StaticBit-io/XrplCSharp/issues/43))** — the standard `Sign`/`SubmitAndWait` now handle XLS-68 end-to-end, no helper choice required: * `Sign` routes by role: a wallet matching `tx.Sponsor` produces the sponsor co-signature; the submitter path preserves an existing `SponsorSignature` and guards against a `SigningPubKey` mismatch. `multisign: true` is untouched — Signer entries are section-agnostic per rippled `STTx::checkMultiSign` (identical preimage for `tx.Signers` and `SponsorSignature.Signers`), so the role is decided at composition time * `SignatureComposer.ComposeSignatures` (offline, explicit sponsor signers) and `client.ComposeSignatures` (ledger-driven SignerList routing with ambiguity/unknown-signer errors) assemble a fully signed transaction from partially signed blobs @@ -32,7 +53,7 @@ * Fixes accumulated since 10.7.0: TxFormat interface parity for `AMMDeposit.TradingFee`, `Uint64.FromJson` TryGetValue parsing, MPT validators mirror rippled preflight (`MutableFlags` masks, `TransferFee` vs confidential-balances rule), `LONFTokenPage.NextPageMin` doc, gateway_balances integration test rebuilt on the standalone node * Release-review pass (PR #48): `SignMulti` preserves the submitter's `SigningPubKey` for LoanSet `Counterparty` multisign parts (the XLS-66 mirror of the sponsor preimage rule); smart `SubmitAndWait` recognizes a multisigned main signature (`Signers`) and skips autofill whenever any signature material is present (a co-signature freezes the body); `SignatureObject` enforces the two protocol shapes (single vs multisig, no empty/mixed forms) and `Combine` rejects structurally unsigned material; `DomainID` validation on MPT issuance transactions (64-char hex; non-zero + `tfMPTRequireAuth` required on Create, zero legal on Set as domain clear — per rippled preflight); `Xrpl.BinaryCodec` package version bumped to 10.8.0 (the codec changed since 10.7.0); Sponsorship guide corrects `SponsorshipTransfer` actors (Create/Reassign are submitted by the sponsee) and documents the sponsee-side `SponsorshipSet` deletion via `CounterpartySponsor`; ConfidentialMPT guide describes the integration test accurately (plain issuance, generic `tem`/`tec` assertion); protocol-watch workflow fails closed on a corrupted baseline, marks removed upstream files and skips duplicate notifications via a `head_sha` marker -### 10.7.0.0 07/13/2026 +## 10.7.0.0 07/13/2026 * Protocol-completeness pass driven by a field-level diff against rippled `develop` (`server_definitions` @ `8306ac77`): * `definitions.json`: add `HighSponsor`/`LowSponsor` (XLS-68 RippleState reserve sponsors); fix `isVLEncoded` on `Sponsor`/`Sponsee`/`CounterpartySponsor` (AccountID fields are VL-encoded); align `Generic` attributes with the node * Transaction models: `NFTokenMint` + `Amount`/`Destination`/`Expiration` (NFTokenMintOffer); `MPTokenIssuanceSet` + `MutableFlags`/`TransferFee`/`MPTokenMetadata`/`DomainID`/`IssuerEncryptionKey`/`AuditorEncryptionKey`; `MPTokenIssuanceCreate` + `MutableFlags`/`DomainID`; `AMMDeposit` + `TradingFee`; `LedgerStateFix` + `BookDirectory`; `VaultDelete` + `MemoData`; `SetFee` + XRPFees drops fields @@ -46,7 +67,7 @@ * `ValidateAccountSet`: `SetFlag`/`ClearFlag` asf-range checks extracted into a shared helper * Unit tests pinning the new fields (binary round-trips) and the dispatch fix; full integration suite (238 tests) green against xrpld `8306ac77` with all amendments active -### 10.6.0.0 07/10/2026 +## 10.6.0.0 07/10/2026 * **Sponsored Fees & Reserves (XLS-68, `Sponsor` amendment)** — merged into rippled `develop` on 07/10/2026 ([rippled #7350](https://github.com/XRPLF/rippled/pull/7350)): * New transaction models `SponsorshipSet` (91) and `SponsorshipTransfer` (90) with tf-flag enums per rippled `TxFlags.h`; `LOSponsorship` ledger object (0x90) * Common transaction fields `Sponsor` and `SponsorFlags` (`SponsorCoverage`: `spfSponsorFee` = 1, `spfSponsorReserve` = 2) on all transactions @@ -60,13 +81,13 @@ * Fix binary-codec JSON decode of base-ten UInt64 fields (`MPTAmount`, `LockedAmount`, `OutstandingAmount`, `MaximumAmount`, `ConfidentialOutstandingAmount`): `Decode` now emits decimal strings matching rippled (`kSmdBaseTen`) instead of 16-digit hex — pre-existing gap surfaced by the new round-trip tests * Tests: binary round-trips for all five ConfidentialMPT transactions and SponsorshipSet; validation tests mirroring rippled preflight; `TestIConfidentialMPT` negative e2e (bogus proof is rejected by ConfidentialTransfer domain logic, not the parser — proving the node parses our encoding) -### 10.5.1.0 07/04/2026 +## 10.5.1.0 07/04/2026 * Fix `SignAsBatchPart` with `TicketSequence`: when the outer Batch used a ticket and had no `Sequence`, the value `0` was applied only to the signing preimage while the serialized blob omitted the required `Sequence: 0` field, producing a malformed transaction on submit. The field is now written into the transaction as well; signatures are unaffected (the preimage already used `0`). Found by review on the 10.5.0.0 release PR * Add a unit test covering the `TicketSequence`-present / `Sequence`-absent signing path (blob carries `Sequence: 0`, signature verifies over the zero-sequence preimage) * Correct the `EncodeForSigningBatch` XML doc: `outerAccount` accepts a classic base58 r-address only (the 40-char hex form was never supported by this overload) * Harden the nightly amendment stand: admin RPC/WS ports (5005/5006/6006) in `docker-compose.batchv11.yml` are now published to `127.0.0.1` only -### 10.5.0.0 07/03/2026 +## 10.5.0.0 07/03/2026 * **BREAKING**: Align Batch (XLS-56) signing with the `BatchV1_1` amendment ([rippled #6446](https://github.com/XRPLF/rippled/pull/6446), merged into `develop` 07/01/2026). The signing preimage now includes the outer `Account` (20 bytes) and outer `Sequence` (4 bytes) after the `BCH\0` prefix; `NetworkID` is removed from the preimage. `XrplBinaryCodec.EncodeForSigningBatch` signature changed to `(string outerAccount, uint outerSequence, uint flags, IEnumerable txIDs)`. Signatures produced by the previous format are rejected by rippled once `BatchV1_1` is active * `SignAsBatchPart` single-sig now binds the signature to the `BatchSigner` account id (`finishMultiSigningData` equivalent); inner multisign binds `owner(20) + signer(20)` account ids — both per the audit hardening in BatchV1_1 * Reject duplicate `BatchSigner` accounts locally (`SortBatchSigners`, `ValidateBatch`) and a `BatchSigner` equal to the outer `Account` — early fail instead of `temBAD_SIGNER` from the server @@ -77,7 +98,7 @@ * Add unit tests for the BatchV1_1 preimage layout and both signing modes with cryptographic verification, including negative checks that pre-V1_1-format signatures no longer verify * Verified end-to-end against `xrpld 3.3.0-b0` (`develop`, commit `c92285f1`) with `BatchV1_1` and `PermissionDelegationV1_1` active: 21/21 integration tests pass; on the 3.2.0 CI image the full `TestI` suite runs 213 passed / 21 skipped / 0 failed -### Xrpl.X402 1.0.0 / Xrpl.X402.AspNetCore 1.0.0 06/23/2026 +## Xrpl.X402 1.0.0 / Xrpl.X402.AspNetCore 1.0.0 06/23/2026 * **New package `Xrpl.X402`** — x402 (HTTP-402) agentic payments client for the XRP Ledger (t54 "XRPL exact scheme"). A `DelegatingHandler` that detects a 402 challenge, builds and locally signs an XRPL `Payment` (XRP or RLUSD/IOU), and retries with a `PAYMENT-SIGNATURE` header. Signs but does not submit — the facilitator settles * Security: spending caps enforced before signing (XRP `MaxAmountDrops`; IOU fails closed without an explicit per-issuer cap), optional payTo/issuer allowlist, anti-double-pay, `LastLedgerSequence` capped by `maxTimeoutSeconds` * Intent binding matches the t54 reference payer: `Payment.InvoiceID = SHA-256(invoiceId)`, a `MemoData` = hex(invoiceId), `payload.invoiceId`, and `SourceTag` from `extra.sourceTag` (configurable via `X402IntentBinding`); IOU payments include `SendMax` @@ -85,18 +106,18 @@ * **New package `Xrpl.X402.AspNetCore`** — ASP.NET Core server middleware: a `RequirePayment` endpoint filter plus `LedgerSettlingFacilitator` (settles locally) and `T54Facilitator` (delegates to a t54 facilitator) * Live interop with the t54 testnet facilitator confirmed on-chain for both XRP and RLUSD/IOU (`/verify` → `isValid:true`, `/settle` settles) -### 10.4.2.0 06/05/2026 +## 10.4.2.0 06/05/2026 * Fix thread-unsafe request id assignment in `RequestManager` — concurrent requests on a single connection (e.g. `Task.WhenAll` over several `BookOffers`) could collide on the same id and throw `Response with id '$' is already pending` or drop a pending promise. Removed the shared `nextId` field; each call now generates its own `Guid` and registers via a single atomic `ConcurrentDictionary.TryAdd`, enabling parallel requests on one connection * Surface exceptions thrown by stream handlers (`OnLedgerClosed`, `OnTransaction`, etc.) through the `OnError` event instead of swallowing them into a debug trace — consumer bugs are now observable, while the message loop stays alive and a throwing `OnError` handler is contained * Clarify in XML docs that `Xrpl.Client.Exceptions.TimeoutException` is not `System.TimeoutException` (it derives from `XrplException`), to avoid mismatched `catch` clauses -### 10.4.1.0 05/28/2026 +## 10.4.1.0 05/28/2026 * Fix `IouValue` (IOU token amount) parsing to accept a trailing decimal point (e.g. `"128700."`), aligning with `xrpl.js` / `ripple-binary-codec` and `rippled` `STAmount` reference behavior — previously the stricter validation regex rejected a value with no digits after the dot, breaking signing of transactions (e.g. `AMMDeposit` via WalletConnect) that carried such amounts * Relax IOU value regex fractional group from `(\.(\d+))?` to `(\.(\d*))?` while adding a `(?=\.?\d)` lookahead that still requires at least one mantissa digit — so trailing/leading dots (`"128700."`, `".5"`) parse but bare-dot inputs (`"."`, `".e10"`) are rejected, matching BigNumber; deduplicate the regex by reusing the single `IouValue.ValueRegex` constant in `AmountValue.cs` and `ExtenstionHelpers.cs` * Native XRP (drops) and MPT amount parsing unchanged; mantissa/exponent math, `ToString()` output, and `ToBytes()` round-trip preserved bit-for-bit for already-valid values * Add unit tests verifying `"128700."` and `"1."` parse identically to their dot-less forms (same mantissa/exponent/precision and `ToBytes()` blob) and regression tests for existing values -### 10.4.0.0 05/13/2026 +## 10.4.0.0 05/13/2026 * Sync `Xrpl.BinaryCodec` enums with upstream `definitions.json` from [xrpl.js](https://github.com/XRPLF/xrpl.js) * Add 24 missing `TransactionType` entries: XChain (8), Vault (6), Loan (9), LedgerStateFix, DelegateSet, Batch, NFTokenModify, PermissionedDomainSet/Delete, CredentialCreate/Accept/Delete, MPToken (4), DID (2), Oracle (2), AMMClawback * Add 16 missing `LedgerEntryType` entries: Bridge, XChainOwnedClaimID, XChainOwnedCreateAccountClaimID, MPTokenIssuance, MPToken, Oracle, Credential, PermissionedDomain, Delegate, Vault, LoanBroker, Loan, DID, NegativeUNL, NFTokenOffer, NFTokenPage @@ -118,7 +139,7 @@ * Add converter mappings for all new transaction and ledger entry types * Add `LendingProtocol-Guide.md` and `LendingProtocol-Guide.ru.md` documentation -### 10.3.0.0 05/05/2026 +## 10.3.0.0 05/05/2026 * **BREAKING**: Migrate entire solution from `Newtonsoft.Json` to `System.Text.Json` — all models, converters, client infrastructure, wallet signing, binary codec * **BREAKING**: Remove `dynamic` keyword from all production code — replace with `object`, `JsonNode`, `JsonElement` for iOS Full AOT compatibility * **BREAKING**: Remove `Newtonsoft.Json` NuGet dependency from all projects (`Xrpl`, `Xrpl.BinaryCodec`, `Xrpl.AddressCodec`, `Xrpl.Keypairs`) @@ -147,7 +168,7 @@ * Fix `ScientificDecimalConverter` — parse raw token text via `decimal.Parse` instead of lossy `double` cast * Fix `EnumMemberValueConverter` — remove permissive `Enum.TryParse` fallback that accepted numeric strings -### 10.2.0.0 03/05/2026 +## 10.2.0.0 03/05/2026 * Add `path_find` WebSocket command — `PathFind(create)`, `PathFindClose`, `PathFindStatus` methods with `PathFindCreateRequest`, `PathFindCloseRequest`, `PathFindStatusRequest` models and `PathFindResponse` * Add `ripple_path_find` command — `RipplePathFind` method with `RipplePathFindRequest`, `RipplePathFindResponse`, `SourceCurrency` models * Add `PathAlternative` shared model with `PathsComputed`, `PathsCanonical`, `SourceAmount`, `DestinationAmount` @@ -171,34 +192,34 @@ * Add unit tests for `CredentialsValidator`, extended `DepositPreauth` validation, and `CredentialIDs` validation across all four affected transactions * Add integration tests for `deposit_authorized` (with/without credentials) and end-to-end XLS-70 scenario: `CredentialCreate` → `CredentialAccept` → `AccountSet(asfDepositAuth)` → `DepositPreauth(AuthorizeCredentials)` → `Payment(CredentialIDs)` -### 10.1.6.0 15/04/2026 +## 10.1.6.0 15/04/2026 * Fix for Currency to HEX for currency with 1 or 2 symbol in name -### 10.1.5.0 14/04/2026 +## 10.1.5.0 14/04/2026 * Fix binary codec field codes for AMM Amount fields — `LPTokenOut` (20→25), `LPTokenIn` (21→26), `EPrice` (22→27), `Price` (23→28), `LPTokenBalance` (24→31) * Add missing binary codec Amount field definitions: `BaseFeeDrops` (22), `ReserveBaseDrops` (23), `ReserveIncrementDrops` (24), `SignatureReward` (29), `MinAccountCreateAmount` (30) * Add AMM lifecycle integration tests (16 tests): AMMCreate, AMMDeposit (SingleAsset, TwoAssets, LPToken), AMMWithdraw (LPToken, WithdrawAll, FullLP precision regression, SingleAsset, Simulate+Submit, TypedModel), AMMDelete (EmptyPool, NonEmptyPool, AfterPartialWithdraw), AMMVote -### 10.1.4.0 14/04/2026 +## 10.1.4.0 14/04/2026 * Fix `Currency.ValueAsNumber` setter precision — change format from `"G15"` to `"G16"` to preserve all 16 significant digits of XRPL token mantissa, preventing `tecAMM_INVALID_TOKENS` on full LP token withdrawal due to rounding up * Add unit tests for `Currency` class — round-trip precision, `ValueAsXrp`, implicit operators, `CurrencyExtensions`, equality operators (39 tests) -### 10.1.3.0 11/04/2026 +## 10.1.3.0 11/04/2026 * Add `deep_freeze` and `deep_freeze_peer` fields to `TrustLine` model (XLS-77 Deep Freeze support) * Add `Limit` field to `AccountLines` response * Change `AccountLinesRequest.IgnoreDefault` type from `bool` to `bool?` * Add `PseudoAccount` field to `AccountInfo` response * Add `AMMID` field to `LOAccountRoot` -### 10.1.2.0 05/04/2026 +## 10.1.2.0 05/04/2026 * Fix `WaitForFinalTransactionOutcome` — `txnNotFound` was never recognized due to reading empty `Exception.Data` instead of `RippledException.Response.Error`, causing false `ValidationException` on successful submissions * Replace generic `catch (Exception)` in `WaitForFinalTransactionOutcome` with split catch blocks: `RippledException` with `when` filter for `txnNotFound`, re-throw for other rippled errors, `XrplException` wrapper for unexpected errors * Add null-safety for `Response` in `XrplErrorClassifier.Classify(RippledException)` -### 10.1.1.0 05/04/2026 +## 10.1.1.0 05/04/2026 * Add new ripple state flags support -### 10.1.0.1 03/04/2026 +## 10.1.0.1 03/04/2026 * Convert XrplErrorClassifier methods to extension methods for fluent error classification (`exception.Classify()`) * Add try-catch around response deserialization in RequestManager.Resolve — reject promise and rethrow on failure * Integrate XrplErrorClassifier into Connection.IOnMessageFastPath error handler with user-friendly error messages @@ -207,7 +228,7 @@ * Fix NoRippleCheck `Transactions` deserialization — use `List` with polymorphic `TransactionRequestConverter` * Fix CurrencyConverter to handle `JsonToken.Integer` for XRP amounts -### 10.1.0.0 02/04/2026 +## 10.1.0.0 02/04/2026 * Add optional CancellationToken support for all client requests (IXrplClient, Connection, RequestManager) * Thread CancellationToken through all Sugar methods (Autofill, Submit, Balances, GetOrderBook, GetFeeXrp, GetLedgerIndex) * Make RequestManager.Resolve idempotent — no longer throws when promise is already cancelled/timed out @@ -215,22 +236,22 @@ * Add 9 unit and E2E tests for CancellationToken (cancellation, race conditions, timeout priority, connection isolation) * Full backward compatibility — all CancellationToken parameters are optional with default value -### 10.0.2.1 30/03/2026 +## 10.0.2.1 30/03/2026 * Fix polymorphic ledger entry deserialization for `account_objects` * Fix `ledger_data` JSON response mapping for `state` * Add missing `ledger`, `validated`, and ledger entry type filter support -### 10.0.2 25/03/2026 +## 10.0.2 25/03/2026 * Add XRPL error classifier with normalized `XrplErrorInfo` * Add structured XRPL error metadata: category, subject, retryable/user-fixable flags, command, field, and warnings * Add tests and documentation for XRPL error classification * Minor RequestManager cleanup for pending response handling -### 10.0.1.1 24/03/2026 +## 10.0.1.1 24/03/2026 * Fix ErrorResponse * Fix RippledException when error in response -### 10.0.1 20/03/2026 +## 10.0.1 20/03/2026 * Refactor gateway_balances request * Add v1 transaction response support * Fix test account builder @@ -240,28 +261,28 @@ * Fix LedgerObject date conversion * Add mnemonic verification -### 10.0.0.1-mptmeta 02/13/2026 +## 10.0.0.1-mptmeta 02/13/2026 * MPToken Metadata parser -### 10.0.0 +## 10.0.0 * Upgrade to .NET 10.0 * TokenEscrow (XLS-85) — extended escrow support for fungible tokens (IOU/MPT) * Credentials (XLS-70) — CredentialCreate, CredentialAccept, CredentialDelete transactions, LOCredential ledger entry * PermissionedDomain (XLS-80) — PermissionedDomainSet, PermissionedDomainDelete transactions, LOPermissionedDomain ledger entry * Permissioned DEX (XLS-81) — DomainID and tfHybrid flag for OfferCreate, DomainID for Payment -### 9.8.3-implicit 02/11/2026 +## 9.8.3-implicit 02/11/2026 * Add Currency uint implicit conversion -### 9.8.2-apiVersion 02/09/2026 +## 9.8.2-apiVersion 02/09/2026 * Fix API version set -### 9.8.1-connection 02/06/2026 +## 9.8.1-connection 02/06/2026 * Connection stabilization improvements * Minor config fix * Documentation updates -### 9.8.0 02/04/2026 +## 9.8.0 02/04/2026 * Mnemonic wallet generator * Xumm numbers generator * Connection stabilization and errored tasks resolution @@ -269,44 +290,44 @@ * Add test data init * Fix connection issues -### 9.7.2 01/24/2026 +## 9.7.2 01/24/2026 * Fix race condition null exception in DID handling -### 9.7.1 01/24/2026 +## 9.7.1 01/24/2026 * Add JSON writer for converters (DID fix) -### 9.7.0 01/22/2026 +## 9.7.0 01/22/2026 * Add DID (Decentralized Identifier) support — DIDSet, DIDDelete transactions * Add Clawback transaction support * Add AMMClawback transaction support * Add Oracle Set/Delete transactions (XLS-47 Price Feeds) -### 9.6.2 01/17/2026 +## 9.6.2 01/17/2026 * Add signer locator (WalletLocator) encoding * Update connection logic * Fix encoding issues * Documentation updates -### 9.6.1 12/16/2025 +## 9.6.1 12/16/2025 * Add connection status tracking * Fix namespace for BalanceChanges -### 9.6.0 12/15/2025 +## 9.6.0 12/15/2025 * Add MPToken support (MPTokenAuthorize, MPTokenIssuanceCreate, MPTokenIssuanceDestroy, MPTokenIssuanceSet) * Add currency extensions * Add features request -### 9.5.0 12/13/2025 +## 9.5.0 12/13/2025 * Signing refactoring — batch signing, in-batch multisign * Refactor autofill logic * Refactor TX common models * Fix encoding and sign model issues * Add sign batch tests -### 9.4.1 12/01/2025 +## 9.4.1 12/01/2025 * Add Pbkdf2 for wallet from text -### 9.4.0 11/18/2025 +## 9.4.0 11/18/2025 * Upgrade to .NET 9 * Add RequestFailurePolicy and status wait for connection * Add reconnection stop flag and timeout for connection @@ -314,35 +335,35 @@ * LastLedgerSequence can be null * Refactoring and test fixes -### 9.3.0 11/12/2025 +## 9.3.0 11/12/2025 * Connection manager fix — auto-reconnect, connection ping-pong, reconnection progress -### 9.2.1 11/10/2025 +## 9.2.1 11/10/2025 * Fix Payment deliverMax serialization -### 9.2.0 11/10/2025 +## 9.2.0 11/10/2025 * Add deliverMax support * Add warning notifications * NFT parse update -### 9.1.5 11/09/2025 +## 9.1.5 11/09/2025 * Add destination interface -### 9.1.4 11/09/2025 +## 9.1.4 11/09/2025 * Fix ledger response -### 9.1.3 11/02/2025 +## 9.1.3 11/02/2025 * Fix WebAssembly (WASM) support error * Add Blazor test app -### 9.1.2 10/16/2025 +## 9.1.2 10/16/2025 * Fix autofill fee calculation -### 9.1.1 10/14/2025 +## 9.1.1 10/14/2025 * Add ledger entry types * Fix serialization error -### 9.1.0 10/14/2025 +## 9.1.0 10/14/2025 * Add Batch transaction support with multi-signature * Add wallet from any text * Add simulate request @@ -351,19 +372,19 @@ * Update AccountInfo and AccountObjects * Minor fixes and optimization -### 9.0.8 06/29/2025 +## 9.0.8 06/29/2025 * Add XLS-46d (dynamic NFTs) transaction support * Fix AMM Withdraw flags * Fix client issues -### 9.0.7 06/01/2025 +## 9.0.7 06/01/2025 * Fix NFTokenIds -### 9.0.6-beta 05/26/2025 +## 9.0.6-beta 05/26/2025 * Fix Submit and wait logic * Add TxV2 request/response -### 9.0.3-beta 05/24/2025 +## 9.0.3-beta 05/24/2025 * Refactoring for API v2 — stream custom converter * Add BalanceChanges * Add Book equals and AMM deposit flag @@ -375,17 +396,17 @@ * Fix AMM TX encoding * Add mnemonic support -### 1.0.6 06/19/2022 +## 1.0.6 06/19/2022 * Fix Trustlines JsonProperty and Limit default (thanks @ReneBrauwers) -### 1.0.5 06/09/2022 +## 1.0.5 06/09/2022 * Add payment channel encoding -### 1.0.3 05/26/2022 +## 1.0.3 05/26/2022 * Update XLS-20 fields -### 1.0.2 03/31/2022 +## 1.0.2 03/31/2022 * Fix tests and initial setup -### 1.0.0 04/30/2023 +## 1.0.0 04/30/2023 * Initial Release of XrplCSharp diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro b/Tests/Xrpl.Tests/Fixtures/transactions.macro new file mode 100644 index 00000000..e805596c --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro @@ -0,0 +1,1245 @@ +#if !defined(TRANSACTION) +#error "undefined macro: TRANSACTION" +#endif + +/** + * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * + * To ease maintenance, you may replace any unneeded values with "..." + * e.g. #define TRANSACTION(tag, value, name, ...) + * + * You must define a transactor class in the `xrpl` namespace named `name`, + * and include its header alongside the TRANSACTOR definition using this + * format: + * #if TRANSACTION_INCLUDE + * # 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 + */ + +/** This transaction type executes a payment. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPAYMENT, 0, Payment, + Delegation::Delegable, + uint256{}, + CreateAcct | MayCreateMpt, + ({ + {sfDestination, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, + {sfSendMax, SoeOptional, SoeMptSupported}, + {sfPaths, SoeDefault}, + {sfInvoiceID, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfDeliverMin, SoeOptional, SoeMptSupported}, + {sfCredentialIDs, SoeOptional}, + {sfDomainID, SoeOptional}, +})) + +/** This transaction type creates an escrow object. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfDestination, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, + {sfCondition, SoeOptional}, + {sfCancelAfter, SoeOptional}, + {sfFinishAfter, SoeOptional}, + {sfDestinationTag, SoeOptional}, +})) + +/** This transaction type completes an existing escrow. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfOwner, SoeRequired}, + {sfOfferSequence, SoeRequired}, + {sfFulfillment, SoeOptional}, + {sfCondition, SoeOptional}, + {sfCredentialIDs, SoeOptional}, +})) + + +/** This transaction type adjusts various account settings. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttACCOUNT_SET, 3, AccountSet, + Delegation::NotDelegable, + uint256{}, + NoPriv, + ({ + {sfEmailHash, SoeOptional}, + {sfWalletLocator, SoeOptional}, + {sfWalletSize, SoeOptional}, + {sfMessageKey, SoeOptional}, + {sfDomain, SoeOptional}, + {sfTransferRate, SoeOptional}, + {sfSetFlag, SoeOptional}, + {sfClearFlag, SoeOptional}, + {sfTickSize, SoeOptional}, + {sfNFTokenMinter, SoeOptional}, +})) + +/** This transaction type cancels an existing escrow. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfOwner, SoeRequired}, + {sfOfferSequence, SoeRequired}, +})) + +/** This transaction type sets or clears an account's "regular key". */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, + Delegation::NotDelegable, + uint256{}, + NoPriv, + ({ + {sfRegularKey, SoeOptional}, +})) + +// 6 deprecated + +/** This transaction type creates an offer to trade one asset for another. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, + Delegation::Delegable, + uint256{}, + MayCreateMpt, + ({ + {sfTakerPays, SoeRequired, SoeMptSupported}, + {sfTakerGets, SoeRequired, SoeMptSupported}, + {sfExpiration, SoeOptional}, + {sfOfferSequence, SoeOptional}, + {sfDomainID, SoeOptional}, +})) + +/** This transaction type cancels existing offers to trade one asset for another. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfOfferSequence, SoeRequired}, +})) + +// 9 deprecated + +/** This transaction type creates a new set of tickets. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfTicketCount, SoeRequired}, +})) + +// 11 deprecated + +/** This transaction type modifies the signer list associated with an account. */ +// The SignerEntries are optional because a SignerList is deleted by +// setting the SignerQuorum to zero and omitting SignerEntries. +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, + Delegation::NotDelegable, + uint256{}, + NoPriv, + ({ + {sfSignerQuorum, SoeRequired}, + {sfSignerEntries, SoeOptional}, +})) + +/** This transaction type creates a new unidirectional XRP payment channel. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfDestination, SoeRequired}, + {sfAmount, SoeRequired}, + {sfSettleDelay, SoeRequired}, + {sfPublicKey, SoeRequired}, + {sfCancelAfter, SoeOptional}, + {sfDestinationTag, SoeOptional}, +})) + +/** This transaction type funds an existing unidirectional XRP payment channel. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfChannel, SoeRequired}, + {sfAmount, SoeRequired}, + {sfExpiration, SoeOptional}, +})) + +/** This transaction type submits a claim against an existing unidirectional payment channel. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfChannel, SoeRequired}, + {sfAmount, SoeOptional}, + {sfBalance, SoeOptional}, + {sfSignature, SoeOptional}, + {sfPublicKey, SoeOptional}, + {sfCredentialIDs, SoeOptional}, +})) + +/** This transaction type creates a new check. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfDestination, SoeRequired}, + {sfSendMax, SoeRequired, SoeMptSupported}, + {sfExpiration, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfInvoiceID, SoeOptional}, +})) + +/** This transaction type cashes an existing check. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCHECK_CASH, 17, CheckCash, + Delegation::Delegable, + uint256{}, + MayCreateMpt, + ({ + {sfCheckID, SoeRequired}, + {sfAmount, SoeOptional, SoeMptSupported}, + {sfDeliverMin, SoeOptional, SoeMptSupported}, +})) + +/** This transaction type cancels an existing check. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfCheckID, SoeRequired}, +})) + +/** This transaction type grants or revokes authorization to transfer funds. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfAuthorize, SoeOptional}, + {sfUnauthorize, SoeOptional}, + {sfAuthorizeCredentials, SoeOptional}, + {sfUnauthorizeCredentials, SoeOptional}, +})) + +/** This transaction type modifies a trustline between two accounts. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttTRUST_SET, 20, TrustSet, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfLimitAmount, SoeOptional}, + {sfQualityIn, SoeOptional}, + {sfQualityOut, SoeOptional}, +})) + +/** This transaction type deletes an existing account. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, + Delegation::NotDelegable, + uint256{}, + MustDeleteAcct, + ({ + {sfDestination, SoeRequired}, + {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, +})) + +// 22 reserved + +/** This transaction mints a new NFT. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, + Delegation::Delegable, + uint256{}, + ChangeNftCounts, + ({ + {sfNFTokenTaxon, SoeRequired}, + {sfTransferFee, SoeOptional}, + {sfIssuer, SoeOptional}, + {sfURI, SoeOptional}, + {sfAmount, SoeOptional}, + {sfDestination, SoeOptional}, + {sfExpiration, SoeOptional}, +})) + +/** This transaction burns (i.e. destroys) an existing NFT. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, + Delegation::Delegable, + uint256{}, + ChangeNftCounts, + ({ + {sfNFTokenID, SoeRequired}, + {sfOwner, SoeOptional}, +})) + +/** This transaction creates a new offer to buy or sell an NFT. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfNFTokenID, SoeRequired}, + {sfAmount, SoeRequired}, + {sfDestination, SoeOptional}, + {sfOwner, SoeOptional}, + {sfExpiration, SoeOptional}, +})) + +/** This transaction cancels an existing offer to buy or sell an existing NFT. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfNFTokenOffers, SoeRequired}, +})) + +/** This transaction accepts an existing offer to buy or sell an existing NFT. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfNFTokenBuyOffer, SoeOptional}, + {sfNFTokenSellOffer, SoeOptional}, + {sfNFTokenBrokerFee, SoeOptional}, +})) + +/** This transaction claws back issued tokens. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCLAWBACK, 30, Clawback, + Delegation::Delegable, + uint256{}, + NoPriv, + ({ + {sfAmount, SoeRequired, SoeMptSupported}, + {sfHolder, SoeOptional}, +})) + +/** This transaction claws back tokens from an AMM pool. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, + Delegation::Delegable, + featureAMMClawback, + MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + {sfHolder, SoeRequired}, + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAsset2, SoeRequired, SoeMptSupported}, + {sfAmount, SoeOptional, SoeMptSupported}, +})) + +/** This transaction type creates an AMM instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_CREATE, 35, AMMCreate, + Delegation::Delegable, + featureAMM, + CreatePseudoAcct | MayCreateMpt, + ({ + {sfAmount, SoeRequired, SoeMptSupported}, + {sfAmount2, SoeRequired, SoeMptSupported}, + {sfTradingFee, SoeRequired}, +})) + +/** This transaction type deposits into an AMM instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, + Delegation::Delegable, + featureAMM, + NoPriv, + ({ + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAsset2, SoeRequired, SoeMptSupported}, + {sfAmount, SoeOptional, SoeMptSupported}, + {sfAmount2, SoeOptional, SoeMptSupported}, + {sfEPrice, SoeOptional}, + {sfLPTokenOut, SoeOptional}, + {sfTradingFee, SoeOptional}, +})) + +/** This transaction type withdraws from an AMM instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, + Delegation::Delegable, + featureAMM, + MayDeleteAcct | MayAuthorizeMpt, + ({ + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAsset2, SoeRequired, SoeMptSupported}, + {sfAmount, SoeOptional, SoeMptSupported}, + {sfAmount2, SoeOptional, SoeMptSupported}, + {sfEPrice, SoeOptional}, + {sfLPTokenIn, SoeOptional}, +})) + +/** This transaction type votes for the trading fee */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_VOTE, 38, AMMVote, + Delegation::Delegable, + featureAMM, + NoPriv, + ({ + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAsset2, SoeRequired, SoeMptSupported}, + {sfTradingFee, SoeRequired}, +})) + +/** This transaction type bids for the auction slot */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_BID, 39, AMMBid, + Delegation::Delegable, + featureAMM, + NoPriv, + ({ + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAsset2, SoeRequired, SoeMptSupported}, + {sfBidMin, SoeOptional}, + {sfBidMax, SoeOptional}, + {sfAuthAccounts, SoeOptional}, +})) + +/** This transaction type deletes AMM in the empty state */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMM_DELETE, 40, AMMDelete, + Delegation::Delegable, + featureAMM, + MustDeleteAcct | MayDeleteMpt, + ({ + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAsset2, SoeRequired, SoeMptSupported}, +})) + +/** This transactions creates a crosschain sequence number */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, + Delegation::Delegable, + featureXChainBridge, + NoPriv, + ({ + {sfXChainBridge, SoeRequired}, + {sfSignatureReward, SoeRequired}, + {sfOtherChainSource, SoeRequired}, +})) + +/** This transactions initiates a crosschain transaction */ +TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, + Delegation::Delegable, + featureXChainBridge, + NoPriv, + ({ + {sfXChainBridge, SoeRequired}, + {sfXChainClaimID, SoeRequired}, + {sfAmount, SoeRequired}, + {sfOtherChainDestination, SoeOptional}, +})) + +/** This transaction completes a crosschain transaction */ +TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, + Delegation::Delegable, + featureXChainBridge, + NoPriv, + ({ + {sfXChainBridge, SoeRequired}, + {sfXChainClaimID, SoeRequired}, + {sfDestination, SoeRequired}, + {sfDestinationTag, SoeOptional}, + {sfAmount, SoeRequired}, +})) + +/** This transaction initiates a crosschain account create transaction */ +TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, + Delegation::Delegable, + featureXChainBridge, + NoPriv, + ({ + {sfXChainBridge, SoeRequired}, + {sfDestination, SoeRequired}, + {sfAmount, SoeRequired}, + {sfSignatureReward, SoeRequired}, +})) + +/** This transaction adds an attestation to a claim */ +TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, + Delegation::Delegable, + featureXChainBridge, + CreateAcct, + ({ + {sfXChainBridge, SoeRequired}, + + {sfAttestationSignerAccount, SoeRequired}, + {sfPublicKey, SoeRequired}, + {sfSignature, SoeRequired}, + {sfOtherChainSource, SoeRequired}, + {sfAmount, SoeRequired}, + {sfAttestationRewardAccount, SoeRequired}, + {sfWasLockingChainSend, SoeRequired}, + + {sfXChainClaimID, SoeRequired}, + {sfDestination, SoeOptional}, +})) + +/** This transaction adds an attestation to an account */ +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, + XChainAddAccountCreateAttestation, + Delegation::Delegable, + featureXChainBridge, + CreateAcct, + ({ + {sfXChainBridge, SoeRequired}, + + {sfAttestationSignerAccount, SoeRequired}, + {sfPublicKey, SoeRequired}, + {sfSignature, SoeRequired}, + {sfOtherChainSource, SoeRequired}, + {sfAmount, SoeRequired}, + {sfAttestationRewardAccount, SoeRequired}, + {sfWasLockingChainSend, SoeRequired}, + + {sfXChainAccountCreateCount, SoeRequired}, + {sfDestination, SoeRequired}, + {sfSignatureReward, SoeRequired}, +})) + +/** This transaction modifies a sidechain */ +TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, + Delegation::Delegable, + featureXChainBridge, + NoPriv, + ({ + {sfXChainBridge, SoeRequired}, + {sfSignatureReward, SoeOptional}, + {sfMinAccountCreateAmount, SoeOptional}, +})) + +/** This transactions creates a sidechain */ +TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, + Delegation::Delegable, + featureXChainBridge, + NoPriv, + ({ + {sfXChainBridge, SoeRequired}, + {sfSignatureReward, SoeRequired}, + {sfMinAccountCreateAmount, SoeOptional}, +})) + +/** This transaction type creates or updates a DID */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttDID_SET, 49, DIDSet, + Delegation::Delegable, + featureDID, + NoPriv, + ({ + {sfDIDDocument, SoeOptional}, + {sfURI, SoeOptional}, + {sfData, SoeOptional}, +})) + +/** This transaction type deletes a DID */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttDID_DELETE, 50, DIDDelete, + Delegation::Delegable, + featureDID, + NoPriv, + ({})) + +/** This transaction type creates an Oracle instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttORACLE_SET, 51, OracleSet, + Delegation::Delegable, + featurePriceOracle, + NoPriv, + ({ + {sfOracleDocumentID, SoeRequired}, + {sfProvider, SoeOptional}, + {sfURI, SoeOptional}, + {sfAssetClass, SoeOptional}, + {sfLastUpdateTime, SoeRequired}, + {sfPriceDataSeries, SoeRequired}, +})) + +/** This transaction type deletes an Oracle instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, + Delegation::Delegable, + featurePriceOracle, + NoPriv, + ({ + {sfOracleDocumentID, SoeRequired}, +})) + +/** This transaction type fixes a problem in the ledger state */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, + Delegation::Delegable, + fixNFTokenPageLinks, + NoPriv, + ({ + {sfLedgerFixType, SoeRequired}, + {sfOwner, SoeOptional}, + {sfBookDirectory, SoeOptional}, +})) + +/** This transaction type creates a MPTokensIssuance instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, + Delegation::Delegable, + featureMPTokensV1, + CreateMptIssuance, + ({ + {sfAssetScale, SoeOptional}, + {sfTransferFee, SoeOptional}, + {sfMaximumAmount, SoeOptional}, + {sfMPTokenMetadata, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfMutableFlags, SoeOptional}, +})) + +/** This transaction type destroys a MPTokensIssuance instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, + Delegation::Delegable, + featureMPTokensV1, + DestroyMptIssuance, + ({ + {sfMPTokenIssuanceID, SoeRequired}, +})) + +/** This transaction type sets flags on a MPTokensIssuance or MPToken instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, + Delegation::Delegable, + featureMPTokensV1, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfMPTokenMetadata, SoeOptional}, + {sfTransferFee, SoeOptional}, + {sfMutableFlags, SoeOptional}, + {sfIssuerEncryptionKey, SoeOptional}, + {sfAuditorEncryptionKey, SoeOptional}, +})) + +/** This transaction type authorizes a MPToken instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, + Delegation::Delegable, + featureMPTokensV1, + MustAuthorizeMpt, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, +})) + +/** This transaction type create an Credential instance */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, + Delegation::Delegable, + featureCredentials, + NoPriv, + ({ + {sfSubject, SoeRequired}, + {sfCredentialType, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfURI, SoeOptional}, +})) + +/** This transaction type accept an Credential object */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, + Delegation::Delegable, + featureCredentials, + NoPriv, + ({ + {sfIssuer, SoeRequired}, + {sfCredentialType, SoeRequired}, +})) + +/** This transaction type delete an Credential object */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, + Delegation::Delegable, + featureCredentials, + NoPriv, + ({ + {sfSubject, SoeOptional}, + {sfIssuer, SoeOptional}, + {sfCredentialType, SoeRequired}, +})) + +/** This transaction type modify a NFToken */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, + Delegation::Delegable, + featureDynamicNFT, + NoPriv, + ({ + {sfNFTokenID, SoeRequired}, + {sfOwner, SoeOptional}, + {sfURI, SoeOptional}, +})) + +/** This transaction type creates or modifies a Permissioned Domain */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, + Delegation::Delegable, + featurePermissionedDomains, + NoPriv, + ({ + {sfDomainID, SoeOptional}, + {sfAcceptedCredentials, SoeRequired}, +})) + +/** This transaction type deletes a Permissioned Domain */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, + Delegation::Delegable, + featurePermissionedDomains, + NoPriv, + ({ + {sfDomainID, SoeRequired}, +})) + +/** This transaction type delegates authorized account specified permissions */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, + Delegation::NotDelegable, + featurePermissionDelegationV1_1, + NoPriv, + ({ + {sfAuthorize, SoeRequired}, + {sfPermissions, SoeRequired}, +})) + +/** This transaction creates a single asset vault. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, + Delegation::NotDelegable, + featureSingleAssetVault, + CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + {sfAsset, SoeRequired, SoeMptSupported}, + {sfAssetsMaximum, SoeOptional}, + {sfMPTokenMetadata, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfWithdrawalPolicy, SoeOptional}, + {sfData, SoeOptional}, + {sfScale, SoeOptional}, +})) + +/** This transaction updates a single asset vault. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttVAULT_SET, 66, VaultSet, + Delegation::NotDelegable, + featureSingleAssetVault, + MustModifyVault, + ({ + {sfVaultID, SoeRequired}, + {sfAssetsMaximum, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfData, SoeOptional}, +})) + +/** This transaction deletes a single asset vault. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, + Delegation::NotDelegable, + featureSingleAssetVault, + MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + {sfVaultID, SoeRequired}, + {sfMemoData, SoeOptional}, +})) + +/** This transaction trades assets for shares with a vault. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, + Delegation::NotDelegable, + featureSingleAssetVault, + MayAuthorizeMpt | MustModifyVault, + ({ + {sfVaultID, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, +})) + +/** This transaction trades shares for assets with a vault. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, + Delegation::NotDelegable, + featureSingleAssetVault, + MayDeleteMpt | MayAuthorizeMpt | MustModifyVault, + ({ + {sfVaultID, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, + {sfDestination, SoeOptional}, + {sfDestinationTag, SoeOptional}, +})) + +/** This transaction claws back tokens from a vault. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, + Delegation::NotDelegable, + featureSingleAssetVault, + MayDeleteMpt | MustModifyVault, + ({ + {sfVaultID, SoeRequired}, + {sfHolder, SoeRequired}, + {sfAmount, SoeOptional, SoeMptSupported}, +})) + +/** This transaction type batches together transactions. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttBATCH, 71, Batch, + Delegation::NotDelegable, + featureBatchV1_1, + NoPriv, + ({ + {sfRawTransactions, SoeRequired}, + {sfBatchSigners, SoeOptional}, +})) + +/** Reserve 72-73 for future Vault-related transactions */ + +/** This transaction creates and updates a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, + Delegation::NotDelegable, + featureLendingProtocol, + CreatePseudoAcct | MayAuthorizeMpt, ({ + {sfVaultID, SoeRequired}, + {sfLoanBrokerID, SoeOptional}, + {sfData, SoeOptional}, + {sfManagementFeeRate, SoeOptional}, + {sfDebtMaximum, SoeOptional}, + {sfCoverRateMinimum, SoeOptional}, + {sfCoverRateLiquidation, SoeOptional}, +})) + +/** This transaction deletes a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, + Delegation::NotDelegable, + featureLendingProtocol, + MustDeleteAcct | MayAuthorizeMpt, ({ + {sfLoanBrokerID, SoeRequired}, +})) + +/** This transaction deposits First Loss Capital into a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, + Delegation::NotDelegable, + featureLendingProtocol, + NoPriv, ({ + {sfLoanBrokerID, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, +})) + +/** This transaction withdraws First Loss Capital from a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, + Delegation::NotDelegable, + featureLendingProtocol, + MayAuthorizeMpt, ({ + {sfLoanBrokerID, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, + {sfDestination, SoeOptional}, + {sfDestinationTag, SoeOptional}, +})) + +/** This transaction claws back First Loss Capital from a Loan Broker to + the issuer of the capital */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, + Delegation::NotDelegable, + featureLendingProtocol, + NoPriv, ({ + {sfLoanBrokerID, SoeOptional}, + {sfAmount, SoeOptional, SoeMptSupported}, +})) + +/** This transaction creates a Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_SET, 80, LoanSet, + Delegation::NotDelegable, + featureLendingProtocol, + MayAuthorizeMpt | MustModifyVault, ({ + {sfLoanBrokerID, SoeRequired}, + {sfData, SoeOptional}, + {sfCounterparty, SoeOptional}, + {sfCounterpartySignature, SoeOptional}, + {sfLoanOriginationFee, SoeOptional}, + {sfLoanServiceFee, SoeOptional}, + {sfLatePaymentFee, SoeOptional}, + {sfClosePaymentFee, SoeOptional}, + {sfOverpaymentFee, SoeOptional}, + {sfInterestRate, SoeOptional}, + {sfLateInterestRate, SoeOptional}, + {sfCloseInterestRate, SoeOptional}, + {sfOverpaymentInterestRate, SoeOptional}, + {sfPrincipalRequested, SoeRequired}, + {sfPaymentTotal, SoeOptional}, + {sfPaymentInterval, SoeOptional}, + {sfGracePeriod, SoeOptional}, +})) + +/** This transaction deletes an existing Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, + Delegation::NotDelegable, + featureLendingProtocol, + NoPriv, ({ + {sfLoanID, SoeRequired}, +})) + +/** This transaction is used to change the delinquency status of an existing Loan */ +#if TRANSACTION_INCLUDE +# 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, ({ + {sfLoanID, SoeRequired}, +})) + +/** The Borrower uses this transaction to make a Payment on the Loan. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_PAY, 84, LoanPay, + Delegation::NotDelegable, + featureLendingProtocol, + MayAuthorizeMpt | MustModifyVault, ({ + {sfLoanID, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, +})) + +/** This transaction type converts into confidential MPT balance. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeRequired}, + {sfHolderEncryptionKey, SoeOptional}, + {sfHolderEncryptedAmount, SoeRequired}, + {sfIssuerEncryptedAmount, SoeRequired}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfBlindingFactor, SoeRequired}, + {sfZKProof, SoeOptional}, +})) + +/** This transaction type merges MPT inbox. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, +})) + +/** This transaction type converts back into public MPT balance. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeRequired}, + {sfHolderEncryptedAmount, SoeRequired}, + {sfIssuerEncryptedAmount, SoeRequired}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfBlindingFactor, SoeRequired}, + {sfZKProof, SoeRequired}, + {sfBalanceCommitment, SoeRequired}, +})) + +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfDestination, SoeRequired}, + {sfDestinationTag, SoeOptional}, + {sfSenderEncryptedAmount, SoeRequired}, + {sfDestinationEncryptedAmount, SoeRequired}, + {sfIssuerEncryptedAmount, SoeRequired}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfZKProof, SoeRequired}, + {sfAmountCommitment, SoeRequired}, + {sfBalanceCommitment, SoeRequired}, + {sfCredentialIDs, SoeOptional}, +})) + +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeRequired}, + {sfMPTAmount, SoeRequired}, + {sfZKProof, SoeRequired}, +})) + +/** This transaction transfers sponsorship on an object/account. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, + Delegation::NotDelegable, + featureSponsor, + NoPriv, + ({ + {sfObjectID, SoeOptional}, + {sfSponsee, SoeOptional}, +})) + +/** This transaction creates a Sponsorship object. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, + Delegation::Delegable, + featureSponsor, + NoPriv, + ({ + {sfCounterpartySponsor, SoeOptional}, + {sfSponsee, SoeOptional}, + {sfFeeAmount, SoeOptional}, + {sfMaxFee, SoeOptional}, + {sfRemainingOwnerCount, SoeOptional}, +})) + +/** This system-generated transaction type is used to update the status of the various amendments. + + For details, see: https://xrpl.org/amendments.html + */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttAMENDMENT, 100, EnableAmendment, + Delegation::NotDelegable, + uint256{}, + NoPriv, + ({ + {sfLedgerSequence, SoeRequired}, + {sfAmendment, SoeRequired}, +})) + +/** This system-generated transaction type is used to update the network's fee settings. + For details, see: https://xrpl.org/fee-voting.html + */ +TRANSACTION(ttFEE, 101, SetFee, + Delegation::NotDelegable, + uint256{}, + NoPriv, + ({ + {sfLedgerSequence, SoeOptional}, + // Old version uses raw numbers + {sfBaseFee, SoeOptional}, + {sfReferenceFeeUnits, SoeOptional}, + {sfReserveBase, SoeOptional}, + {sfReserveIncrement, SoeOptional}, + // New version uses Amounts + {sfBaseFeeDrops, SoeOptional}, + {sfReserveBaseDrops, SoeOptional}, + {sfReserveIncrementDrops, SoeOptional}, +})) + +/** This system-generated transaction type is used to update the network's negative UNL + + For details, see: https://xrpl.org/negative-unl.html + */ +TRANSACTION(ttUNL_MODIFY, 102, UNLModify, + Delegation::NotDelegable, + uint256{}, + NoPriv, + ({ + {sfUNLModifyDisabling, SoeRequired}, + {sfLedgerSequence, SoeRequired}, + {sfUNLModifyValidator, SoeRequired}, +})) diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref new file mode 100644 index 00000000..ff6ce6ed --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref @@ -0,0 +1,13 @@ +https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/transactions.macro +sha fd2cc6dcb308fb811960380eba7bf4309934d05d +date 2026-07-10T21:58:19Z + +transactions.macro is vendored byte-identical to the ref above so that it can be +re-verified with a plain diff: + + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/fd2cc6dcb308fb811960380eba7bf4309934d05d/include/xrpl/protocol/detail/transactions.macro \ + | diff - Tests/Xrpl.Tests/Fixtures/transactions.macro + +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/TestIConnectionStates.cs b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs index a6cb8aac..121521e1 100644 --- a/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs +++ b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -7,62 +8,105 @@ using Xrpl.Client; using Xrpl.Client.Exceptions; +using XrplTests.Xrpl.ClientLib.Integration; + namespace Xrpl.Tests.Integration; +/// +/// Connection lifecycle against the local standalone node. +/// +/// +/// These tests used to point at the public testnet and devnet, which made them depend on +/// third-party availability and latency inside a suite that is otherwise hermetic — they +/// failed intermittently for reasons that had nothing to do with the SDK. Nothing here is +/// specific to a public network: every assertion is about the client's own state machine, +/// so the local node serves the purpose and the run becomes deterministic and offline. +/// +/// Fixed sleeps were the other half of the flakiness; state is now awaited with a timeout +/// instead of guessed at. +/// [TestClass] [TestCategory("Integration")] [TestCategory("TestI")] public class TestIConnectionStates { + private static string LocalServer => IntegrationTestConfig.GetNodeUrl(TestNodeType.Standalone); + + /// + /// The same node under a different URL spelling — enough to exercise a real server switch + /// (teardown plus reconnect to a new endpoint) without a second container. + /// + private static string LocalServerAlternateSpelling => + LocalServer.Replace("localhost", "127.0.0.1", StringComparison.OrdinalIgnoreCase); + + /// + /// A closed port on the loopback interface: refuses immediately and, unlike a bogus + /// hostname, involves no DNS resolver and so no external dependency. + /// + private const string UnreachableServer = "ws://127.0.0.1:1"; + + private static XrplClient.ClientOptions LocalOptions() => new XrplClient.ClientOptions + { + MaxReconnectAttempts = 3, + StopAfterMaxAttempts = true, + ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) + }; + + private static async Task WaitForStateAsync( + XrplClient client, + XrpConnectionState expected, + string because, + int timeoutMs = 20000) + { + Stopwatch elapsed = Stopwatch.StartNew(); + while (elapsed.ElapsedMilliseconds < timeoutMs) + { + if (client.connection.CurrentConnectionState == expected) + return; + await Task.Delay(50); + } + + Assert.AreEqual(expected, client.connection.CurrentConnectionState, $"{because} (waited {timeoutMs} ms)"); + } + [TestMethod] public async Task TestConnectionStateSequence_ConnectDisconnect() { - var stateChanges = new List(); - var stateMessages = new List(); + List stateChanges = new List(); - var client = new XrplClient("wss://s.altnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 3, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); + XrplClient client = new XrplClient(LocalServer, LocalOptions()); client.connection.OnConnectionStatus += (status) => { stateChanges.Add(status.ConnectionState); - stateMessages.Add($"[{status.ConnectionState}] {status.Message}"); Console.WriteLine($"State: {status.ConnectionState}, Message: {status.Message}"); }; Assert.AreEqual(XrpConnectionState.Disconnected, client.connection.CurrentConnectionState, "Initial state should be Disconnected"); await client.Connect(); - await Task.Delay(3000); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Current state should be Connected"); Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Connecting), "Should have Connecting state during connection"); Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Connected), "Should have Connected state after connection"); - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, "Current state should be Connected"); stateChanges.Clear(); await client.Disconnect(); - await Task.Delay(3000); - Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Disconnected), "Should have Disconnected state after user disconnect"); - Assert.AreEqual(XrpConnectionState.Disconnected, client.connection.CurrentConnectionState, "Current state should be Disconnected"); + await WaitForStateAsync(client, XrpConnectionState.Disconnected, "Current state should be Disconnected"); - Console.WriteLine("\n=== Test completed successfully ==="); - Console.WriteLine("State sequence: " + string.Join(" -> ", stateChanges)); + Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Disconnected), "Should have Disconnected state after user disconnect"); } [TestMethod] public async Task TestConnectionStateReconnect_InvalidServer() { - var stateChanges = new List(); - var reconnectAttempts = 0; - var tcs = new TaskCompletionSource(); + List stateChanges = new List(); + int reconnectAttempts = 0; + TaskCompletionSource tcs = new TaskCompletionSource(); - var client = new XrplClient("wss://invalid-server-that-does-not-exist.example.com:51233", new XrplClient.ClientOptions + XrplClient client = new XrplClient(UnreachableServer, new XrplClient.ClientOptions { MaxReconnectAttempts = 2, StopAfterMaxAttempts = true, @@ -89,19 +133,17 @@ public async Task TestConnectionStateReconnect_InvalidServer() try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await client.Connect(cts.Token); + using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await client.Connect(cts.Token); } catch { } - var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))); + await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))); Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Connecting), "Should have Connecting state"); - - Console.WriteLine($"\n=== Reconnect attempts: {reconnectAttempts} ==="); - Console.WriteLine("State changes: " + string.Join(" -> ", stateChanges)); + Console.WriteLine($"Reconnect attempts: {reconnectAttempts}"); await client.Disconnect(); } @@ -109,41 +151,23 @@ public async Task TestConnectionStateReconnect_InvalidServer() [TestMethod] public async Task TestCurrentConnectionStateProperty() { - var client = new XrplClient("wss://s.altnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 3, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); + XrplClient client = new XrplClient(LocalServer, LocalOptions()); Assert.AreEqual(XrpConnectionState.Disconnected, client.connection.CurrentConnectionState, "Initial CurrentConnectionState should be Disconnected"); await client.Connect(); - await Task.Delay(3000); - - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, "After connect, CurrentConnectionState should be Connected"); + await WaitForStateAsync(client, XrpConnectionState.Connected, "After connect, CurrentConnectionState should be Connected"); await client.Disconnect(); - await Task.Delay(3000); - - Assert.AreEqual(XrpConnectionState.Disconnected, client.connection.CurrentConnectionState, "After disconnect, CurrentConnectionState should be Disconnected"); - - Console.WriteLine("=== CurrentConnectionState property test passed ==="); + await WaitForStateAsync(client, XrpConnectionState.Disconnected, "After disconnect, CurrentConnectionState should be Disconnected"); } [TestMethod] public async Task TestIdempotentConnect_StaysConnected() { - var stateChanges = new List(); + List stateChanges = new List(); - var client = new XrplClient("wss://s.altnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 3, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); + XrplClient client = new XrplClient(LocalServer, LocalOptions()); client.connection.OnConnectionStatus += (status) => { @@ -151,36 +175,25 @@ public async Task TestIdempotentConnect_StaysConnected() Console.WriteLine($"State: {status.ConnectionState}, Message: {status.Message}"); }; - await client.Connect(); - await Task.Delay(3000); - - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, "Should be Connected after first connect"); + await client.Connect(); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Should be Connected after first connect"); stateChanges.Clear(); await client.Connect(); - await Task.Delay(3000); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Should remain Connected after idempotent connect call"); - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, "Should remain Connected after idempotent connect call"); Assert.IsTrue(stateChanges.All(s => s == XrpConnectionState.Connected), "Only Connected state should be emitted for idempotent call"); await client.Disconnect(); - - Console.WriteLine("=== Idempotent connect test passed ==="); } [TestMethod] public async Task TestChangeServer_SwitchesSuccessfully() { - var stateChanges = new List(); + List stateChanges = new List(); - var client = new XrplClient("wss://s.altnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 3, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); + XrplClient client = new XrplClient(LocalServer, LocalOptions()); client.connection.OnConnectionStatus += (status) => { @@ -189,69 +202,46 @@ public async Task TestChangeServer_SwitchesSuccessfully() }; await client.Connect(); - await Task.Delay(2000); - - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, "Should be Connected after first connect"); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Should be Connected after first connect"); stateChanges.Clear(); - await client.ChangeServer("wss://s.devnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 5, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); - await Task.Delay(2000); + await client.ChangeServer(LocalServerAlternateSpelling, LocalOptions()); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Should be Connected after ChangeServer"); - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, "Should be Connected after ChangeServer"); - Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Disconnected) || stateChanges.Contains(XrpConnectionState.Connecting), + Assert.IsTrue( + stateChanges.Contains(XrpConnectionState.Disconnected) || stateChanges.Contains(XrpConnectionState.Connecting), "Should have gone through Disconnected or Connecting state during server change"); await client.Disconnect(); - - Console.WriteLine("=== ChangeServer test passed ==="); } [TestMethod] public async Task TestChangeServer_NoWebSocketCleanupError() { - var client = new XrplClient("wss://s.altnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 3, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); + XrplClient client = new XrplClient(LocalServer, LocalOptions()); await client.Connect(); - await Task.Delay(2000); - - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Should be Connected after connect"); for (int i = 0; i < 3; i++) { Console.WriteLine($"ChangeServer iteration {i + 1}"); - - await client.ChangeServer("wss://s.altnet.rippletest.net:51233"); - await Task.Delay(2000); - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, - $"Should be Connected after ChangeServer iteration {i + 1}"); + await client.ChangeServer(LocalServer); + await WaitForStateAsync(client, XrpConnectionState.Connected, $"Should be Connected after ChangeServer iteration {i + 1}"); } await client.Disconnect(); - - Console.WriteLine("=== ChangeServer no cleanup error test passed ==="); } [TestMethod] public async Task TestChangeServer_AfterMaxReconnectAttempts_NoNotConnectedException() { - var stateChanges = new List(); - var disconnectedPermanently = new TaskCompletionSource(); + List stateChanges = new List(); + TaskCompletionSource disconnectedPermanently = new TaskCompletionSource(); - var client = new XrplClient("wss://invalid-server-that-does-not-exist.example.com:51233", new XrplClient.ClientOptions + XrplClient client = new XrplClient(UnreachableServer, new XrplClient.ClientOptions { MaxReconnectAttempts = 2, StopAfterMaxAttempts = true, @@ -273,35 +263,24 @@ public async Task TestChangeServer_AfterMaxReconnectAttempts_NoNotConnectedExcep try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await client.Connect(cts.Token); } catch { } - var completed = await Task.WhenAny(disconnectedPermanently.Task, Task.Delay(TimeSpan.FromSeconds(30))); + await Task.WhenAny(disconnectedPermanently.Task, Task.Delay(TimeSpan.FromSeconds(30))); - Assert.AreEqual(XrpConnectionState.Disconnected, client.connection.CurrentConnectionState, + Assert.AreEqual(XrpConnectionState.Disconnected, client.connection.CurrentConnectionState, "Should be Disconnected after max reconnect attempts"); stateChanges.Clear(); try { - await client.ChangeServer("wss://s.altnet.rippletest.net:51233", new XrplClient.ClientOptions - { - MaxReconnectAttempts = 3, - StopAfterMaxAttempts = true, - ConnectionAttemptTimeout = TimeSpan.FromSeconds(15), - ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(30) - }); - await Task.Delay(3000); - - Assert.AreEqual(XrpConnectionState.Connected, client.connection.CurrentConnectionState, - "Should be Connected after ChangeServer to valid server"); - - Console.WriteLine("=== ChangeServer after max reconnect attempts test PASSED ==="); + await client.ChangeServer(LocalServer, LocalOptions()); + await WaitForStateAsync(client, XrpConnectionState.Connected, "Should be Connected after ChangeServer to valid server"); } catch (NotConnectedException ex) { @@ -312,4 +291,4 @@ public async Task TestChangeServer_AfterMaxReconnectAttempts_NoNotConnectedExcep await client.Disconnect(); } } -} \ No newline at end of file +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs index b73d6eb4..00f308a4 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs @@ -137,4 +137,54 @@ public async Task TestDelegateSet_MultiplePermissions() .ToList(); CollectionAssert.AreEqual(expectedValues, actualValues); } + + /// + /// The other half of delegation: not granting the permission, but exercising it. + /// The delegate submits a transaction whose Account is the owner and whose sfDelegate + /// names the delegate — the only field that distinguishes a delegated transaction from + /// an ordinary one, and the reason it belongs on ITransactionCommon. + /// + [TestMethod] + public async Task TestDelegatedPayment_DelegateFieldSurvivesTheLedgerRoundTrip() + { + XrplWallet walletOwner = XrplWallet.Generate(); + XrplWallet walletDelegate = XrplWallet.Generate(); + XrplWallet walletDestination = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, walletOwner, walletDelegate, walletDestination); + + // PermissionValue 1 == ttPAYMENT (0) + 1 + DelegateSet grant = new DelegateSet + { + Account = walletOwner.ClassicAddress, + Authorize = walletDelegate.ClassicAddress, + Permissions = new List + { + new PermissionWrapper { Permission = new PermissionEntry { PermissionValue = 1 } }, + }, + }; + grant = await client.Autofill(grant); + ValidateResult(await client.SubmitAndWait(grant, walletOwner, true)); + + // The delegate signs, but the transaction is the owner's + Payment payment = new Payment + { + Account = walletOwner.ClassicAddress, + Destination = walletDestination.ClassicAddress, + Amount = new Currency { ValueAsXrp = 1 }, + Delegate = walletDelegate.ClassicAddress, + }; + payment = await client.Autofill(payment); + + TransactionSummary result = await client.SubmitAndWait(payment, walletDelegate, true); + ValidateResult(result); + + // Back out of the ledger into the typed model + TransactionResponse readBack = await client.Tx(new TxRequest(result.Hash)); + Assert.AreEqual(walletDelegate.ClassicAddress, readBack.Delegate, "Delegate must survive the ledger round trip"); + Assert.AreEqual(walletOwner.ClassicAddress, readBack.Account, "the transaction stays the owner's"); + + // And through the interface, which is what generic code holds + ITransactionCommon asCommon = readBack; + Assert.AreEqual(walletDelegate.ClassicAddress, asCommon.Delegate); + } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs new file mode 100644 index 00000000..171bb682 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Models; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration +{ + /// + /// Ground truth for the TxFormat corrections and the new model properties: a real rippled + /// has to accept exactly the field sets the SDK now declares. + /// + /// + /// TxFormat itself cannot be exercised end-to-end — it is inert at runtime. What these tests + /// pin is the claim underneath it: that the fields we added are real and land on the ledger, + /// and that the ones we removed were never top-level fields of those transactions. That claim + /// was previously backed only by reading rippled's transactions.macro. + /// + [TestClass] + public class TestIProtocolFieldSets + { + public TestContext TestContext { get; set; } + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; + + private static DateTime RippleEpoch => new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// Submit responses carry tx_json as a JsonElement on some paths, a JsonObject on others. + private static string HashOf(Submit submitted) => submitted.TxJson switch + { + System.Text.Json.JsonElement element => element.GetProperty("hash").GetString(), + System.Text.Json.Nodes.JsonObject node => node["hash"].GetValue(), + _ => throw new InvalidOperationException($"unexpected TxJson type {submitted.TxJson?.GetType()}"), + }; + + [TestMethod] + [Timeout(120000)] + public async Task TestICheckCreate_OptionalFieldsLandOnTheLedger() + { + IXrplClient client = await IntegrationTestConfig.CreateClientAsync(nodeType); + try + { + XrplWallet sender = XrplWallet.Generate(); + XrplWallet receiver = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sender, receiver); + + const string invoiceId = "6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59B"; + DateTime expiration = RippleEpoch.AddSeconds(900000000); + + CheckCreate tx = new CheckCreate + { + Account = sender.ClassicAddress, + Destination = receiver.ClassicAddress, + SendMax = new Currency { ValueAsXrp = 50 }, + DestinationTag = 13, + Expiration = expiration, + InvoiceID = invoiceId, + }; + Submit submitted = await client.Submit(tx.ToDictionary(), sender); + Assert.AreEqual("tesSUCCESS", submitted.EngineResult); + string hash = HashOf(submitted); + await Utils.LedgerAccept(client); + + AccountObjects objects = await client.AccountObjects( + new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }); + LOCheck check = objects.AccountObjectList.OfType().Single(); + + Assert.AreEqual(invoiceId, check.InvoiceID, "InvoiceID must survive as a Hash256"); + Assert.AreEqual(13u, check.DestinationTag); + Assert.AreEqual(expiration, check.Expiration); + + // ...and back into the typed transaction model, not just the ledger object: + // InvoiceID was uint? until this change and could not round-trip at all + CheckCreateResponse readBack = await client.Tx(new TxRequest(hash)) as CheckCreateResponse; + Assert.IsNotNull(readBack, "tx must deserialize into CheckCreateResponse"); + Assert.AreEqual(invoiceId, readBack.InvoiceID); + Assert.AreEqual(13u, readBack.DestinationTag); + Assert.AreEqual(expiration, readBack.Expiration); + } + finally + { + client.Dispose(); + } + } + + [TestMethod] + [Timeout(120000)] + public async Task TestICheckCash_DeliverMinLandsOnTheLedger() + { + IXrplClient client = await IntegrationTestConfig.CreateClientAsync(nodeType); + try + { + XrplWallet sender = XrplWallet.Generate(); + XrplWallet receiver = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sender, receiver); + + CheckCreate setup = new CheckCreate + { + Account = sender.ClassicAddress, + Destination = receiver.ClassicAddress, + SendMax = new Currency { ValueAsXrp = 50 }, + }; + await Utils.TestTransaction(client, setup.ToDictionary(), sender); + + AccountObjects created = await client.AccountObjects( + new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }); + string checkId = created.AccountObjectList.Single().Index; + + // DeliverMin is the CheckCash branch the suite never exercised; rippled takes + // exactly one of Amount / DeliverMin, which is why both are Optional in the format. + CheckCash cash = new CheckCash + { + Account = receiver.ClassicAddress, + CheckID = checkId, + DeliverMin = new Currency { ValueAsXrp = 10 }, + }; + await Utils.TestTransaction(client, cash.ToDictionary(), receiver); + + AccountObjects afterCash = await client.AccountObjects( + new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }); + Assert.IsEmpty(afterCash.AccountObjectList, "the check must be consumed"); + } + finally + { + client.Dispose(); + } + } + + [TestMethod] + [Timeout(120000)] + public async Task TestINFTokenMint_OfferFieldsLandOnTheLedger() + { + IXrplClient client = await IntegrationTestConfig.CreateClientAsync(nodeType); + try + { + XrplWallet minter = XrplWallet.Generate(); + XrplWallet buyer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, minter, buyer); + + DateTime expiration = RippleEpoch.AddSeconds(900000000); + + // Amount/Destination/Expiration are the NFTokenMintOffer fields that TxFormat + // was missing: minting with them creates the sell offer in the same transaction. + NFTokenMint mint = new NFTokenMint + { + Account = minter.ClassicAddress, + NFTokenTaxon = 0, + Flags = NFTokenMintFlags.tfTransferable, + Amount = new Currency { ValueAsXrp = 5 }, + Destination = buyer.ClassicAddress, + Expiration = expiration, + }; + await Utils.TestTransaction(client, mint.ToDictionary(), minter); + + AccountObjects offers = await client.AccountObjects( + new AccountObjectsRequest(minter.ClassicAddress) { Type = LedgerEntryType.NFTokenOffer }); + LONFTokenOffer offer = offers.AccountObjectList.OfType().Single(); + + Assert.AreEqual("5000000", offer.Amount.Value, "the mint-time sell offer must carry Amount"); + Assert.AreEqual(buyer.ClassicAddress, offer.Destination); + } + finally + { + client.Dispose(); + } + } + + [TestMethod] + [Timeout(120000)] + public async Task TestIAccountSet_NewModelFieldsSurviveTheLedgerRoundTrip() + { + IXrplClient client = await IntegrationTestConfig.CreateClientAsync(nodeType); + try + { + XrplWallet wallet = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, wallet); + + const string walletLocator = "CAFEBABE00000000000000000000000000000000000000000000000000000000"; + const uint operationLimit = 21337; + + // OperationLimit is inert on XRPL but must reach the wire unaltered - this is the + // Xahau Burn-2-Mint marker the typed API previously could not set at all. + AccountSet tx = new AccountSet(wallet.ClassicAddress) + { + WalletLocator = walletLocator, + WalletSize = 3, + OperationLimit = operationLimit, + }; + + Dictionary txJson = tx.ToDictionary(); + Submit submitted = await client.Submit(txJson, wallet); + Assert.AreEqual("tesSUCCESS", submitted.EngineResult); + string hash = HashOf(submitted); + await Utils.LedgerAccept(client); + + // Back out of the ledger and into the typed model - the full cycle the models used to break + TransactionResponse readBack = await client.Tx(new TxRequest(hash)); + Assert.AreEqual(operationLimit, readBack.OperationLimit, "OperationLimit must survive the ledger round trip"); + + AccountSetResponse typed = readBack as AccountSetResponse; + Assert.IsNotNull(typed, "tx must deserialize into AccountSetResponse"); + Assert.AreEqual(walletLocator, typed.WalletLocator); + Assert.AreEqual(3u, typed.WalletSize); + + AccountInfo info = await client.AccountInfo(new AccountInfoRequest(wallet.ClassicAddress)); + Assert.AreEqual(walletLocator, info.AccountData.WalletLocator, "WalletLocator must be stored on the account root"); + } + finally + { + client.Dispose(); + } + } + } +} diff --git a/Tests/Xrpl.Tests/Models/RippledTransactionFormats.cs b/Tests/Xrpl.Tests/Models/RippledTransactionFormats.cs new file mode 100644 index 00000000..86a181e6 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/RippledTransactionFormats.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +using TxFormat = Xrpl.Models.Transaction.TxFormat; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Reads the vendored rippled transactions.macro — the only place the protocol + /// states which fields belong to which transaction type. definitions.json and the + /// server_definitions RPC carry field codes but no per-transaction formats, so they + /// cannot answer this question. + /// + /// + /// The source is C++ macro text, not a stability-guaranteed contract: the TRANSACTION + /// signature has changed before and its own header invites callers to elide arguments. + /// Every parse step therefore fails loudly rather than yielding a thin or empty table — + /// a silently empty result would turn the conformance test green on nothing. + /// + internal static class RippledTransactionFormats + { + /// + /// TRANSACTION(ttTAG, code, Name, delegable, amendments, privileges, ({ {sfField, SoeX}, ... })) + /// + private static readonly Regex TransactionBlock = new Regex( + @"TRANSACTION\(\s*tt\w+\s*,\s*\d+\s*,\s*(?\w+)\s*,(?.*?)\}\)\)", + RegexOptions.Singleline | RegexOptions.Compiled); + + /// {sfField, SoeRequired} / {sfField, SoeOptional, SoeMptSupported} + private static readonly Regex FieldEntry = new Regex( + @"\{\s*sf(?\w+)\s*,\s*Soe(?Required|Optional|Default)\b", + RegexOptions.Compiled); + + /// Catches a requirement keyword the mapping below does not know yet. + private static readonly Regex AnyFieldEntry = new Regex( + @"\{\s*sf(?\w+)\s*,\s*Soe(?\w+)", + RegexOptions.Compiled); + + private const int MinimumExpectedTransactions = 60; + + internal static string FixturePath => + Path.Combine(AppContext.BaseDirectory, "Fixtures", "transactions.macro"); + + /// + /// Transaction name -> field name -> requirement, exactly as rippled declares it. + /// + internal static Dictionary> Parse() + { + if (!File.Exists(FixturePath)) + throw new InvalidOperationException($"Vendored transactions.macro not found at {FixturePath}"); + + string macro = File.ReadAllText(FixturePath); + if (string.IsNullOrWhiteSpace(macro)) + throw new InvalidOperationException("Vendored transactions.macro is empty"); + + Dictionary> formats = new(); + + foreach (Match block in TransactionBlock.Matches(macro)) + { + string name = block.Groups["name"].Value; + string body = block.Groups["body"].Value; + + // A requirement keyword we do not map would otherwise drop the field silently. + foreach (Match raw in AnyFieldEntry.Matches(body)) + { + string keyword = raw.Groups["requirement"].Value; + if (keyword is not ("Required" or "Optional" or "Default" or "MptSupported")) + { + throw new InvalidOperationException( + $"{name}.{raw.Groups["field"].Value}: unknown requirement keyword 'Soe{keyword}' — " + + "the macro format changed, update the parser before trusting this test"); + } + } + + Dictionary fields = new(); + foreach (Match field in FieldEntry.Matches(body)) + { + fields[field.Groups["field"].Value] = field.Groups["requirement"].Value switch + { + "Required" => TxFormat.Requirement.Required, + "Optional" => TxFormat.Requirement.Optional, + "Default" => TxFormat.Requirement.Default, + _ => throw new InvalidOperationException("unreachable"), + }; + } + + formats[name] = fields; + } + + if (formats.Count < MinimumExpectedTransactions) + { + throw new InvalidOperationException( + $"Parsed only {formats.Count} transaction formats from transactions.macro " + + $"(expected at least {MinimumExpectedTransactions}) — the macro layout changed " + + "and the parser silently stopped matching"); + } + + return formats; + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestAccountSet.cs b/Tests/Xrpl.Tests/Models/TestAccountSet.cs index 39dc7d27..6e414193 100644 --- a/Tests/Xrpl.Tests/Models/TestAccountSet.cs +++ b/Tests/Xrpl.Tests/Models/TestAccountSet.cs @@ -91,6 +91,33 @@ public async Task TestVerifyValid() await Helper.ThrowsExceptionAsync(() => Validation.Validate(accountSet), "AccountSet: out of TickSize"); accountSet.Remove("TickSize"); } + + [TestMethod] + public async Task TestUAccountSet_ValidatesWalletFieldTypes() + { + Dictionary tx = new Dictionary + { + { "TransactionType", "AccountSet" }, + { "Account", "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo" }, + { "Fee", "12" }, + }; + + // sfWalletLocator is a Hash256: a plain string is not enough, it must be 64 hex chars. + // Same rule the SignerListSet validator already applies to WalletLocator in a SignerEntry. + tx["WalletLocator"] = new string('A', 64); + tx["WalletSize"] = 3u; + await Validation.ValidateAccountSet(tx); + + tx["WalletLocator"] = 12345; + await Helper.ThrowsExceptionAsync(() => Validation.ValidateAccountSet(tx), "AccountSet: invalid WalletLocator"); + + tx["WalletLocator"] = "not a hash"; + await Helper.ThrowsExceptionAsync(() => Validation.ValidateAccountSet(tx), "AccountSet: invalid WalletLocator"); + + tx["WalletLocator"] = new string('A', 64); + tx["WalletSize"] = "3"; + await Helper.ThrowsExceptionAsync(() => Validation.ValidateAccountSet(tx), "AccountSet: invalid WalletSize"); + } } } diff --git a/Tests/Xrpl.Tests/Models/TestCheckCreate.cs b/Tests/Xrpl.Tests/Models/TestCheckCreate.cs index 8e915424..60b1fd32 100644 --- a/Tests/Xrpl.Tests/Models/TestCheckCreate.cs +++ b/Tests/Xrpl.Tests/Models/TestCheckCreate.cs @@ -116,6 +116,31 @@ public async Task TestVerify_InValid_InvoiceID() await Helper.ThrowsExceptionAsync(() => Validation.ValidateCheckCreate(tx), "CheckCreate: invalid InvoiceID"); await Helper.ThrowsExceptionAsync(() => Validation.Validate(tx), "CheckCreate: invalid InvoiceID"); } + + [TestMethod] + public void TestUCheckCreate_InvoiceIDIsAHash256() + { + // sfInvoiceID is Hash256, and ValidateCheckCreate above already demands a string. + // The typed property was uint?, so every non-null value threw on signing: + // "Can't decode `InvoiceID` from `123`". + const string invoiceId = "6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59B"; + CheckCreate tx = new CheckCreate + { + Account = "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo", + Destination = "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy", + SendMax = new global::Xrpl.Models.Common.Currency { ValueAsXrp = 50 }, + InvoiceID = invoiceId, + Sequence = 1, + Fee = new global::Xrpl.Models.Common.Currency { Value = "12" }, + SigningPublicKey = "", + }; + + System.Text.Json.Nodes.JsonObject json = System.Text.Json.Nodes.JsonNode.Parse(tx.ToJson()).AsObject(); + System.Text.Json.Nodes.JsonObject decoded = global::Xrpl.BinaryCodec.XrplBinaryCodec + .Decode(global::Xrpl.BinaryCodec.XrplBinaryCodec.Encode(json)).AsObject(); + + Assert.AreEqual(invoiceId, decoded["InvoiceID"].GetValue()); + } } } diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 3e15837e..a4962c39 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -234,6 +235,74 @@ public async Task TestUMPTokenIssuanceCreate_MutableFlagsMask() await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceCreate(tx)); } + /// + /// The fields a format declares on top of the common set shared by every transaction. + /// + private static Dictionary TypeSpecificFields( + BinaryCodec.Types.TransactionType transactionType) + { + TxFormat common = new TxFormat(); + return TxFormat.Formats[transactionType] + .Where(entry => !common.ContainsKey(entry.Key)) + .ToDictionary(entry => entry.Key, entry => entry.Value); + } + + private static void AssertFormat( + BinaryCodec.Types.TransactionType transactionType, + Dictionary expected) + { + Dictionary actual = TypeSpecificFields(transactionType); + CollectionAssert.AreEquivalent( + expected.Keys.ToArray(), + actual.Keys.ToArray(), + $"{transactionType} declares the wrong field set"); + + foreach (KeyValuePair field in expected) + { + Assert.AreEqual(field.Value, actual[field.Key], $"{transactionType}.{field.Key} requirement"); + } + } + + [TestMethod] + public void TestUCheckTransactions_DeclareTheirOwnFields() + { + // All three Check formats used to be a verbatim copy of PaymentChannelClaim + // (Channel/Amount/Balance/Signature/PublicKey). Field sets per rippled + // include/xrpl/protocol/detail/transactions.macro. + AssertFormat(BinaryCodec.Types.TransactionType.CheckCreate, new Dictionary + { + [BinaryCodec.Enums.Field.Destination] = TxFormat.Requirement.Required, + [BinaryCodec.Enums.Field.SendMax] = TxFormat.Requirement.Required, + [BinaryCodec.Enums.Field.Expiration] = TxFormat.Requirement.Optional, + [BinaryCodec.Enums.Field.DestinationTag] = TxFormat.Requirement.Optional, + [BinaryCodec.Enums.Field.InvoiceID] = TxFormat.Requirement.Optional, + }); + + AssertFormat(BinaryCodec.Types.TransactionType.CheckCash, new Dictionary + { + [BinaryCodec.Enums.Field.CheckID] = TxFormat.Requirement.Required, + [BinaryCodec.Enums.Field.Amount] = TxFormat.Requirement.Optional, + [BinaryCodec.Enums.Field.DeliverMin] = TxFormat.Requirement.Optional, + }); + + AssertFormat(BinaryCodec.Types.TransactionType.CheckCancel, new Dictionary + { + [BinaryCodec.Enums.Field.CheckID] = TxFormat.Requirement.Required, + }); + } + + [TestMethod] + public void TestUSignerListSet_DeclaresNoTopLevelWalletLocator() + { + // sfWalletLocator is a member of the nested SignerEntry object, not a + // top-level SignerListSet field: rippled declares only quorum and entries. + AssertFormat(BinaryCodec.Types.TransactionType.SignerListSet, new Dictionary + { + [BinaryCodec.Enums.Field.SignerQuorum] = TxFormat.Requirement.Required, + [BinaryCodec.Enums.Field.SignerEntries] = TxFormat.Requirement.Optional, + }); + } + [TestMethod] public void TestULORippleState_SponsorFields_Deserialize() { diff --git a/Tests/Xrpl.Tests/Models/TestUTransactionProtocolFields.cs b/Tests/Xrpl.Tests/Models/TestUTransactionProtocolFields.cs new file mode 100644 index 00000000..69dd7bfe --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUTransactionProtocolFields.cs @@ -0,0 +1,339 @@ +using System.Collections.Generic; +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.BinaryCodec; +using Xrpl.Client.Json; +using Xrpl.Models.Common; +using Xrpl.Models.Transactions; +using Xrpl.Wallet; + +using TxFormat = Xrpl.Models.Transaction.TxFormat; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Pins the transaction fields that the protocol declares but the models used to drop: + /// the common fields sfOperationLimit and sfDelegate, and the AccountSet-local + /// sfWalletLocator / sfWalletSize. Also pins that unset fields stay out of the + /// signed bytes, and that the retired sfTarget is not resurrected. + /// + [TestClass] + public class TestUTransactionProtocolFields + { + // Deterministic Ed25519 key material - the same seed yields the same signature bytes. + private const string Seed = "sEdSKaCy2JT7JaM7v95H9SxkhP9wS2r"; + private const string DestinationSeed = "sEdTM1uX8pu2do5XvTnutH6HsouMaM2"; + + // Blobs captured from the released 10.9.1 signing path BEFORE the new properties existed. + // Any change to the bytes of a transaction that does not set the new fields breaks these. + private const string GoldenAccountSet = + "120003240000000168400000000000000C7321ED01FA53FA5A7E77798F882ECE20B1ABC00BB358A9E55A202D0D0676BD0CE37A63" + + "74405E9B4DB6638729743A12BCF8A1E67B2E1AF0DBAE7DE2DCCF6422E2D00E2D7EB9F615B1A6FF276E66D3641ECEE3D31A2F0E22" + + "1FEE92654280BFF5C160670C300E8114D28B177E48D9A8D057E70F7E464B498367281B98"; + + private const string GoldenPayment = + "12000024000000016140000000000F424068400000000000000C7321ED01FA53FA5A7E77798F882ECE20B1ABC00BB358A9E55A20" + + "2D0D0676BD0CE37A6374405774060AC195CEF17762B1989FC4FD0E0F2F1D9705A1397EC6B368615110F353E1BC1DA7F679A29F0F" + + "C5745BF9FF2567531CCE74D645E470CD7FDB5A0A5820058114D28B177E48D9A8D057E70F7E464B498367281B988314A6070B8A18" + + "22E3322676A99F0C804EE2D15B8270"; + + private const string GoldenTrustSet = + "1200142200020000240000000163D5038D7EA4C680000000000000000000000000005553440000000000A6070B8A1822E3322676" + + "A99F0C804EE2D15B827068400000000000000C7321ED01FA53FA5A7E77798F882ECE20B1ABC00BB358A9E55A202D0D0676BD0CE3" + + "7A637440F2ACDE17AEFF830E0366478A0C48BFAB6AC7B6BC40D9D95F430A56533541484B909075B79C62BE5B610258494F402599" + + "DA18C390B9F868399BBD11C4BA27D0038114D28B177E48D9A8D057E70F7E464B498367281B98"; + + private static XrplWallet Wallet => XrplWallet.FromSeed(Seed); + + private static string Destination => XrplWallet.FromSeed(DestinationSeed).ClassicAddress; + + private static Currency MinimalFee => new Currency { Value = "12" }; + + [TestMethod] + public void TestUOperationLimit_SurvivesResponseDeserialization() + { + // Xahau Burn-2-Mint marks the burn with OperationLimit = destination network id. + // Reading such a transaction back used to lose the marker entirely. + string json = $@"{{ + ""TransactionType"": ""AccountSet"", + ""Account"": ""{Wallet.ClassicAddress}"", + ""Fee"": ""5000000"", + ""Sequence"": 1, + ""OperationLimit"": 21337 + }}"; + + ITransactionResponse response = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.IsInstanceOfType(response); + Assert.AreEqual(21337u, ((TransactionResponse)response).OperationLimit); + StringAssert.Contains(response.ToJson(), "\"OperationLimit\":21337"); + Assert.IsTrue(Xrpl.Models.Transactions.Common.TryGetUInt32(response.ToDictionary()["OperationLimit"], out uint fromDictionary)); + Assert.AreEqual(21337u, fromDictionary); + } + + [TestMethod] + public void TestUOperationLimit_TypedSigningMatchesDictionaryPath() + { + // The dictionary route is the workaround consumers use today; the typed model must + // produce byte-identical output so switching to it cannot change a signature. + AccountSet typed = new AccountSet(Wallet.ClassicAddress) + { + Fee = MinimalFee, + Sequence = 1, + OperationLimit = 21337, + SigningPublicKey = Wallet.PublicKey, + }; + + Dictionary viaDictionary = new Dictionary + { + ["TransactionType"] = "AccountSet", + ["Account"] = Wallet.ClassicAddress, + ["Fee"] = "12", + ["Sequence"] = 1u, + ["OperationLimit"] = 21337u, + ["SigningPubKey"] = Wallet.PublicKey, + }; + + Assert.AreEqual(Wallet.Sign(viaDictionary).TxBlob, Wallet.Sign(typed).TxBlob); + Assert.AreEqual(21337u, XrplBinaryCodec.Decode(Wallet.Sign(typed).TxBlob).AsObject()["OperationLimit"].GetValue()); + } + + [TestMethod] + public void TestUDelegate_SurvivesResponseDeserialization() + { + string json = $@"{{ + ""TransactionType"": ""Payment"", + ""Account"": ""{Wallet.ClassicAddress}"", + ""Destination"": ""{Destination}"", + ""Amount"": ""1000000"", + ""Fee"": ""12"", + ""Sequence"": 1, + ""Delegate"": ""{Destination}"" + }}"; + + ITransactionResponse response = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.IsInstanceOfType(response); + Assert.AreEqual(Destination, ((TransactionResponse)response).Delegate); + StringAssert.Contains(response.ToJson(), "\"Delegate\":"); + } + + [TestMethod] + public void TestUDelegate_TypedSigningMatchesDictionaryPath() + { + Payment typed = new Payment + { + Account = Wallet.ClassicAddress, + Destination = Destination, + Amount = new Currency { ValueAsXrp = 1m }, + Fee = MinimalFee, + Sequence = 1, + Delegate = Destination, + SigningPublicKey = Wallet.PublicKey, + }; + + Dictionary viaDictionary = new Dictionary + { + ["TransactionType"] = "Payment", + ["Account"] = Wallet.ClassicAddress, + ["Destination"] = Destination, + ["Amount"] = "1000000", + ["Fee"] = "12", + ["Sequence"] = 1u, + ["Delegate"] = Destination, + ["SigningPubKey"] = Wallet.PublicKey, + }; + + Assert.AreEqual(Wallet.Sign(viaDictionary).TxBlob, Wallet.Sign(typed).TxBlob); + Assert.AreEqual(Destination, XrplBinaryCodec.Decode(Wallet.Sign(typed).TxBlob).AsObject()["Delegate"].GetValue()); + } + + [TestMethod] + public void TestUAccountSetWalletFields_RoundTripThroughBinaryCodec() + { + AccountSet typed = new AccountSet(Wallet.ClassicAddress) + { + Fee = MinimalFee, + Sequence = 1, + WalletLocator = new string('A', 64), + WalletSize = 3, + SigningPublicKey = Wallet.PublicKey, + }; + + System.Text.Json.Nodes.JsonObject decoded = XrplBinaryCodec.Decode(Wallet.Sign(typed).TxBlob).AsObject(); + + Assert.AreEqual(new string('A', 64), decoded["WalletLocator"].GetValue()); + Assert.AreEqual(3u, decoded["WalletSize"].GetValue()); + } + + [TestMethod] + public void TestUAccountSetWalletFields_SurviveResponseDeserialization() + { + string json = $@"{{ + ""TransactionType"": ""AccountSet"", + ""Account"": ""{Wallet.ClassicAddress}"", + ""Fee"": ""12"", + ""Sequence"": 1, + ""WalletLocator"": ""{new string('A', 64)}"", + ""WalletSize"": 3 + }}"; + + AccountSetResponse response = (AccountSetResponse)JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(new string('A', 64), response.WalletLocator); + Assert.AreEqual(3u, response.WalletSize); + } + + [TestMethod] + public void TestUUnsetProtocolFields_LeaveSignedBytesUnchanged() + { + // The regression guard for adding fields to the common base: a transaction that + // does not set them must sign to exactly the bytes it signed to before. + AccountSet accountSet = new AccountSet(Wallet.ClassicAddress) + { + Fee = MinimalFee, + Sequence = 1, + SigningPublicKey = Wallet.PublicKey, + }; + Payment payment = new Payment + { + Account = Wallet.ClassicAddress, + Destination = Destination, + Amount = new Currency { ValueAsXrp = 1m }, + Fee = MinimalFee, + Sequence = 1, + SigningPublicKey = Wallet.PublicKey, + }; + TrustSet trustSet = new TrustSet + { + Account = Wallet.ClassicAddress, + LimitAmount = new Currency { CurrencyCode = "USD", Issuer = Destination, Value = "100" }, + Fee = MinimalFee, + Sequence = 1, + SigningPublicKey = Wallet.PublicKey, + }; + + Assert.AreEqual(GoldenAccountSet, Wallet.Sign(accountSet).TxBlob); + Assert.AreEqual(GoldenPayment, Wallet.Sign(payment).TxBlob); + Assert.AreEqual(GoldenTrustSet, Wallet.Sign(trustSet).TxBlob); + } + + [TestMethod] + public void TestUUnsetProtocolFields_StayOutOfOutgoingJson() + { + string json = new Payment + { + Account = Wallet.ClassicAddress, + Destination = Destination, + Amount = new Currency { ValueAsXrp = 1m }, + Fee = MinimalFee, + Sequence = 1, + SigningPublicKey = Wallet.PublicKey, + }.ToJson(); + + foreach (string field in new[] { "OperationLimit", "Delegate", "WalletLocator", "WalletSize" }) + { + Assert.IsFalse(json.Contains(field), $"{field} must not appear in outgoing JSON when unset"); + } + } + + [TestMethod] + public async System.Threading.Tasks.Task TestUBaseTransaction_ValidatesNewCommonFieldTypes() + { + Dictionary tx = new Dictionary + { + ["TransactionType"] = "AccountSet", + ["Account"] = Wallet.ClassicAddress, + }; + + tx["OperationLimit"] = 21337u; + tx["Delegate"] = Destination; + await Xrpl.Models.Transactions.Common.ValidateBaseTransaction(tx); + + tx["OperationLimit"] = "not a number"; + await Assert.ThrowsExactlyAsync( + () => Xrpl.Models.Transactions.Common.ValidateBaseTransaction(tx)); + + tx["OperationLimit"] = 21337u; + tx["Delegate"] = 12345; + await Assert.ThrowsExactlyAsync( + () => Xrpl.Models.Transactions.Common.ValidateBaseTransaction(tx)); + } + + [TestMethod] + public void TestUCommonFields_AreReachableThroughTheInterface() + { + // Delegate/OperationLimit (rippled commonFields) and Sponsor/SponsorFlags (XLS-68) + // are common to every transaction, so ITransactionCommon must expose them: + // otherwise anything typed by the interface — Batch.RawTransaction, + // SimulateRequest.Transaction — can only reach them through a cast. + ITransactionCommon request = new AccountSet(Wallet.ClassicAddress) + { + Delegate = Destination, + OperationLimit = 21337, + Sponsor = Destination, + SponsorFlags = SponsorCoverage.spfSponsorFee, + }; + + Assert.AreEqual(Destination, request.Delegate); + Assert.AreEqual(21337u, request.OperationLimit); + Assert.AreEqual(Destination, request.Sponsor); + Assert.AreEqual(SponsorCoverage.spfSponsorFee, request.SponsorFlags); + + ITransactionCommon response = new AccountSetResponse + { + Account = Wallet.ClassicAddress, + Delegate = Destination, + OperationLimit = 21337, + Sponsor = Destination, + SponsorFlags = SponsorCoverage.spfSponsorFee, + }; + + Assert.AreEqual(Destination, response.Delegate); + Assert.AreEqual(21337u, response.OperationLimit); + Assert.AreEqual(Destination, response.Sponsor); + Assert.AreEqual(SponsorCoverage.spfSponsorFee, response.SponsorFlags); + } + + [TestMethod] + public void TestUBatchInnerTransaction_ExposesDelegateWithoutACast() + { + // The concrete reason this matters: BatchUtils resolves required signers from an + // inner transaction's Delegate, but Batch.RawTransaction is typed ITransactionRequest. + IBatch batch = new Batch + { + Account = Wallet.ClassicAddress, + RawTransactions = new List + { + new RawTransactionWrapper + { + RawTransaction = new Payment + { + Account = Wallet.ClassicAddress, + Destination = Destination, + Amount = new Currency { ValueAsXrp = 1m }, + Delegate = Destination, + }, + }, + }, + }; + + ITransactionRequest inner = batch.RawTransactions[0].RawTransaction; + Assert.AreEqual(Destination, inner.Delegate); + } + + [TestMethod] + public void TestUTicketCreate_DoesNotCarryRetiredTargetField() + { + // sfTarget is retired in rippled (AccountID nth 7 is marked unused) and absent from + // definitions.json; TicketCreate carries only sfTicketCount since the TicketBatch amendment. + TxFormat ticketCreate = TxFormat.Formats[BinaryCodec.Types.TransactionType.TicketCreate]; + + Assert.IsTrue(ticketCreate.ContainsKey(BinaryCodec.Enums.Field.TicketCount)); + Assert.IsFalse(ticketCreate.ContainsKey(BinaryCodec.Enums.Field.Target)); + Assert.IsFalse(ticketCreate.ContainsKey(BinaryCodec.Enums.Field.Expiration)); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUTxFormatConformance.cs b/Tests/Xrpl.Tests/Models/TestUTxFormatConformance.cs new file mode 100644 index 00000000..ecd09f3e --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUTxFormatConformance.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TxFormat = Xrpl.Models.Transaction.TxFormat; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds to the field sets rippled declares in the vendored + /// transactions.macro. + /// + /// + /// TxFormat is inert at runtime — TxFormat.Validate is not on the signing path, the + /// binary codec serializes straight from definitions.json — so a wrong entry produces no + /// symptom and nothing else in the suite notices. That is how three Check formats stayed + /// verbatim copies of PaymentChannelClaim, and how a top-level WalletLocator sat on + /// SignerListSet. This test is the only thing standing between that table and rot. + /// + /// It deliberately compares against a PINNED copy of the macro, not the live develop branch: + /// upstream drift is protocol-watch's job (transactions.macro is in its watch list), and a + /// network-dependent test would turn red on Ripple's release schedule rather than on ours. + /// + [TestClass] + public class TestUTxFormatConformance + { + /// + /// Fields shared by every transaction, declared once in the TxFormat constructor. + /// rippled keeps them in a separate commonFields list, so they are excluded on both sides. + /// + private static HashSet CommonFieldNames() => + new TxFormat().Keys.Select(field => field.Name).ToHashSet(StringComparer.Ordinal); + + [TestMethod] + public void TestUTxFormat_MatchesRippledTransactionsMacro() + { + Dictionary> upstream = RippledTransactionFormats.Parse(); + HashSet common = CommonFieldNames(); + StringBuilder report = new StringBuilder(); + + foreach (KeyValuePair entry in TxFormat.Formats) + { + string name = entry.Key.Name; + if (!upstream.TryGetValue(name, out Dictionary declared)) + { + report.AppendLine($"{name}: present in TxFormat, absent from transactions.macro"); + continue; + } + + Dictionary mine = entry.Value + .Where(field => !common.Contains(field.Key.Name)) + .ToDictionary(field => field.Key.Name, field => field.Value, StringComparer.Ordinal); + + foreach (string field in declared.Keys.Except(mine.Keys).OrderBy(f => f, StringComparer.Ordinal)) + { + report.AppendLine($"{name}.{field}: declared by rippled, missing from TxFormat"); + } + + foreach (string field in mine.Keys.Except(declared.Keys).OrderBy(f => f, StringComparer.Ordinal)) + { + report.AppendLine($"{name}.{field}: in TxFormat, not a field of this transaction in rippled"); + } + + foreach (string field in declared.Keys.Intersect(mine.Keys).OrderBy(f => f, StringComparer.Ordinal)) + { + if (declared[field] != mine[field]) + { + report.AppendLine($"{name}.{field}: rippled={declared[field]}, TxFormat={mine[field]}"); + } + } + } + + foreach (string name in upstream.Keys + .Except(TxFormat.Formats.Keys.Select(type => type.Name)) + .OrderBy(n => n, StringComparer.Ordinal)) + { + report.AppendLine($"{name}: declared by rippled, absent from TxFormat"); + } + + Assert.AreEqual( + string.Empty, + report.ToString(), + $"TxFormat diverges from the pinned rippled transactions.macro:{Environment.NewLine}{report}"); + } + + [TestMethod] + public void TestUTxFormatConformance_ParserFailsLoudlyOnAnEmptySource() + { + // Guards the guard: a parser that quietly returns nothing would make the + // conformance test above pass against an empty table. + Dictionary> parsed = RippledTransactionFormats.Parse(); + + Assert.IsGreaterThan(60, parsed.Count, "the vendored macro must yield a full format table"); + Assert.IsTrue(parsed.ContainsKey("Payment"), "Payment must be present — the parser matched nothing sane"); + Assert.IsTrue(parsed["Payment"].ContainsKey("Destination")); + Assert.AreEqual(TxFormat.Requirement.Required, parsed["Payment"]["Destination"]); + } + } +} diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index 8feee976..e560d5a3 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -30,6 +30,9 @@ PreserveNewest + + PreserveNewest + diff --git a/Tests/Xrpl.X402.Tests/Integration/X402T54LiveInteropTests.cs b/Tests/Xrpl.X402.Tests/Integration/X402T54LiveInteropTests.cs index aa26c3d1..422affa8 100644 --- a/Tests/Xrpl.X402.Tests/Integration/X402T54LiveInteropTests.cs +++ b/Tests/Xrpl.X402.Tests/Integration/X402T54LiveInteropTests.cs @@ -56,6 +56,11 @@ namespace Xrpl.X402.Tests.Integration; /// [TestClass] [DoNotParallelize] // live tests share one client + treasury; serialize to keep the single faucet dependency clean +// Excluded from the default integration run: these are the only tests in the suite that need +// the public XRPL testnet faucet and the hosted t54 facilitator, so a green CI would otherwise +// depend on two third-party services being up. Run them deliberately: +// dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --filter "TestCategory=Live" +[TestCategory("Live")] public class X402T54LiveInteropTests { private const string TestnetUrl = "wss://s.altnet.rippletest.net:51233"; diff --git a/Xrpl/Models/Transactions/AccountSet.cs b/Xrpl/Models/Transactions/AccountSet.cs index ada78f7b..5c33795c 100644 --- a/Xrpl/Models/Transactions/AccountSet.cs +++ b/Xrpl/Models/Transactions/AccountSet.cs @@ -139,6 +139,10 @@ public AccountSet(string account) : this() public uint? TickSize { get; set; } /// public string NFTokenMinter { get; set; } + /// + public string WalletLocator { get; set; } + /// + public uint? WalletSize { get; set; } } /// @@ -187,6 +191,18 @@ public interface IAccountSet : ITransactionCommon /// public string NFTokenMinter { get; set; } //todo not found field NFTokenMinter?: string + + /// + /// (Optional) An arbitrary 256-bit value stored on the account root, for use by + /// external applications (sfWalletLocator). + /// + public string WalletLocator { get; set; } + + /// + /// (Optional) Legacy field, kept in rippled's AccountSet format but not acted on by the + /// transactor. Exposed so that transactions carrying it survive a read/write round trip. + /// + public uint? WalletSize { get; set; } } /// @@ -209,6 +225,12 @@ public class AccountSetResponse : TransactionResponse, IAccountSet /// public string NFTokenMinter { get; set; } + + /// + public string WalletLocator { get; set; } + + /// + public uint? WalletSize { get; set; } } public partial class Validation @@ -268,6 +290,17 @@ public static async Task ValidateAccountSet(Dictionary tx) throw new ValidationException("AccountSet: out of TickSize"); } + // sfWalletLocator is a Hash256, so a string alone is not enough - the same + // 256-bit rule the SignerListSet validator applies to a SignerEntry's WalletLocator + if (tx.TryGetValue("WalletLocator", out var WalletLocator) && WalletLocator is not null) + { + if (WalletLocator is not string walletLocator || + walletLocator.Length != 64 || !walletLocator.All(Uri.IsHexDigit)) + throw new ValidationException("AccountSet: invalid WalletLocator"); + } + + if (tx.TryGetValue("WalletSize", out var WalletSize) && !Common.IsUInt32(WalletSize)) + throw new ValidationException("AccountSet: invalid WalletSize"); } } diff --git a/Xrpl/Models/Transactions/CheckCreate.cs b/Xrpl/Models/Transactions/CheckCreate.cs index c5e66150..2351de68 100644 --- a/Xrpl/Models/Transactions/CheckCreate.cs +++ b/Xrpl/Models/Transactions/CheckCreate.cs @@ -29,7 +29,7 @@ public CheckCreate() /// public DateTime? Expiration { get; set; } /// - public uint? InvoiceID { get; set; } + public string InvoiceID { get; set; } } /// @@ -64,7 +64,7 @@ public interface ICheckCreate : ITransactionCommon /// Arbitrary 256-bit hash representing a specific reason or identifier for.
/// this Check. ///
- uint? InvoiceID { get; set; } + string InvoiceID { get; set; } } /// @@ -79,7 +79,7 @@ public class CheckCreateResponse : TransactionResponse, ICheckCreate /// public DateTime? Expiration { get; set; } /// - public uint? InvoiceID { get; set; } + public string InvoiceID { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Transactions/Common.cs b/Xrpl/Models/Transactions/Common.cs index e0b0c6a8..48b300c4 100644 --- a/Xrpl/Models/Transactions/Common.cs +++ b/Xrpl/Models/Transactions/Common.cs @@ -313,6 +313,14 @@ public static Task ValidateBaseTransaction(Dictionary tx) { throw new ValidationException("BaseTransaction: invalid SponsorFlags"); } + if (tx.TryGetValue("Delegate", out var Delegate) && Delegate is not string { }) + { + throw new ValidationException("BaseTransaction: invalid Delegate"); + } + if (tx.TryGetValue("OperationLimit", out var OperationLimit) && !IsUInt32(OperationLimit)) + { + throw new ValidationException("BaseTransaction: invalid OperationLimit"); + } return Task.CompletedTask; } } @@ -375,16 +383,18 @@ public Dictionary ToDictionary() /// public uint? TicketSequence { get; set; } - /// - /// XLS-68: the account sponsoring this transaction's fee and/or reserve. - /// + /// public string Sponsor { get; set; } - /// - /// XLS-68: what the sponsor covers (serialized as the numeric sfSponsorFlags value). - /// + /// public SponsorCoverage? SponsorFlags { get; set; } + /// + public string Delegate { get; set; } + + /// + public uint? OperationLimit { get; set; } + //todo not found fields - SourceTag?: number, TicketSequence?: number } @@ -685,6 +695,30 @@ public interface ITransactionCommon /// If this is provided, Sequence must be 0. Cannot be used with AccountTxnID. /// public uint? TicketSequence { get; set; } + + /// + /// (Optional) The account submitting this transaction on behalf of , + /// under permissions granted by DelegateSet (sfDelegate, a rippled common field). + /// + public string Delegate { get; set; } + + /// + /// (Optional) sfOperationLimit, a rippled common field accepted on every transaction type. + /// No XRPL transactor reads it; on Xahau a Burn-2-Mint burn carries the destination + /// network id here. + /// + public uint? OperationLimit { get; set; } + + /// + /// XLS-68: the account sponsoring this transaction's fee and/or reserve. + /// + public string Sponsor { get; set; } + + /// + /// XLS-68: what the sponsor covers (serialized as the numeric sfSponsorFlags value). + /// + public SponsorCoverage? SponsorFlags { get; set; } + /// /// convert transaction to string json value /// @@ -759,16 +793,18 @@ public class TransactionResponse : BaseTransactionResponse, ITransactionResponse [JsonPropertyName("meta")] public Meta Meta { get; set; } - /// - /// XLS-68: the account sponsoring this transaction's fee and/or reserve. - /// + /// public string Sponsor { get; set; } - /// - /// XLS-68: what the sponsor covers (serialized as the numeric sfSponsorFlags value). - /// + /// public SponsorCoverage? SponsorFlags { get; set; } + /// + public string Delegate { get; set; } + + /// + public uint? OperationLimit { get; set; } + /// public string ToJson() { diff --git a/Xrpl/Models/Transactions/TxFormat.cs b/Xrpl/Models/Transactions/TxFormat.cs index b6e1f009..5d43aa4a 100644 --- a/Xrpl/Models/Transactions/TxFormat.cs +++ b/Xrpl/Models/Transactions/TxFormat.cs @@ -170,19 +170,19 @@ static TxFormat() [Field.OfferSequence] = Requirement.Required }, // 9 + // Since the TicketBatch amendment rippled's TicketCreate carries only sfTicketCount; + // the pre-amendment sfTarget/sfExpiration pair is gone (sfTarget is retired outright - + // AccountID nth 7 is marked unused in sfields.macro and absent from definitions.json). [BinaryCodec.Types.TransactionType.TicketCreate] = new TxFormat { - [Field.Target] = Requirement.Optional, - [Field.Expiration] = Requirement.Optional, [Field.TicketCount] = Requirement.Required }, // 11 + // WalletLocator belongs to the nested SignerEntry object, not to SignerListSet itself [BinaryCodec.Types.TransactionType.SignerListSet] = new TxFormat { [Field.SignerQuorum] = Requirement.Required, - [Field.SignerEntries] = Requirement.Optional, - [Field.WalletLocator] = Requirement.Optional, - + [Field.SignerEntries] = Requirement.Optional }, [BinaryCodec.Types.TransactionType.PaymentChannelCreate] = new TxFormat() { @@ -210,27 +210,22 @@ static TxFormat() }, [BinaryCodec.Types.TransactionType.CheckCreate] = new TxFormat() { - [Field.Channel] = Requirement.Required, - [Field.Amount] = Requirement.Optional, - [Field.Balance] = Requirement.Optional, - [Field.Signature] = Requirement.Optional, - [Field.PublicKey] = Requirement.Optional + [Field.Destination] = Requirement.Required, + [Field.SendMax] = Requirement.Required, + [Field.Expiration] = Requirement.Optional, + [Field.DestinationTag] = Requirement.Optional, + [Field.InvoiceID] = Requirement.Optional }, + // Exactly one of Amount / DeliverMin is expected, which the format cannot express [BinaryCodec.Types.TransactionType.CheckCash] = new TxFormat() { - [Field.Channel] = Requirement.Required, + [Field.CheckID] = Requirement.Required, [Field.Amount] = Requirement.Optional, - [Field.Balance] = Requirement.Optional, - [Field.Signature] = Requirement.Optional, - [Field.PublicKey] = Requirement.Optional + [Field.DeliverMin] = Requirement.Optional }, [BinaryCodec.Types.TransactionType.CheckCancel] = new TxFormat() { - [Field.Channel] = Requirement.Required, - [Field.Amount] = Requirement.Optional, - [Field.Balance] = Requirement.Optional, - [Field.Signature] = Requirement.Optional, - [Field.PublicKey] = Requirement.Optional + [Field.CheckID] = Requirement.Required }, [BinaryCodec.Types.TransactionType.DepositPreauth] = new TxFormat() { @@ -257,7 +252,11 @@ static TxFormat() [Field.NFTokenTaxon] = Requirement.Required, [Field.Issuer] = Requirement.Optional, [Field.TransferFee] = Requirement.Optional, - [Field.URI] = Requirement.Optional + [Field.URI] = Requirement.Optional, + // NFTokenMintOffer: the model gained these in 10.7.0, the format lagged behind + [Field.Amount] = Requirement.Optional, + [Field.Destination] = Requirement.Optional, + [Field.Expiration] = Requirement.Optional }, [BinaryCodec.Types.TransactionType.NFTokenModify] = new TxFormat { @@ -375,11 +374,8 @@ static TxFormat() [Field.Provider] = Requirement.Optional, [Field.AssetClass] = Requirement.Optional, [Field.URI] = Requirement.Optional, - - [Field.BaseAsset] = Requirement.Required, - [Field.QuoteAsset] = Requirement.Required, - [Field.AssetPrice] = Requirement.Optional, - [Field.Scale] = Requirement.Optional, + // BaseAsset/QuoteAsset/AssetPrice/Scale belong to the nested PriceData + // entries of PriceDataSeries, not to OracleSet itself }, [BinaryCodec.Types.TransactionType.OracleDelete] = new TxFormat { @@ -500,7 +496,6 @@ static TxFormat() [BinaryCodec.Types.TransactionType.VaultCreate] = new TxFormat { [Field.Asset] = Requirement.Required, - [Field.Amount] = Requirement.Optional, [Field.AssetsMaximum] = Requirement.Optional, [Field.MPTokenMetadata] = Requirement.Optional, [Field.WithdrawalPolicy] = Requirement.Optional, diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index c7fcbe11..745c024a 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.9.1.0 + 10.10.0.0