Xrpl.PaymentGateway.Abstractions 1.1.0

dotnet add package Xrpl.PaymentGateway.Abstractions --version 1.1.0
                    
NuGet\Install-Package Xrpl.PaymentGateway.Abstractions -Version 1.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Xrpl.PaymentGateway.Abstractions" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Xrpl.PaymentGateway.Abstractions" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Xrpl.PaymentGateway.Abstractions" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Xrpl.PaymentGateway.Abstractions --version 1.1.0
                    
#r "nuget: Xrpl.PaymentGateway.Abstractions, 1.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Xrpl.PaymentGateway.Abstractions@1.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Xrpl.PaymentGateway.Abstractions&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Xrpl.PaymentGateway.Abstractions&version=1.1.0
                    
Install as a Cake Tool

Xrpl.PaymentGateway

Accept XRP Ledger payments in any .NET application, and record them wherever you like.

The library watches one receiving account, hands each buyer a stable destination tag, and records every incoming payment exactly once. Where those records live is your decision: implement one interface and the gateway writes through it, whether that is PostgreSQL, a file, or something else entirely.

Install

dotnet add package Xrpl.PaymentGateway

Add Xrpl.PaymentGateway.Postgres as well if you want the PostgreSQL store. A project that only implements storage takes Xrpl.PaymentGateway.Abstractions instead, which has no dependency on the XRPL SDK.

Choosing a store

Where payments are recorded is your decision, and there are three ways to make it:

Store Where it lives Use it when
PostgresPaymentStore Xrpl.PaymentGateway.Postgres You have a database. Tag allocation and hash uniqueness are enforced by Postgres, so they hold across processes and restarts. Call EnsureSchemaAsync on start; it is idempotent and is the only migration there is.
FilePaymentStore Xrpl.PaymentGateway You want no database. A single JSON file, rewritten atomically per write. One process only — which is the only supported way to run the monitor anyway.
InMemoryPaymentStore Xrpl.PaymentGateway Tests and demos. Everything is lost on restart, so the gateway would re-scan from the current ledger and miss whatever arrived while it was down.

Writing your own is the fourth way, and the interface is small. Two requirements are not negotiable: GetOrAssignTagAsync must be atomic, and TryAddPaymentAsync must enforce uniqueness of the transaction hash and return false rather than throw on a duplicate. Both are easy to state and easy to get wrong, so the test suite has a PaymentStoreContract class that every shipped store derives from — including the concurrency and restart cases. Deriving from it is how a new store proves itself.

Use

// Any IPaymentStore will do. This one is from Xrpl.PaymentGateway.Postgres.
PostgresPaymentStore store = new PostgresPaymentStore(connectionString);
await store.EnsureSchemaAsync();

builder.Services.AddSingleton<IPaymentStore>(store);
builder.Services.AddSingleton<IPaymentReceivedHandler, MyOrderActivator>();

builder.Services.AddXrplPaymentGateway(options =>
{
    options.Address = "rYourReceivingAddress";
    options.Nodes =
    [
        new Uri("wss://xrplcluster.com"),
        new Uri("wss://s1.ripple.com"),
    ];
});

Every setting, with its default and what happens when it is wrong, is in the configuration reference.

Issue instructions when a buyer reaches checkout:

PaymentInstructions instructions = await gateway.GetPaymentInstructionsAsync(buyerId, cancellationToken);

React when the money arrives:

public sealed class MyOrderActivator : IPaymentReceivedHandler
{
    public Task OnPaymentReceivedAsync(PaymentRecord payment, string? buyerId, CancellationToken ct)
    {
        // Called at least once per payment. Make this idempotent.
        return activations.ActivateAsync(buyerId, payment.Value, ct);
    }
}

What it guarantees

  • Exactly once in the store. Records are keyed by transaction hash, and the store rejects duplicates.
  • At least once to your handler. Delivery is retried by reconciliation until it succeeds, so handlers must be idempotent.
  • No gaps across disconnects. A persisted ledger cursor marks the boundary below which completeness is proven. Every reconnect subscribes first, then replays account_tx from the cursor. If no node can prove a range, the cursor freezes rather than skipping it. A stream that skips ledger numbers, or a client whose inbound queue overflowed, ends the session rather than advancing the cursor across what it did not see.
  • Amounts as delivered. Values come from transaction metadata balance changes, not the Amount field, so a partial payment is recorded at what actually arrived.

What counts as a payment

Exactly one thing: a validated, successful Payment whose Destination is the receiving account, sent by somebody else. Nothing else is recorded, even when it moves the account's balances — an offer of yours being crossed on the DEX is trade proceeds, and a payment rippling through you to a third party is money in transit. Neither is a buyer paying you, and neither carries a destination tag that means anything to you.

Other ways funds can reach an account — CheckCash, EscrowFinish, PaymentChannelClaim — are outside this library's scope. They cannot be attributed to a buyer by destination tag, so if you accept them you need to reconcile them yourself.

MPT payments are not supported yet. Amounts are derived from balance changes, and the reader behind that walks XRP and trust-line entries only. An MPT payment to the account is not recorded — but it is not lost quietly either: a successful payment addressed to you that credits nothing readable is logged as an error and raises AnomalyCount.

What it does not do

Deliberately out of scope, so that what is in scope can be relied on:

  • Outgoing payments, refunds, invoicing, fiat conversion. This receives and records; everything after that is the host's.
  • More than one receiving account per instance. Run an instance per account — the ledger cursor and the tag counter belong to one account each.
  • More than one monitor per account. A second instance produces no duplicate records, because writes are keyed by transaction hash, but it is not a supported configuration.
  • MPT amounts. See above: not recorded, but not lost quietly either.
  • A scheduler. CheckAsync and ReconcileAsync are called by whatever the host already runs.

Quotes and valuation

Optionally, the gateway keeps a liquidity reading for each asset you accept, prices any size against it without touching the network, and records what each received payment was worth in an asset of your choosing.

It does not compute prices itself — you supply an IQuoteSource, and the gateway owns the refresh rhythm, the age policy and delivery. Valuation runs behind the payment path and never on it: the payment is recorded and announced first, and its value arrives as a second signal through IPaymentValuedHandler.

See the quotes reference.

What it expects of the receiving account

Use a dedicated account that only receives. Specifically, it must not have DefaultRipple enabled and should not hold DEX offers or AMM positions.

If the account is itself the issuer of a token it accepts, a payment in that token is a redemption, and the balance change the ledger reports for it names the sender as the issuer rather than the account. A quote pair configured for that asset never matches it, so the payment is recorded but never valued. This is a property of the protocol, not a defect here — issue tokens you accept from a separate account, not the one this library watches.

If a payment addressed to you also debits the account, or credits two assets at once, the record is still written — it is a buyer's money and dropping it would lose a real payment — but it is logged as an error and increments AnomalyCount in the health report. Both shapes are physically impossible for an account that only receives, so treat any rise in AnomalyCount as something to investigate rather than a statistic: the usual cause is an offer or a rippling trust line the account should not have.

Health and reconciliation

Call these from whatever scheduler you already run — Hangfire, Quartz, a timer:

PaymentMonitorHealthReport report = await health.CheckAsync(cancellationToken);   // cheap, call often
ReconciliationResult result = await health.ReconcileAsync(cancellationToken);     // slower, call hourly

ReconcileAsync redelivers anything the handler never accepted and re-verifies a window of ledgers below the cursor. A non-zero RecoveredCount means a payment was missing from the store: investigate, because the monitor should never let that happen.

Schedule it more often than its own window. ReconcileWindow defaults to 2000 ledgers, roughly two hours; running reconciliation less often than that leaves ledgers no sweep ever covers. RedeliveredCount counts only records that actually reached the handler — a handler that keeps failing shows up in Errors, not as progress.

Operational notes

  • Run one monitor instance per receiving account. A second one produces no duplicate records, but it is not a supported configuration.
  • Destination tags are allocated by your store, not by the library. PaymentGatewayOptions.FirstDestinationTag is the value to hand your store; the library validates it but cannot apply it on the store's behalf.
  • Catch-up refuses nodes whose complete_ledgers does not cover the range it needs. If the health report says HistoryGap, add a full-history node to CatchUpNodes.
  • A network-wide consensus stall is reported as NetworkStalled rather than treated as a node failure. No payments are lost: nothing is being validated while the network is stopped.

Development

The test project is an executable, not a dotnet test target: xunit.v3 runs on Microsoft Testing Platform, and the .NET 10 SDK's dotnet test still routes through VSTest, which refuses to run it.

dotnet run --project tests/Xrpl.PaymentGateway.Tests -- -trait- "Category=Integration"

Integration tests need the stand: a standalone node on ws://localhost:6006 and, for the Postgres store's contract tests, a database on localhost:55432. Both come up together, and each test skips itself rather than failing when its dependency is missing.

They build a small economy on the ledger — a receiving account, two token issuers, two buyers — and then pay it in XRP and in an issued currency. That takes about a minute and a half of closed ledgers, most of it setup. The two issuers exist because a trust line's sides are ordered by comparing account ids, and which side the receiving account lands on changes the shape of the metadata a payment produces; one issuer would test whichever case the random addresses happened to give.

docker compose -p xrplpg-ci -f .ci-config/docker-compose.ci.yml up -d
dotnet run --project tests/Xrpl.PaymentGateway.Tests -- -trait "Category=Integration"
docker compose -p xrplpg-ci -f .ci-config/docker-compose.ci.yml down

By default the stand publishes the same ports as the XrplCSharp CI stand, so only one of the two can run; CONTRIBUTING.md shows how to move this one's ports so both can. If a healthy standalone node is already listening there, the tests use it and you can skip the compose step entirely; when nothing is listening, they skip themselves rather than fail.

CONTRIBUTING.md has the rest: test filters, how to prove a new payment store against the shared contract, code style, and the release process.

Sample

samples/Xrpl.PaymentGateway.SampleApi is a minimal API with a checkout page in front of it — the whole surface, end to end, in something you can click. No build step and no package manager: the page is three static files, so dotnet run is the entire toolchain.

docker compose -p xrplpg-ci -f .ci-config/docker-compose.ci.yml up -d
Xrpl__Address=rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh dotnet run --project samples/Xrpl.PaymentGateway.SampleApi

Open the printed URL. Enter a buyer id, take the address and destination tag, and pay. The page polls until the gateway reports the payment; the strip along the top shows the monitor's own state, so a reconnect or a catch-up is visible rather than looking like nothing happening. The page's own "sending from the standalone stand" section prints the calls to pay yourself with no wallet.

The QR code carries an X-address, not a bare account: one string holding the address and the destination tag together. A scanner given a classic address drops the tag, and a payment without the tag lands on the account attached to nobody. The X-address also encodes which network it is for — the sample flags it as test, matching the standalone stand, so set Xrpl:IsTestNetwork to false before pointing it at mainnet or wallets there will refuse the code. Test X-addresses begin with T, mainnet ones with X, which makes a mistake visible at a glance.

Xrpl:Address above is the standalone stand's master account, which is convenient for a demo because it already exists and is funded. For anything else, point it at your own receiving account.

Endpoint
POST /api/checkout/{buyerId} Address, destination tag, and an X-address carrying both
GET /api/checkout/{buyerId}/qr.svg The X-address as a scannable QR code
GET /api/checkout/{buyerId}/price What this checkout is priced at, in the quote asset, and how old that reading is. Empty until a quote pair is configured
GET /api/checkout/{buyerId}/payments What this buyer has paid. The page polls this
GET /api/checkout/{buyerId}/valuations What this buyer's payments turned out to be worth, a second and later signal than the payment itself
GET /api/payments Everything the handler has been given
GET /api/valuations Every valuation the handler has been given, whichever state it landed in
GET /api/recorded Everything the store holds, when the store offers a snapshot
GET /api/health The monitor's state; 503 when it is not streaming
POST /api/reconcile Redeliver and re-verify on demand
GET /api/quotes/health Pair freshness, refresh failures, pending valuations and the age of the oldest of them. 404 when no quote pairs are configured; 503 when configured but not healthy
GET /api/quotes/unresolved Payments the automatic pipeline has not resolved, for an operator to act on
POST /api/quotes/unresolved/{transactionHash}/settle Price one unresolved payment by hand, at a rate supplied in the body
POST /api/quotes/unresolved/{transactionHash}/write-off Close one unresolved payment with no quote amount, recording why
GET /api/demo The demo wallet's address and the assets it may be asked to send. 404 when no demo seed is configured
POST /api/checkout/{buyerId}/pay Pay this checkout from the demo wallet. 404 when no demo seed is configured

Set Xrpl:StorePath to keep payments in a file instead of memory, and they survive a restart.

Quotes and valuation in the sample

The demo shop takes payment in three things, all valued in USD: XRP and one issued token (GEM below, a placeholder), each priced into USD through its own pair, and USD itself, accepted directly. Set Xrpl:Quotes:QuoteCurrency/Xrpl:Quotes:QuoteIssuer and Xrpl:Quotes:Pairs and the page grows a price at checkout, a valuation that appears on a payment row a few seconds after the payment itself, a quote health strip beside the monitor strip, and an "Unresolved payments" section for whatever the automatic pipeline could not price. Leave it empty, the shipped default, and the sample runs exactly as it does without the feature — AddXrplPaymentQuotes is never even called.

"Xrpl": {
  "Quotes": {
    "QuoteCurrency": "USD",
    "QuoteIssuer": "rUsdIssuerAddress",
    "Pairs": [
      { "Currency": "XRP", "Rate": 0.55 },
      { "Currency": "GEM", "Issuer": "rGemIssuerAddress", "Rate": 1.10 }
    ],
    "RefusedCurrencies": []
  }
}

Rate is quote-asset units per unit of the received asset; every pair shares the one QuoteCurrency/QuoteIssuer above them, because the sample values everything a buyer can pay with in the one asset a real shop would price its catalog in. USD is the third accepted asset and deliberately has no entry in Pairs: QuotePair rejects quoting an asset against itself, so there is no USD/USD pair and cannot be one — a payment already in USD needs no conversion, and the library queues no valuation for it. The sample handles that itself rather than asking the library to: SamplePaymentHandler checks whether a payment's currency and issuer are the configured QuoteCurrency/QuoteIssuer, and the page shows such a payment at its own amount, labelled as needing no conversion, instead of a row waiting forever for a valuation that was never going to arrive.

A currency code longer than three characters has no short form on the ledger. RLUSD is 524C555344000000000000000000000000000000, and that hex is what goes in CurrencyCurrencyKey rejects a five-character code outright — and what the node reports back on every payment in it. The page decodes such a code to its name for display, so the asset still reads as RLUSD on the price line, on its pay button and on the payment row, while everything on the wire stays the code the ledger actually uses.

Where the prices come from is Xrpl:Quotes:Source, and the sample ships two answers to it.

The default, with the key absent or set to anything else, is FixedRateQuoteSource: rates read straight from configuration, no network call at all. It shows the shape of the integration with nothing else running — no stand, no pools, no liquidity — and it prices nothing real. Size does not move its answer, and its LedgerIndex counts captures rather than naming a ledger. Xrpl:Quotes:RefusedCurrencies names currencies it throws for instead of pricing, which gives the unresolved queue and the settle and write-off buttons something to act on in a demo that otherwise always prices cleanly.

"Source": "amm" switches to AmmQuoteSource, which reads the pair's AMM pool off a validated ledger and prices a size through the constant-product formula with the pool's own trading fee. Every number then comes from the ledger: the ask moves when the pool does, the ledger index is a real one, and the price for the size differs from the price per unit — the checkout line grows a "below spot" figure, which is slippage, and a valuation's EffectivePrice sits below its MarginalPrice for the same reason. An asset with no pool reads as having no liquidity, so it goes to the operator's queue like any other asset nothing can price it against.

AmmQuoteSource is still a demonstration, not a quote engine: one pool, no order book, no routing through a third asset, no splitting a size across venues. A pair whose book is deeper than its pool is mispriced by it, and a pair with no pool reads as dry with an order book standing right there. A real host brings its own pricing, the same way it brings its own IPaymentStore — that is what IQuoteSource being an interface is for.

Paying from the page

Set Xrpl:Demo:PayerSeed to the seed of a funded account on the same network and the "Send the payment" step grows a row per accepted asset — an amount, prefilled with what the checkout asks for, and a button that signs the payment and submits it. It is the shell snippet the page already prints, moved onto the page, so a full demonstration needs one terminal instead of two.

"Xrpl": {
  "Demo": {
    "PayerSeed": "sXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  }
}

Leave it empty, the shipped default, and neither the endpoints nor the buttons exist — the page waits for money sent from somewhere else, exactly as before.

A seed in configuration is a private key in a text file. Use it against a standalone stand or a test network and nothing else. The endpoint is narrow on purpose — it can only pay the address and destination tag the gateway itself just issued for the buyer being checked out, in an asset the sample is configured to accept, and the caller names neither — but that narrowness protects the destination, not the seed.

To pay in an issued currency, the demo payer needs a trust line to its issuer and a balance on it, and the receiving account needs a trust line for the same currency, or the payment is rejected by the ledger before the gateway ever sees it. On a standalone stand that means creating an issuer, enabling DefaultRipple on it so its token can move between two holders, opening the trust lines, and funding the payer.

Reproducing the whole demo on a standalone stand

The quote demo needs a token economy the ledger does not come with — an issuer whose tokens can move between two holders, a receiving account with a trust line for each token it accepts, and a payer holding a balance in each. samples/seed-demo-stand.py builds it and writes the configuration that goes with it:

docker compose -p xrplpg-ci -f .ci-config/docker-compose.ci.yml up -d
python3 samples/seed-demo-stand.py --write samples/Xrpl.PaymentGateway.SampleApi/appsettings.Development.json
ASPNETCORE_ENVIRONMENT=Development dotnet run --project samples/Xrpl.PaymentGateway.SampleApi

It also creates an AMM pool for each priced pair and writes "Source": "amm", so the demo prices off the ledger rather than off fixed rates. That gives the page all five assets at once: XRP, GEM and RLUSD — the last under the long hex code — each priced into USD through its own pool, USD accepted directly, and JNK, which is issued with no pool at all, so nothing can price it and it lands in the operator's queue. The demo payer's buttons can send any of them.

The pools are sized so their spot prices match the fixed rates the same configuration carries, which makes the two sources comparable: switch Source back and the asks move only by the pool's fee and the size's own slippage. Drop the line entirely and the stand's pools stop being read at all.

Every account comes from a fixed passphrase, so a second run against a live stand does nothing and a run against a recreated stand reproduces the same addresses. --rpc and --node point it at a stand on other ports; XRPLPG_RPC_URL and XRPLPG_NODE_URL do the same from the environment. Without --write it prints the configuration instead.

appsettings.Development.json is git-ignored, because what the script writes into it includes the demo payer's seed. Its shape is committed beside it as appsettings.Development.example.json, with the addresses replaced by placeholders. And it is read only when the environment is Development — hence the variable above, since dotnet run in a fresh clone has no launchSettings.json to set it.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on Xrpl.PaymentGateway.Abstractions:

Package Downloads
Xrpl.PaymentGateway

Background XRP Ledger payment monitor: destination tag allocation, exactly-once recording, catch-up after disconnects, health and reconciliation.

Xrpl.PaymentGateway.Postgres

PostgreSQL storage for Xrpl.PaymentGateway: an IPaymentStore with durable payment records, atomic destination tag allocation and the ledger cursor, plus an IQuoteStore for quote readings and payment valuations.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 39 9/4/2026
1.0.0 107 8/27/2026