diff --git a/.ci-config/docker-compose.batchv11.yml b/.ci-config/docker-compose.batchv11.yml index bcd4224b..d1076865 100644 --- a/.ci-config/docker-compose.batchv11.yml +++ b/.ci-config/docker-compose.batchv11.yml @@ -12,6 +12,8 @@ services: - "127.0.0.1:5005:5005" - "127.0.0.1:5006:5006" - "127.0.0.1:6006:6006" + # credential-protected admin ws port, see [port_ws_admin_auth] in rippled.batchv11.cfg + - "127.0.0.1:6007:6007" volumes: - ./rippled.batchv11.cfg:/config/rippled.cfg:ro - ./validators.txt:/config/validators.txt:ro diff --git a/.ci-config/docker-compose.ci.yml b/.ci-config/docker-compose.ci.yml index 0e15eedf..bf42c40a 100644 --- a/.ci-config/docker-compose.ci.yml +++ b/.ci-config/docker-compose.ci.yml @@ -4,9 +4,19 @@ services: container_name: rippled-service command: ["-a", "--start"] ports: - - "5005:5005" - - "5006:5006" - - "6006:6006" + # Publish to loopback only, matching docker-compose.batchv11.yml. Every port below grants + # the admin role (rippled.cfg sets `admin = 0.0.0.0` on each stanza), and admin means + # `stop`, `connect`, `feature`, `validation_seed` — node control, not just reads. Only + # 6007 asks for credentials; the rest accept anyone who can reach them. + # Binding wider buys nothing: tests connect over localhost and the ledger-acceptor + # reaches the node through the compose network (http://xrpld:5006), not a published port. + # A host firewall is not a substitute — Docker's DNAT rules sit ahead of the chains ufw + # manages, so a published port stays reachable even with ufw enabled. + - "127.0.0.1:5005:5005" + - "127.0.0.1:5006:5006" + - "127.0.0.1:6006:6006" + # credential-protected admin ws port, see [port_ws_admin_auth] in rippled.cfg + - "127.0.0.1:6007:6007" volumes: - ./rippled.cfg:/config/rippled.cfg:ro - ./validators.txt:/config/validators.txt:ro diff --git a/.ci-config/rippled.batchv11.cfg b/.ci-config/rippled.batchv11.cfg index a38a4287..c68c941f 100644 --- a/.ci-config/rippled.batchv11.cfg +++ b/.ci-config/rippled.batchv11.cfg @@ -2,6 +2,7 @@ port_rpc_admin_local port_rpc_admin port_ws_admin +port_ws_admin_auth [port_rpc_admin_local] port = 5005 @@ -21,6 +22,15 @@ ip = 0.0.0.0 protocol = ws admin = 0.0.0.0 +# Mirrors [port_ws_admin_auth] of rippled.cfg so TestIAdminCredentials behaves the same on both stands. +[port_ws_admin_auth] +port = 6007 +ip = 0.0.0.0 +protocol = ws +admin = 0.0.0.0 +admin_user = xrpl_admin +admin_password = xrpl_admin_secret + [node_size] small diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index b8a56e8e..55cd3f06 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -2,6 +2,7 @@ port_rpc_admin_local port_rpc_admin port_ws_admin +port_ws_admin_auth [port_rpc_admin_local] port = 5005 @@ -21,6 +22,20 @@ ip = 0.0.0.0 protocol = ws admin = 0.0.0.0 +# Credential-protected admin port used only by TestIAdminCredentials. +# rippled grants the admin role over ws/wss only when the client sends admin_user/admin_password +# inside the request JSON *and* its IP is in `admin` — so commands like ledger_accept are rejected +# here with `forbidden` / `Bad credentials.` unless XrplClient.ClientOptions.AdminUser/AdminPassword +# are set (requestRole returns Role::FORBID, it does not fall back to guest). +# Kept separate from port_ws_admin so the rest of the integration suite is unaffected. +[port_ws_admin_auth] +port = 6007 +ip = 0.0.0.0 +protocol = ws +admin = 0.0.0.0 +admin_user = xrpl_admin +admin_password = xrpl_admin_secret + [node_size] small 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..19dab341 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,47 @@ # Changes -### 10.9.1.0 07/27/2026 +## 10.10.0.0 07/29/2026 +* **`ConnectionOptions.authorization` did nothing — now it does** — the option was public on `XrplClient.ClientOptions` since the xrpl.js port, but `Connection.CreateWebSocket` was a block of commented-out JS pseudocode ending in `WebSocketClient.Create(url); // todo add options`, and `WebSocketClient` had no parameter to receive them. Nothing the caller set on `authorization`, `headers`, `proxy`, `trustedCertificates`, `key`, `passphrase` or `certificate` ever reached the socket: + * `authorization` now produces `Authorization: Basic base64(value)` on the WebSocket upgrade handshake, matching xrpl.js `createWebSocket` — the value is the raw `user:password` pair, the SDK does the base64 + * `headers` are put on the handshake as-is; the type changed from `Dictionary` to `Dictionary` to match xrpl.js and drop the `ToString()` ambiguity (**source-breaking**, but the property was inert, so no working code can depend on it) + * both are skipped under WebAssembly — the browser WebSocket API cannot set request headers, so `ClientWebSocket.Options.SetRequestHeader` is guarded by `OperatingSystem.IsBrowser()` the same way `KeepAliveInterval` already was + * `proxy`, `proxyAuthorization`, `trustedCertificates`, `key`, `passphrase`, `certificate` and the unused `trace`/`Trace` pair are **removed** rather than implemented (**breaking**, no `[Obsolete]` grace period — consistent with the 10.9.0.0 hex-helper removals): current xrpl.js has dropped these options too, they cannot be honored uniformly across `ClientWebSocket` targets, and nothing in the solution ever read them. A property that silently does nothing is worse than one that does not compile + * **Scope note:** rippled does *not* check Basic auth on the ws/wss handshake — `authorized()` is called only from the plain-HTTP `onRequest()` path, while `onHandoff()` upgrades WebSockets without it. A port stanza's `user`/`password` therefore only guards HTTP JSON-RPC. `authorization` is for reaching a node behind a reverse proxy or a provider that requires Basic auth + +* **`AdminUser`/`AdminPassword` — admin commands over WebSocket** — the mechanism rippled actually accepts for ws/wss: `admin_user`/`admin_password` travel *inside the request JSON*, not in a header. Without them, a port that sets `admin_user`/`admin_password` rejects `ledger_accept`, `stop`, `connect` and friends outright — `forbidden` / `Bad credentials.` — regardless of the client's IP, because `requestRole` returns `Role::FORBID` rather than demoting the client to guest. Both must be set for either to be sent, mirroring rippled's own check (a matching `admin` net **and** correct credentials) + * injected into the serialized request rather than into the request object, so the credentials never reach the `TimeoutException` message that consumers log — `TestAdminPasswordIsNotLeakedIntoTimeoutMessage` pins that + * `RequestManager.CreateRequest`/`CreateGRequest` take the credentials as a trailing optional parameter, so existing positional call sites are unaffected + +* **Coverage** — `TestUAuthorization` asserts against the raw HTTP upgrade text captured by a loopback socket server: Basic header present and correctly encoded, custom headers present, and no `Authorization` header when the option is unset. `TestIAdminCredentials` runs against a new `[port_ws_admin_auth]` stanza on the standalone stand (port 6007, `admin_user`/`admin_password` set) and checks both directions — `ledger_accept` rejected with `forbidden` / `Bad credentials.` without credentials, accepted with them. The port is separate from `port_ws_admin` so the rest of the integration suite is untouched + +* **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"` + +* Review pass (PR [#68](https://github.com/StaticBit-io/XrplCSharp/pull/68)): + * **`ValidateCheckCreate` now enforces `InvoiceID` as a Hash256**, not merely as a string. `sfInvoiceID` is a 256-bit hash and this same release added exactly that rule for `WalletLocator` in `AccountSet` and `SignerListSet`, so `CheckCreate` was the odd one out: a malformed value passed validation and only blew up later inside the codec, reporting an encoding error instead of a `ValidationException`. The exception message is unchanged (`CheckCreate: invalid InvoiceID`) + * **the CI stand publishes every port on loopback only**, matching the nightly stand. The review flagged the new credential-protected ws port (6007) for being bound to every interface, but that was the least exposed of the four: `rippled.cfg` sets `admin = 0.0.0.0` on every stanza, so 5005/5006/6006 hand the admin role — `stop`, `connect`, `feature`, `validation_seed`, i.e. node control — to anyone who can reach them, and 6007 is the only one that asks for credentials at all. Nothing needed the wider binding: tests connect over localhost and the ledger-acceptor reaches the node through the compose network. Worth doing even though the node is a throwaway genesis container, because a host firewall does not cover this — Docker's DNAT rules sit ahead of the chains `ufw` manages + * `TestIConnectionStates` — both reconnect-exhaustion tests discarded the `Task.WhenAny` winner, so a run where the terminal event never arrived proceeded after the 30 s timeout and could still pass. They now assert the event task won, and that at least one reconnect was attempted + * `TestIProtocolFieldSets` sets `Expiration` on the mint-time NFT offer but never checked it read back; asserted now, closing the last unverified field of the corrected `NFTokenMint` set + * test-only tidying: the parse-floor literal is shared instead of duplicated (`RippledTransactionFormats.MinimumExpectedTransactions`), the common-field set both conformance surfaces subtract now comes from one helper (`RippledTransactionFormats.CommonFields`), and a redundant `Link` on the vendored fixture is dropped + +## 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 +49,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 +58,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 +73,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 +87,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 +101,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 +118,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 +126,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 +159,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 +188,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 +212,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 +248,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 +256,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 +281,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 +310,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 +355,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 +392,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 +416,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/CLAUDE.md b/CLAUDE.md index 69b10aa9..3e4a6e4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,11 +204,12 @@ Integration tests do **not** run on PRs into `dev` — `dev` uses a GitHub merge ### Release Process 1. Ensure all tests pass on `dev` -2. Update version in all `.csproj` files (`Xrpl`, `Xrpl.AddressCodec`, `Xrpl.BinaryCodec`, `Xrpl.Keypairs`) -3. Update `CHANGES.md` -4. Merge `dev` → `release` -5. NuGet publish triggers automatically on push to `release` -6. Create GitHub release with tag +2. Bump `` **only in the packages that actually changed** — not in all four. The base packages (`Xrpl.AddressCodec`, `Xrpl.BinaryCodec`, `Xrpl.Keypairs`) are consumed via `ProjectReference`, so a `Xrpl` package built at a newer version keeps depending on the already published base packages at their existing version. Leaving an untouched package behind is correct, not an oversight. Check with `git diff --stat origin/release...origin/dev -- Base/` before deciding +3. Choose the bump from the nature of the change: patch for a bugfix with no contract change, minor otherwise +4. Update `CHANGES.md` +5. Merge `dev` → `release` +6. NuGet publish triggers automatically on push to `release` +7. Create GitHub release with tag ### NuGet Packages Published diff --git a/DocFx/StandaloneNode-Guide.md b/DocFx/StandaloneNode-Guide.md index 6f19d093..65fffeff 100644 --- a/DocFx/StandaloneNode-Guide.md +++ b/DocFx/StandaloneNode-Guide.md @@ -41,6 +41,11 @@ docker compose -f .ci-config/docker-compose.batchv11.yml down | 5005 | JSON-RPC (admin) | | 5006 | JSON-RPC (admin, used by the ledger-acceptor) | | 6006 | WebSocket (admin) — integration tests connect here (`ws://localhost:6006`) | +| 6007 | WebSocket with `admin_user`/`admin_password` — used only by `TestIAdminCredentials` | + +All ports are published on **loopback only** (`127.0.0.1`): the stand is reachable from the same machine and not from the network. That is deliberate — every stanza in the config sets `admin = 0.0.0.0`, so each port hands the admin role (`stop`, `connect`, `feature`, `validation_seed`) to anyone who can reach it, and only 6007 asks for credentials. If you genuinely need to reach the node from another host, widen the binding in the compose file as a conscious decision, not to make something work. + +Port 6007 (`[port_ws_admin_auth]`) exists to prove that admin commands over WS open up only when `ClientOptions.AdminUser`/`AdminPassword` are set. rippled carries those credentials **inside the request JSON** — it never checks a Basic header on the ws handshake (its `user`/`password` port settings apply to plain HTTP JSON-RPC only). No other test uses this port. ## Why you get `temDISABLED` diff --git a/DocFx/StandaloneNode-Guide.ru.md b/DocFx/StandaloneNode-Guide.ru.md index 4e837399..c7182b48 100644 --- a/DocFx/StandaloneNode-Guide.ru.md +++ b/DocFx/StandaloneNode-Guide.ru.md @@ -41,6 +41,11 @@ docker compose -f .ci-config/docker-compose.batchv11.yml down | 5005 | JSON-RPC (admin) | | 5006 | JSON-RPC (admin, используется ledger-acceptor) | | 6006 | WebSocket (admin) — интеграционные тесты подключаются сюда (`ws://localhost:6006`) | +| 6007 | WebSocket с `admin_user`/`admin_password` — только для `TestIAdminCredentials` | + +Все порты публикуются **только на loopback** (`127.0.0.1`), поэтому стенд доступен с той же машины и недоступен из сети. Так и задумано: каждая станза в конфиге стоит с `admin = 0.0.0.0`, то есть даёт роль администратора (`stop`, `connect`, `feature`, `validation_seed`) любому, кто дотянулся до порта, а креды спрашивает только 6007. Если нужно ходить на ноду с другой машины — правьте привязку в compose-файле осознанно, а не «чтобы заработало». + +Порт 6007 (`[port_ws_admin_auth]`) существует, чтобы проверить, что admin-команды по WS открываются только при заданных `ClientOptions.AdminUser`/`AdminPassword`. rippled передаёт эти креды **внутри JSON** запроса — Basic-заголовок на ws-рукопожатии сама нода не проверяет (её `user`/`password` относятся только к HTTP JSON-RPC). Остальные тесты порт не используют. ## Почему возникает `temDISABLED` diff --git a/Tests/Xrpl.Tests/Client/HandshakeCapturingServer.cs b/Tests/Xrpl.Tests/Client/HandshakeCapturingServer.cs new file mode 100644 index 00000000..6ce52acd --- /dev/null +++ b/Tests/Xrpl.Tests/Client/HandshakeCapturingServer.cs @@ -0,0 +1,127 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Tests.MockRippled; + +namespace Xrpl.Tests +{ + /// + /// Minimal WebSocket server that captures the raw HTTP upgrade request of the first + /// client and then completes a valid handshake, so the client stays connected. + /// Used to assert which headers the SDK puts on the WebSocket handshake. + /// + internal sealed class HandshakeCapturingServer : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly TaskCompletionSource _handshake = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public HandshakeCapturingServer() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = AcceptAsync(); + } + + public int Port { get; } + + public string Url => $"ws://127.0.0.1:{Port}/"; + + /// Raw text of the client's HTTP upgrade request, including all headers. + public Task HandshakeRequest => _handshake.Task; + + public async Task WaitForHandshakeAsync(TimeSpan timeout) + { + Task completed = await Task.WhenAny(_handshake.Task, Task.Delay(timeout, _cts.Token)) + .ConfigureAwait(false); + + if (completed != _handshake.Task) + { + throw new TimeoutException($"No WebSocket handshake received within {timeout.TotalSeconds:F0}s."); + } + + return await _handshake.Task.ConfigureAwait(false); + } + + private async Task AcceptAsync() + { + try + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); + NetworkStream stream = client.GetStream(); + + string request = await ReadUntilHeadersEndAsync(stream).ConfigureAwait(false); + _handshake.TrySetResult(request); + + string key = Helpers.GetHandshakeRequestKey(request); + byte[] response = Encoding.ASCII.GetBytes(Helpers.GetHandshakeResponse(Helpers.HashKey(key))); + await stream.WriteAsync(response, _cts.Token).ConfigureAwait(false); + await stream.FlushAsync(_cts.Token).ConfigureAwait(false); + + // Hold the connection open until the test disposes the server. + await Task.Delay(Timeout.InfiniteTimeSpan, _cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _handshake.TrySetCanceled(); + } + catch (Exception ex) + { + _handshake.TrySetException(ex); + } + } + + private async Task ReadUntilHeadersEndAsync(NetworkStream stream) + { + byte[] buffer = new byte[4096]; + StringBuilder request = new StringBuilder(); + + while (true) + { + int read = await stream.ReadAsync(buffer, _cts.Token).ConfigureAwait(false); + if (read == 0) + { + break; + } + + request.Append(Encoding.ASCII.GetString(buffer, 0, read)); + + if (request.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + { + break; + } + } + + return request.ToString(); + } + + public void Dispose() + { + try + { + _cts.Cancel(); + } + catch + { + // best effort + } + + try + { + _listener.Stop(); + } + catch + { + // best effort + } + + _cts.Dispose(); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUAuthorization.cs b/Tests/Xrpl.Tests/Client/TestUAuthorization.cs new file mode 100644 index 00000000..dde5232d --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUAuthorization.cs @@ -0,0 +1,155 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Models.Methods; + +using XrplTests; + +using static Xrpl.Client.Connection; + +namespace Xrpl.Tests +{ + /// + /// Covers the two authentication mechanisms rippled exposes: + /// + /// HTTP Basic on the WebSocket upgrade handshake — user/password of a port stanza, + /// in practice consumed by a reverse proxy in front of the node (xrpl.js calls this authorization). + /// admin_user/admin_password carried inside the request JSON, which is the only way + /// to reach admin commands over ws/wss. + /// + /// + [TestClass] + public class TestUAuthorization + { + private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(10); + + private static async Task CaptureHandshakeAsync(Action configure) + { + using HandshakeCapturingServer server = new HandshakeCapturingServer(); + + ConnectionOptions options = new ConnectionOptions + { + MaxReconnectAttempts = 0, + UseCustomPing = false, + }; + configure(options); + + Connection connection = new Connection(server.Url, options); + try + { + _ = connection.Connect(System.Threading.CancellationToken.None); + return await server.WaitForHandshakeAsync(HandshakeTimeout); + } + finally + { + try + { + await connection.Disconnect(); + } + catch + { + // the fake server never speaks the WebSocket protocol beyond the handshake + } + } + } + + [TestMethod] + public async Task TestAuthorizationSendsBasicHeaderOnHandshake() + { + string handshake = await CaptureHandshakeAsync(options => options.authorization = "user:pass"); + + // base64("user:pass") == "dXNlcjpwYXNz" + StringAssert.Contains(handshake, "Authorization: Basic dXNlcjpwYXNz"); + } + + [TestMethod] + public async Task TestNoAuthorizationSendsNoAuthorizationHeader() + { + string handshake = await CaptureHandshakeAsync(_ => { }); + + Assert.IsFalse( + handshake.Contains("Authorization:", StringComparison.OrdinalIgnoreCase), + $"Handshake unexpectedly carried an Authorization header:\n{handshake}"); + } + + [TestMethod] + public async Task TestCustomHeadersAreSentOnHandshake() + { + string handshake = await CaptureHandshakeAsync(options => + options.headers = new Dictionary { { "X-Api-Key", "abc123" } }); + + StringAssert.Contains(handshake, "X-Api-Key: abc123"); + } + + [TestMethod] + public void TestAdminCredentialsAddedToTypedRequest() + { + RequestManager manager = new RequestManager(); + + RequestManager.XrplGRequest request = manager.CreateGRequest( + new BaseRequest { Command = "ledger_accept" }, + timeout: System.Threading.Timeout.InfiniteTimeSpan, + adminCredentials: new AdminCredentials("root", "passw0rd")); + + StringAssert.Contains(request.Message, "\"admin_user\":\"root\""); + StringAssert.Contains(request.Message, "\"admin_password\":\"passw0rd\""); + } + + [TestMethod] + public void TestAdminCredentialsAddedToDictionaryRequest() + { + RequestManager manager = new RequestManager(); + + RequestManager.XrplRequest request = manager.CreateRequest( + new Dictionary { { "command", "ledger_accept" } }, + timeout: System.Threading.Timeout.InfiniteTimeSpan, + adminCredentials: new AdminCredentials("root", "passw0rd")); + + StringAssert.Contains(request.Message, "\"admin_user\":\"root\""); + StringAssert.Contains(request.Message, "\"admin_password\":\"passw0rd\""); + } + + [TestMethod] + public void TestRequestWithoutAdminCredentialsCarriesNoAdminFields() + { + RequestManager manager = new RequestManager(); + + RequestManager.XrplRequest request = manager.CreateRequest( + new Dictionary { { "command", "ledger_accept" } }, + timeout: System.Threading.Timeout.InfiniteTimeSpan); + + Assert.IsFalse( + request.Message.Contains("admin_user", StringComparison.Ordinal), + $"Unexpected admin_user in request: {request.Message}"); + Assert.IsFalse( + request.Message.Contains("admin_password", StringComparison.Ordinal), + $"Unexpected admin_password in request: {request.Message}"); + } + + [TestMethod] + public async Task TestAdminPasswordIsNotLeakedIntoTimeoutMessage() + { + RequestManager manager = new RequestManager(); + + RequestManager.XrplGRequest request = manager.CreateGRequest( + new BaseRequest { Command = "ledger_accept" }, + timeout: TimeSpan.FromMilliseconds(50), + adminCredentials: new AdminCredentials("root", "passw0rd")); + + // The credentials go on the wire... + StringAssert.Contains(request.Message, "passw0rd"); + + // ...but the timeout message is logged by consumers, so it must stay clean. + Xrpl.Client.Exceptions.TimeoutException error = + await Helper.ThrowsExceptionAsync(() => request.Promise); + + Assert.IsFalse( + error.Message.Contains("passw0rd", StringComparison.Ordinal), + $"Admin password leaked into the timeout message: {error.Message}"); + } + } +} 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/TestIAdminCredentials.cs b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs new file mode 100644 index 00000000..6828e546 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs @@ -0,0 +1,93 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; + +using XrplTests; + +namespace Xrpl.Tests.Integration +{ + /// + /// Verifies that / + /// actually unlock rippled admin commands over WebSocket. + /// + /// Runs against [port_ws_admin_auth] of the standalone stand (port 6007), which sets + /// admin_user/admin_password. rippled carries these credentials in the request JSON, + /// not in an HTTP header — Basic auth on the ws handshake is never checked by the node itself. + /// + /// + [TestClass] + public class TestIAdminCredentials + { + private const string AdminUser = "xrpl_admin"; + private const string AdminPassword = "xrpl_admin_secret"; + private const string ServerUrl = "ws://127.0.0.1:6007"; + + private static readonly Dictionary LedgerAccept = new() + { + { "command", "ledger_accept" }, + }; + + private static XrplClient CreateClient(bool withCredentials) + { + XrplClient.ClientOptions options = new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + UseCustomPing = false, + }; + + if (withCredentials) + { + options.AdminUser = AdminUser; + options.AdminPassword = AdminPassword; + } + + return new XrplClient(ServerUrl, options); + } + + [TestMethod] + public async Task TestAdminCommandIsRejectedWithoutCredentials() + { + XrplClient client = CreateClient(withCredentials: false); + await client.Connect(); + try + { + RippledException error = await Helper.ThrowsExceptionAsync( + () => client.Request(new Dictionary(LedgerAccept))); + + // A port that sets admin_user/admin_password answers Role::FORBID — rippled rejects the + // missing credentials outright rather than demoting the client to guest and replying noPermission. + StringAssert.Contains(error.Message, "forbidden"); + StringAssert.Contains(error.Message, "Bad credentials"); + } + finally + { + await client.Disconnect(); + } + } + + [TestMethod] + public async Task TestAdminCommandSucceedsWithCredentials() + { + XrplClient client = CreateClient(withCredentials: true); + await client.Connect(); + try + { + Dictionary response = + await client.Request(new Dictionary(LedgerAccept)); + + Assert.IsTrue( + response.ContainsKey("ledger_current_index"), + $"ledger_accept did not return a ledger index: {string.Join(", ", response.Keys)}"); + } + finally + { + await client.Disconnect(); + } + } + } +} diff --git a/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs index a6cb8aac..c7e0cdee 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,19 @@ 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))); + Task terminal = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(tcs.Task, terminal, "Reconnect exhaustion was not observed before the 30s timeout"); + await tcs.Task; Assert.IsTrue(stateChanges.Contains(XrpConnectionState.Connecting), "Should have Connecting state"); - - Console.WriteLine($"\n=== Reconnect attempts: {reconnectAttempts} ==="); - Console.WriteLine("State changes: " + string.Join(" -> ", stateChanges)); + Assert.IsTrue(reconnectAttempts >= 1, $"Expected at least one reconnect attempt, got {reconnectAttempts}"); await client.Disconnect(); } @@ -109,41 +153,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 +177,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 +204,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 +265,27 @@ 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))); + Task terminal = await Task.WhenAny(disconnectedPermanently.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(disconnectedPermanently.Task, terminal, + "Permanent disconnect was not observed before the 30s timeout"); + await disconnectedPermanently.Task; - 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 +296,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..dd63b0ad --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs @@ -0,0 +1,224 @@ +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); + Assert.IsNotNull(offer.Expiration, "the mint-time sell offer must carry Expiration"); + Assert.AreEqual(expiration, offer.Expiration.Value); + } + 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..285460b6 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/RippledTransactionFormats.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using Xrpl.BinaryCodec.Enums; + +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); + + /// + /// Lower bound on how many formats a healthy parse yields. Exposed so the guard test + /// asserts against the same number the parser enforces, instead of a second literal + /// that would drift when the fixture is re-pinned. + /// + internal const int MinimumExpectedTransactions = 60; + + /// + /// Fields shared by every transaction, declared once in the constructor. + /// rippled keeps them in a separate commonFields list, so both conformance surfaces + /// exclude them — they read the set from here so the two cannot drift apart. + /// + internal static HashSet CommonFields() => new TxFormat().Keys.ToHashSet(); + + 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..b895135a 100644 --- a/Tests/Xrpl.Tests/Models/TestCheckCreate.cs +++ b/Tests/Xrpl.Tests/Models/TestCheckCreate.cs @@ -116,6 +116,79 @@ 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()); + } + + [TestMethod] + public async Task TestUCheckCreate_RejectsInvoiceIDThatIsNotA256BitHexValue() + { + // sfInvoiceID is Hash256. A string of the wrong length or with non-hex characters is + // malformed and must fail validation rather than blow up later inside the codec — + // the same rule SignerListSet and AccountSet already apply to WalletLocator. + string[] malformed = + { + "6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59", // 63 chars + "6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59BA", // 65 chars + "6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF5XZ", // non-hex + "", + }; + + foreach (string invoiceId in malformed) + { + Dictionary tx = new Dictionary + { + { "TransactionType", "CheckCreate" }, + { "Account", "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo" }, + { "Destination", "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy" }, + { "SendMax", "100000000" }, + { "InvoiceID", invoiceId }, + { "Fee", "12" }, + }; + + await Helper.ThrowsExceptionAsync( + () => Validation.ValidateCheckCreate(tx), + "CheckCreate: invalid InvoiceID"); + } + } + + [TestMethod] + public async Task TestUCheckCreate_AcceptsA256BitHexInvoiceID() + { + Dictionary tx = new Dictionary + { + { "TransactionType", "CheckCreate" }, + { "Account", "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo" }, + { "Destination", "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy" }, + { "SendMax", "100000000" }, + { "InvoiceID", "6f1dfd1d0fe8a32e40e1f2c05cf1c15545bab56b617f9c6c2d63a6b704bef59b" }, + { "Fee", "12" }, + }; + + await Validation.ValidateCheckCreate(tx); + } } } diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 3e15837e..367382b5 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,76 @@ 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. + /// The common set comes from , which + /// TestUTxFormatConformance also reads, so the two conformance surfaces stay in step. + /// + private static Dictionary TypeSpecificFields( + BinaryCodec.Types.TransactionType transactionType) + { + HashSet common = RippledTransactionFormats.CommonFields(); + return TxFormat.Formats[transactionType] + .Where(entry => !common.Contains(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..4342a925 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUTxFormatConformance.cs @@ -0,0 +1,108 @@ +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 + { + /// + /// Names of the fields shared by every transaction; excluded on both sides of the diff. + /// Derived from so this test and + /// TestUProtocolCompleteness cannot disagree about what "common" means. + /// + private static HashSet CommonFieldNames() => + RippledTransactionFormats.CommonFields() + .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.IsGreaterThanOrEqualTo( + RippledTransactionFormats.MinimumExpectedTransactions, + 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/TestConnection.cs b/Tests/Xrpl.Tests/TestConnection.cs index 8a4012f8..81e4c691 100644 --- a/Tests/Xrpl.Tests/TestConnection.cs +++ b/Tests/Xrpl.Tests/TestConnection.cs @@ -45,8 +45,10 @@ public void TestDefaultOptions() ConnectionOptions options = new ConnectionOptions(); Connection connection = new Connection("url", options); Assert.AreEqual("url", connection.GetUrl()); - Assert.IsNull(connection.config.proxy); Assert.IsNull(connection.config.authorization); + Assert.IsNull(connection.config.headers); + Assert.IsNull(connection.config.AdminUser); + Assert.IsNull(connection.config.AdminPassword); } //[TestMethod] diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index 8feee976..f076abe6 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/Client/RequestManager.cs b/Xrpl/Client/RequestManager.cs index 7f19c8aa..be85d284 100644 --- a/Xrpl/Client/RequestManager.cs +++ b/Xrpl/Client/RequestManager.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -19,6 +20,13 @@ namespace Xrpl.Client { + /// + /// rippled's admin_user/admin_password port-stanza credentials. They are carried + /// inside the JSON body of each request, which is the only mechanism rippled accepts for + /// admin commands over ws/wss. + /// + public sealed record AdminCredentials(string User, string Password); + /// /// Manage all the requests made to the websocket, and their async responses /// that come in from the WebSocket.Responses come in over the WS connection @@ -157,7 +165,35 @@ public void RejectAllWithCancellation() } } - public XrplGRequest CreateGRequest(R request, TimeSpan timeout, CancellationToken cancellationToken = default) + /// + /// Adds rippled's admin credentials to an already serialized request. + /// + /// + /// Applied to the serialized JSON rather than to the request object so that the credentials + /// never end up in the request instance that error messages are built from. + /// + private static string ApplyAdminCredentials(string json, AdminCredentials? credentials) + { + if (credentials is null) + { + return json; + } + + if (JsonNode.Parse(json) is not JsonObject request) + { + return json; + } + + request["admin_user"] = credentials.User; + request["admin_password"] = credentials.Password; + return request.ToJsonString(); + } + + public XrplGRequest CreateGRequest( + R request, + TimeSpan timeout, + CancellationToken cancellationToken = default, + AdminCredentials? adminCredentials = null) { if (timeout != System.Threading.Timeout.InfiniteTimeSpan && timeout <= TimeSpan.Zero) { @@ -172,6 +208,7 @@ public XrplGRequest CreateGRequest(R request, TimeSpan timeout, Cancellati info.SetValue(request, newId, null); string newRequest = JsonSerializer.Serialize(request, serializerOptions); + string outgoingRequest = ApplyAdminCredentials(newRequest, adminCredentials); TaskCompletionSource task = new TaskCompletionSource(); TaskInfo taskInfo = new TaskInfo(); @@ -223,14 +260,18 @@ public XrplGRequest CreateGRequest(R request, TimeSpan timeout, Cancellati return new XrplGRequest() { Id = newId, - Message = newRequest, + Message = outgoingRequest, Promise = task.Task }; } /// /// - public XrplRequest CreateRequest(Dictionary request, TimeSpan timeout, CancellationToken cancellationToken = default) + public XrplRequest CreateRequest( + Dictionary request, + TimeSpan timeout, + CancellationToken cancellationToken = default, + AdminCredentials? adminCredentials = null) { if (timeout != System.Threading.Timeout.InfiniteTimeSpan && timeout <= TimeSpan.Zero) { @@ -244,6 +285,7 @@ public XrplRequest CreateRequest(Dictionary request, TimeSpan ti request["id"] = newId; string newRequest = JsonSerializer.Serialize(request, serializerOptions); + string outgoingRequest = ApplyAdminCredentials(newRequest, adminCredentials); TaskCompletionSource> task = new TaskCompletionSource>(); TaskInfo taskInfo = new TaskInfo(); @@ -295,7 +337,7 @@ public XrplRequest CreateRequest(Dictionary request, TimeSpan ti return new XrplRequest() { Id = newId, - Message = newRequest, + Message = outgoingRequest, Promise = task.Task }; } diff --git a/Xrpl/Client/WebSocketClient.cs b/Xrpl/Client/WebSocketClient.cs index 6c08b3ab..97cf200a 100644 --- a/Xrpl/Client/WebSocketClient.cs +++ b/Xrpl/Client/WebSocketClient.cs @@ -1,6 +1,7 @@  using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.WebSockets; @@ -27,6 +28,7 @@ public class WebSocketClient : IDisposable private ClientWebSocket _ws; private readonly Uri _uri; + private readonly IReadOnlyDictionary? _handshakeHeaders; private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private readonly CancellationToken _cancellationToken; private Task? _receiveTask; @@ -43,9 +45,10 @@ public class WebSocketClient : IDisposable private Func _onDisconnected; private Func _onClosed; - protected WebSocketClient(string uri) + protected WebSocketClient(string uri, IReadOnlyDictionary? handshakeHeaders = null) { _uri = new Uri(uri); + _handshakeHeaders = handshakeHeaders; _cancellationToken = _cancellationTokenSource.Token; } /// @@ -88,10 +91,14 @@ public void ResetIntentionalDisconnect() /// Creates a new instance. /// /// The URI of the WebSocket server. + /// + /// Optional HTTP headers to put on the upgrade handshake (e.g. Authorization). + /// Ignored under WebAssembly — the browser WebSocket API cannot set request headers. + /// /// Instance of the created WebSocketWrapper - public static WebSocketClient Create(string uri) + public static WebSocketClient Create(string uri, IReadOnlyDictionary? handshakeHeaders = null) { - return new WebSocketClient(uri); + return new WebSocketClient(uri, handshakeHeaders); } /// @@ -106,6 +113,7 @@ public async Task Connect() if (!OperatingSystem.IsBrowser()) { _ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); + ApplyHandshakeHeaders(_ws); } } @@ -113,6 +121,19 @@ public async Task Connect() return this; } + private void ApplyHandshakeHeaders(ClientWebSocket socket) + { + if (_handshakeHeaders is null) + { + return; + } + + foreach (KeyValuePair header in _handshakeHeaders) + { + socket.Options.SetRequestHeader(header.Key, header.Value); + } + } + /// /// Disconnects from the WebSocket server. /// diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index 4a13e754..d443c3dd 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -116,32 +116,41 @@ public static string Base64Decode(string base64EncodedData) return System.Text.Encoding.UTF8.GetString(base64EncodedBytes); } - public class Trace - { - public string id { get; set; } - - public string message { get; set; } - } - public class ConnectionOptions { - public Trace trace { get; set; } - - public string proxy { get; set; } - - public string proxyAuthorization { get; set; } - + /// + /// Raw user:password pair sent as an HTTP Basic Authorization header on the + /// WebSocket upgrade handshake. Matches the authorization option of xrpl.js. + /// + /// + /// rippled itself does not check Basic auth on the ws/wss handshake — its user/password + /// port-stanza settings only apply to plain HTTP JSON-RPC. This option is for reaching a node behind a + /// reverse proxy or a provider that requires Basic auth. For admin commands over ws/wss use + /// / instead. + /// Ignored under WebAssembly — the browser WebSocket API cannot set request headers. + /// public string authorization { get; set; } - public string trustedCertificates { get; set; } - - public string key { get; set; } - - public string passphrase { get; set; } + /// + /// Extra HTTP headers to put on the WebSocket upgrade handshake. + /// + /// Ignored under WebAssembly — the browser WebSocket API cannot set request headers. + public Dictionary headers { get; set; } - public string certificate { get; set; } + /// + /// Admin user for rippled's admin_user port-stanza setting. + /// + /// + /// Unlike , these credentials travel inside the JSON body of every + /// request — that is the only mechanism rippled accepts for admin commands over ws/wss. + /// Both and must be set for them to be sent. + /// + public string AdminUser { get; set; } - public Dictionary headers { get; set; } + /// + /// Admin password for rippled's admin_password port-stanza setting. See . + /// + public string AdminPassword { get; set; } /// /// Timeout for individual API requests after connection is established. @@ -230,37 +239,36 @@ private void ValidateConfig() } } + // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/client/connection.ts createWebSocket private static WebSocketClient CreateWebSocket(string url, ConnectionOptions config) => + WebSocketClient.Create(url, BuildHandshakeHeaders(config)); - // Client or Creation... - //ClientWebSocketOptions options = new ClientWebSocketOptions() - //{ - // Proxy = config.proxy, - // Credentials = config.authorization, - // ClientCertificates = config.trustedCertificates - //}; - //options.agent = getAgent(url, config) - //WebSocketCreationOptions create = new WebSocketCreationOptions() - //{ - //}; - // if (config.authorization != null) - // { - // string base64 = Base64Encode(config.authorization); - // options.headers = { - // ...options.headers, - // Authorization: $"Basic {base64}", - // } - // const optionsOverrides = _.omitBy( - // { - // ca: config.trustedCertificates, - // key: config.key, - // passphrase: config.passphrase, - // cert: config.certificate, - // }, - // (value) => value == null, - //) - //const websocketOptions = { ...options, ...optionsOverrides }; - WebSocketClient.Create(url); // todo add options + /// + /// Builds the HTTP headers put on the WebSocket upgrade handshake: the caller's own + /// plus a Basic Authorization header derived + /// from . + /// + internal static Dictionary? BuildHandshakeHeaders(ConnectionOptions config) + { + bool hasAuthorization = !string.IsNullOrEmpty(config.authorization); + bool hasHeaders = config.headers is { Count: > 0 }; + + if (!hasAuthorization && !hasHeaders) + { + return null; + } + + Dictionary headers = hasHeaders + ? new Dictionary(config.headers, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (hasAuthorization) + { + headers["Authorization"] = $"Basic {Base64Encode(config.authorization)}"; + } + + return headers; + } public string url { get; private set; } @@ -1500,6 +1508,14 @@ private void CheckIfNotConnected() } } + /// + /// rippled requires both admin_user and admin_password; a half-configured pair sends neither. + /// + private AdminCredentials? GetAdminCredentials() => + string.IsNullOrEmpty(config.AdminUser) || string.IsNullOrEmpty(config.AdminPassword) + ? null + : new AdminCredentials(config.AdminUser, config.AdminPassword); + public async Task> Request( Dictionary request, TimeSpan? timeout = null, @@ -1508,7 +1524,7 @@ public async Task> Request( { await EnsureConnectionForRequest(policyOverride, cancellationToken); - var _request = requestManager.CreateRequest(request, timeout: timeout ?? config.RequestTimeout, cancellationToken: cancellationToken); + var _request = requestManager.CreateRequest(request, timeout: timeout ?? config.RequestTimeout, adminCredentials: GetAdminCredentials(), cancellationToken: cancellationToken); try { WebsocketSendAsync(ws, _request.Message); @@ -1529,7 +1545,7 @@ public async Task GRequest( { await EnsureConnectionForRequest(policyOverride, cancellationToken); - var _request = requestManager.CreateGRequest(request, timeout: timeout ?? config.RequestTimeout, cancellationToken: cancellationToken); + var _request = requestManager.CreateGRequest(request, timeout: timeout ?? config.RequestTimeout, adminCredentials: GetAdminCredentials(), cancellationToken: cancellationToken); try { WebsocketSendAsync(ws, _request.Message); @@ -2075,6 +2091,12 @@ private async Task ExecutePingCheckAsync(CancellationTokenSource cts) try { Debug.WriteLine($"{DateTime.Now}[PING-CHECK] Fire-and-forget keepalive ping (active connection)"); + + // Raw send: this bypasses RequestManager, so AdminUser/AdminPassword are NOT attached. + // Safe for ping specifically — rippled resolves the role per command, and a guest-level + // command is answered normally even on a port that sets admin_user/admin_password + // (only commands requiring Role::ADMIN get "forbidden / Bad credentials."). Anything + // needing admin must go through Request/GRequest instead of being added here. currentSocket?.SendMessage("{\"command\":\"ping\",\"id\":\"00000000-0000-0000-0000-000000000000\"}"); if (OnPing != null) { 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..5ac6064a 100644 --- a/Xrpl/Models/Transactions/CheckCreate.cs +++ b/Xrpl/Models/Transactions/CheckCreate.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Text.Json.Serialization; @@ -29,7 +30,7 @@ public CheckCreate() /// public DateTime? Expiration { get; set; } /// - public uint? InvoiceID { get; set; } + public string InvoiceID { get; set; } } /// @@ -64,7 +65,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 +80,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 @@ -108,7 +109,11 @@ public static async Task ValidateCheckCreate(Dictionary tx) throw new ValidationException("CheckCreate: invalid DestinationTag"); if (tx.TryGetValue("Expiration", out var Expiration) && !Common.IsUInt32(Expiration)) throw new ValidationException("CheckCreate: invalid Expiration"); - if (tx.TryGetValue("InvoiceID", out var InvoiceID) && InvoiceID is not string { }) + // sfInvoiceID is a Hash256 — the same 256-bit rule SignerListSet and AccountSet apply to + // WalletLocator. Without the shape check a malformed string only fails later inside the + // codec, which reports an encoding error instead of a ValidationException. + if (tx.TryGetValue("InvoiceID", out var InvoiceID) && + (InvoiceID is not string invoiceId || !Regex.IsMatch(invoiceId, @"^[0-9A-Fa-f]{64}$"))) throw new ValidationException("CheckCreate: invalid InvoiceID"); 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