Xrpl.PaymentGateway.Postgres
1.2.0
dotnet add package Xrpl.PaymentGateway.Postgres --version 1.2.0
NuGet\Install-Package Xrpl.PaymentGateway.Postgres -Version 1.2.0
<PackageReference Include="Xrpl.PaymentGateway.Postgres" Version="1.2.0" />
<PackageVersion Include="Xrpl.PaymentGateway.Postgres" Version="1.2.0" />
<PackageReference Include="Xrpl.PaymentGateway.Postgres" />
paket add Xrpl.PaymentGateway.Postgres --version 1.2.0
#r "nuget: Xrpl.PaymentGateway.Postgres, 1.2.0"
#:package Xrpl.PaymentGateway.Postgres@1.2.0
#addin nuget:?package=Xrpl.PaymentGateway.Postgres&version=1.2.0
#tool nuget:?package=Xrpl.PaymentGateway.Postgres&version=1.2.0
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.
Listing what was recorded
IPaymentStore answers point questions — this buyer's tag, this tag's buyer, what is still undelivered —
because that is all the payment path needs. An operator screen needs listings, and those live on a
separate interface, IPaymentDirectory:
// The store you already registered. All three shipped stores implement it.
IPaymentDirectory directory = (IPaymentDirectory)store;
BuyerTagPage buyers = await directory.ListBuyersAsync(limit: 50, offset: 0, ct);
RecordedPaymentPage recent = await directory.ListPaymentsAsync(PaymentAttribution.Any, 50, 0, ct);
// Money that arrived and belongs to nobody: no destination tag, or a tag never issued.
RecordedPaymentPage orphans = await directory.ListPaymentsAsync(PaymentAttribution.Unattributed, 50, 0, ct);
Separate rather than folded into IPaymentStore for the same reason IQuoteStore was kept separate in
1.1.0: a store written against an earlier version keeps compiling. It is a capability, so a host that
supplies its own store tests for the interface rather than assuming it.
PaymentAttribution.Unattributed is the one worth knowing about. Such a payment is invisible everywhere
else — no valuation queue holds it, no balance shows it, and the sender is known but is not necessarily
the buyer. Without a listing, an operator learns about it from whoever sent it.
Each row carries the buyer the tag was issued to (null when it belongs to nobody) and whether
MarkHandledAsync has been called, so a stuck delivery is visible as an old unhandled payment. Buyers are
ordered by tag, which is issue order and the only ordering paging can trust; payments are newest first,
which offsets cannot promise stability under — fine for a screen, not a basis for anything that must see
every row. A new store proves its directory by deriving from PaymentDirectoryContract.
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_txfrom 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
Amountfield, 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.
CheckAsyncandReconcileAsyncare 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.FirstDestinationTagis 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_ledgersdoes not cover the range it needs. If the health report saysHistoryGap, add a full-history node toCatchUpNodes. - A network-wide consensus stall is reported as
NetworkStalledrather 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, and 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.
Quotes and valuation, the operator's queue, and a wallet the page can pay itself from are each one setting away and all off by default. The sample guide covers those, the page's endpoints, and a script that builds the token economy — issuer, trust lines, AMM pools — that the quote demo needs on a standalone stand.
| Product | Versions 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. |
-
net10.0
- Npgsql (>= 9.0.5)
- Xrpl.PaymentGateway.Abstractions (>= 1.2.0)
-
net8.0
- Npgsql (>= 9.0.5)
- Xrpl.PaymentGateway.Abstractions (>= 1.2.0)
-
net9.0
- Npgsql (>= 9.0.5)
- Xrpl.PaymentGateway.Abstractions (>= 1.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.