Liking cljdoc? Tell your friends :D

Integrations

Every system outside this process that the money path touches, and what it takes to wire one.

Each of them sits behind a protocol declared in collect/, and the protocol — not the vendor — is what the store depends on. That has a consequence worth stating up front: every integration on this page is optional. The store boots with none of them, runs its whole test suite with none of them, and takes money with a fake wallet. What an absent integration changes is what can be sold, not whether the process starts.

The one exception is the host application, which is not a service the store dials at all. It is the process the store runs inside.

The picture

Integrations — every system the money path touches

Source: docs/diagrams/plantuml/monero-store/integration-view.puml, and both it and the picture are build artifacts. The model is models/monero-store/model.edn and views.edn; bb arch regenerates the .puml from it, and nothing else. The .svg embedded here is a second step: bb arch:svg depends on arch and then runs plantuml -tsvg over the generated .puml files, so it needs plantuml on PATH. bb arch alone leaves the picture above stale. Editing either artifact is editing the wrong file; see models.md.

The arrows in that view are the thing to read, because direction is what the rest of this page is about:

RelationDirectionTransport
store → MoneroPayopens subaddresses and re-reads themHTTP
MoneroPay → storeprompts a re-read ofunsigned callback
store → monero-wallet-rpcopens subaddresses and re-reads themJSON-RPC
store → Stripeopens checkout sessions and re-reads themHTTPS
Stripe → storeposts signed settlement notices towebhook
store → rate tickersreads independent prices fromHTTPS
store → PostgreSQLstores invoices and payments inJDBC
store → analyticsreports scrubbed funnel counts toHTTPS
store → host applicationhands fulfilment toIFulfilment
host application → storesays who is askingidentify-fn
payer → storebuys fromHTTPS
operator → storesettles unapplied money throughHTTPS, operator token

Five arrows point inward, and only two of them are the trust problem. A payer buying and an operator settling unapplied money under ADMIN_TOKEN are the store's own HTTP surface being used as intended; the host application saying who is asking is a function call, not a request. What is left is the two settlement notices, arriving from a system the store does not control — and they are answered very differently.

At a glance

SystemPortSelected byAliasWhen it is absent
MoneroPayIChainWalletMONERO_BACKEND=moneropaynoneno chain rail is registered
monero-wallet-rpcIChainWalletMONERO_BACKEND=wallet-rpc:monero-rpcwarns, no chain rail is registered
StripeICardGatewayCARDS_BACKEND=stripe:stripewarns, no card rail is registered
Rate tickersIRateSourcealways onnonethe round yields nothing and is not cached
PostgreSQLIOrderStoreSTORE_BACKEND=jdbc:jdbcwarns, falls back to memory
AnalyticsIAnalyticsANALYTICS=umaminonemeasures nothing
Host applicationIFulfilment, identify-fnstart! overridesnoneledger-plus-log fulfilment, nobody identified

Two of them degrade rather than refuse: a jdbc store with no :jdbc alias falls back to store/memory-store, and ANALYTICS=umami with no website id falls back to analytics/noop. Both install a working implementation that is not the one asked for. The rails do the opposite — chain-wallet and card-gateway return nil, system/rails conjoins nothing, and a rail that cannot settle is never advertised. Of the two that degrade, only the store one can cost you something, and it is explained in its own section.


MoneroPay

What it is for. MoneroPay watches a Monero wallet on the store's behalf. It assigns one subaddress per expected amount and posts a callback when it sees money, so the store never runs a wallet daemon itself.

Port. monero-store.collect.wallet/IChainWallet, satisfied by wallet/moneropay-wallet. Two operations, both plain HTTP against the gateway:

  • open-address!POST {base}/receive with {:amount :description :callback_url}
  • observeGET {base}/receive/{address}

Configuration.

VariableDefaultMeaning
MONERO_BACKENDnonemoneropay selects this wallet
MONEROPAY_URLhttp://moneropay:5000the gateway origin
MONERO_CALLBACK_SECRETunsetHMAC key for the callback token
MONERO_MIN_CONFIRMATIONS10confirmations before the payment settles
MONERO_PROVIDER_IDmonerohow this rail is named in the registry
MONERO_CURRENCYxmrthe rail's currency

Alias. None. MoneroPay speaks HTTP, and the HTTP client (hato) is a core dependency. This is why MONERO_BACKEND=moneropay needs no extra source root while wallet-rpc does.

When it is absent or misconfigured. The shared HTTP client catches everything and returns {:http/status 0} rather than throwing, so an unreachable gateway is a status, not a stack trace. From there the two operations diverge on purpose:

  • open-address! throws ex-info with {:monero-store/error :address-unavailable} when the gateway will not open an address. Checkout fails loudly, because handing a customer an invoice with no address to pay is worse than an error.
  • observe logs gateway cannot report on address and returns nil. No observation is no evidence — the invoice stays open and the reconcile sweep tries again on the next RECONCILE_INTERVAL_MS.

Notices. MoneroPay signs nothing. See The trust model below; this is the case the whole design is shaped around.

MONERO_BACKEND=fake registers the same chain rail over wallet/fake-wallet, an IChainWallet over an atom. wallet/credit! appends a transfer to it, which drives the entire settlement path from a REPL with no daemon, no gateway and no coin.

monero-wallet-rpc

What it is for. The deployment runs its own Monero wallet and the store drives it directly, with no gateway in between.

Port. The same IChainWallet, satisfied by monero-store.adapters.monero-rpc/rpc-wallet over the monero-java SDK. The rail above it is unchanged — a chain rail does not know which of the two wallets is underneath.

Configuration.

VariableDefaultMeaning
MONERO_BACKENDnonewallet-rpc selects this wallet
MONERO_WALLET_RPC_URIunsetthe RPC endpoint
MONERO_WALLET_RPC_USERNAMEunsetdigest credentials
MONERO_WALLET_RPC_PASSWORDunsetdigest credentials
MONERO_ACCOUNT_INDEX0the account subaddresses are opened under

MONERO_MIN_CONFIRMATIONS, MONERO_PROVIDER_ID and MONERO_CURRENCY apply here too — they belong to the rail, not to the wallet.

Alias. :monero-rpc, which adds adapters/monero-rpc/src and io.github.woodser/monero-java. Nothing under src/ requires that namespace; system/optional-fn resolves it by symbol at wiring time and returns nil when it is not on the classpath. That is how an SDK-backed adapter stays optional without a compile-time dependency, and why the published library carries no payment SDK at all.

When it is absent or misconfigured.

ConditionWhat happens
:monero-rpc not on the classpathlogs MONERO_BACKEND=wallet-rpc but the :monero-rpc alias is not on the classpath, no wallet
MONERO_WALLET_RPC_URI blanklogs MONERO_BACKEND=wallet-rpc but MONERO_WALLET_RPC_URI is unset, no wallet
daemon unreachable at bootnothing — the connection is a delay, opened on first use
daemon unreachable at observationlogs wallet cannot observe subaddress, returns nil

No wallet means no chain rail: system/rails only conjoins a chain entry when chain-wallet returned something. A rail that cannot settle is never advertised.

Notices. A wallet does not call back. The chain rail publishes the same webhook route whichever wallet backs it, so /webhooks/monero/… exists — but with wallet-rpc nothing posts to it, and settlement is carried entirely by the reconcile sweep.

Stripe

What it is for. Hosted card checkout, for a deployment that wants to sell to people who will not pay in Monero.

Port. monero-store.collect.cards/ICardGateway, satisfied by monero-store.adapters.stripe-cards/stripe-gateway (open-checkout!, read-checkout). The rail is payments.stripe/entry, which is payments.hosted/entry with a Stripe profile and a Stripe notice reader.

Configuration.

VariableDefaultMeaning
CARDS_BACKENDnonestripe selects this gateway; fake selects cards/fake-gateway
STRIPE_API_KEYunsetdecides live or test mode
STRIPE_WEBHOOK_SECRETunsetthe endpoint signing secret
STRIPE_API_BASEunsetpoints the client elsewhere — a sandbox, or stripe-mock
CARDS_CURRENCYusdthe rail's currency

The success and cancel URLs are not separately configurable: they are derived from PUBLIC_BASE_URL as {base}/paid and {base}/.

Alias. :stripe, which adds adapters/stripe/src and com.stripe/stripe-java. Resolved the same lazy way as :monero-rpc.

When it is absent or misconfigured.

ConditionWhat happens
:stripe not on the classpathlogs CARDS_BACKEND=stripe but the :stripe alias is not on the classpath, no gateway, no card rail
STRIPE_API_KEY blanklogs CARDS_BACKEND=stripe but STRIPE_API_KEY is unset, no gateway, no card rail
key unusablenothing at boot — the client is a delay, built on first use
Stripe cannot report on a sessionlogs card processor cannot report on session, returns nil
STRIPE_WEBHOOK_SECRET blankthe rail still registers, and refuses every notice

That last row is the one to understand. A configured Stripe with no webhook secret is not a broken deployment — it is a slower one. The signed fast path is off, and the polled path carries settlement, because the profile sets :provider/settlement-poll? true.

Notices. Stripe signs its payloads. See below.

The rate tickers

What it is for. Pricing. A quote in XMR needs a rate, and a rate from one exchange is a rate one exchange can be wrong about.

Port. monero-store.collect.rates/IRateSource. rates/registry builds one source per profile in rates/sources, which is four independent public tickers, none of which needs an account or a key:

SourcePairEndpoint
:krakenXMR/USDapi.kraken.com/0/public/Ticker?pair=XMRUSD
:coingeckoXMR/USDapi.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd
:coinpaprikaXMR/USDapi.coinpaprika.com/v1/tickers/xmr-monero
:bitfinexXMR/USDapi-pub.bitfinex.com/v2/ticker/tXMRUSD

Configuration.

VariableDefaultMeaning
RATE_CACHE_TTL_MS60000at most one round of network per this window
RATE_TIMEOUT_MS10000bounds every upstream HTTP call

RATE_TIMEOUT_MS is worth a second look: it configures the single http/hato-client that start! builds and then hands to the rate feed, the MoneroPay wallet and the analytics sink alike. It is named for rates and it bounds all three.

Alias. None.

When they are absent or misconfigured. A source that fails is absent from the round rather than failing it — rates/round keeps what answered. A round that yields nothing is not cached, so a total outage retries on the next call instead of pinning an empty result for RATE_CACHE_TTL_MS. A ticker that quotes its price as a string is handled; rates/->number accepts either.

Notices. None, in either direction. The store only ever reads.

PostgreSQL

What it is for. Remembering who paid. The default in-memory store forgets everything on restart, including that.

Port. monero-store.collect.store/IOrderStore, satisfied by monero-store.adapters.jdbc-store/jdbc-store over next.jdbc and HikariCP. The DDL runs at construction.

Configuration.

VariableDefaultMeaning
STORE_BACKENDmemoryjdbc selects PostgreSQL
DATABASE_URLunsetthe JDBC URL
DATABASE_USERunset
DATABASE_PASSWORDunset

Unlike PORT, MONEROPAY_URL or TOKENS_FILE, none of the three DATABASE_* variables carries a fallback in code: system/config reads them with the one-argument env:jdbc-url (env "DATABASE_URL") — so unset is nil, not a localhost guess. The familiar jdbc:postgresql://postgres:5432/store and store do exist, in .env.example and docker-compose.yml, but they are the compose deployment's values rather than the store's. A store run outside compose is told its database or has none.

Alias. :jdbc, which adds adapters/jdbc/src, next.jdbc, the PostgreSQL driver and HikariCP.

When it is absent or misconfigured. This is the degradation that matters. With STORE_BACKEND=jdbc and no :jdbc alias, the store logs

STORE_BACKEND=jdbc but the :jdbc alias is not on the classpath; using memory

and boots on store/memory-store. The reasoning is that a template which refuses to boot teaches nothing — but it is the wrong behaviour for a real deployment, and the log line is the only thing standing between you and a store that quietly forgets who paid. Treat it as a boot-time assertion in whatever runs the process.

Failing to connect is different from failing to load: with the alias present and the database unreachable, jdbc-store builds its datasource and runs the DDL at construction, so the failure surfaces during start!. An unset DATABASE_URL fails the same way rather than falling back — datasource hands HikariCP a nil JDBC URL, and the DDL is what discovers it.

Notices. None. PostgreSQL is written to, never listened to.

The analytics sink

What it is for. Funnel counts — how many people reached checkout, how many paid. Not who they were.

Port. monero-store.collect.analytics/IAnalytics, one method, track!.

ANALYTICSSink
umamianalytics/umami — posts to {UMAMI_URL}/api/send
loganalytics/logging
anything else, including unsetanalytics/noop

Configuration.

VariableDefaultMeaning
ANALYTICSnonewhich sink
UMAMI_URLunsetthe Umami origin
UMAMI_WEBSITE_IDunsetthe site's uuid
ANALYTICS_HOSTNAMEstorethe hostname reported with each event

Alias. None.

When it is absent or misconfigured. ANALYTICS=umami with a blank UMAMI_WEBSITE_ID logs ANALYTICS=umami but UMAMI_WEBSITE_ID is unset; measuring nothing and installs the noop sink instead. At runtime, a rejected request is logged and a throwing sink is caught: a customer paying an invoice does not care that a dashboard is down. analytics/composite extends the same rule to several sinks — one that throws does not stop the others.

Umami rejects a request with no User-Agent, so one is always sent.

What leaves the process. analytics/scrub drops a fixed set of keys before any adapter sees an event:

#{:email :customer :customer-id :address :pay-to :external-ref :reference
  :tx-hash :references :secret :token}

An address is a pseudonym until it is joined to anything else; an email or a transaction hash joined to a funnel event is a deanonymisation waiting to be queried. The scrubber is also strict about values — an unrecognised type becomes its class name rather than being passed through, because the failure mode of a permissive scrubber is silent and permanent.

Notices. None inbound.

The host application

Not a service. The host application is the process this store runs inside, and it is an integration in the sense that matters most: it owns identity and it owns entitlement. The store decides whether money was good; the host decides what that buys.

How it embeds. Depend on io.github.buddhilw/monero-store (0.3.0) from the hive-gitea Maven registry — a source jar of src and resources only, carrying no payment-SDK dependency — and call system/start! with overrides:

OverrideReplaces
:fulfilmentthe IFulfilment — hand delivery to the host
:identify-fnwho is asking
:catalogsell the host's own items
:railsregister a rail this template has never heard of
:storepersist where the host already persists
:rates-fnprice from a feed the host already has
:analyticsmeasure with the host's own sink
:experimentsrun arms declared somewhere other than the token file

Anything else in the map is a config key and overrides the environment.

Identity. IDENTITY=header installs identity/header-identity, which trusts the x-customer-ref request header outright. It logs a warning saying so at boot. That is a demo, or a deployment behind a gateway that has already authenticated the caller. Any other value resolves nobody, which is the default. Anything real supplies :identify-fn and never touches IDENTITY at all.

The operator surface. ADMIN_TOKEN gates GET /api/admin/queue and POST /api/admin/grants. Unset means there is no operator surface, and start! logs operator surface disabled: ADMIN_TOKEN is unset.

Other environment the host controls.

VariableDefaultMeaning
PORT8080
PUBLIC_BASE_URLhttp://localhost:8080the base callbacks and redirects are built from
CATALOG_FILEunsetan EDN vector of items; the sample catalog otherwise
FULFILMENTledger + lognone or log
DISABLE_MANUAL_RAILunsetset to true/1/yes to drop the manual rail
RECONCILE_INTERVAL_MS60000how often the sweep runs
TOKENS_FILEtokens.ednthe design contract experiments are read from

Notices. None — the host is in-process. IFulfilment is a function call.


The trust model

Three rails, three different answers to who authenticated this notice. The answer lives in each rail's profile under :provider/webhook-auth, and the routes are the same for all of them:

POST /webhooks/:provider
POST /webhooks/:provider/:invoice
POST /webhooks/:provider/:invoice/:token

One handler serves all three shapes. Which shape a rail uses changes nothing about who authenticates the notice — the invoice's own rail always does.

Rail:provider/webhook-authWhat authenticates a notice
chain (:monero):server-confirmednothing in the body; the store re-reads the wallet
Stripe:signed-payloada v1 HMAC-SHA256 over the exact bytes that arrived
manual:noneno notice is accepted at all

MoneroPay signs nothing

This is the load-bearing fact. MoneroPay posts a callback with no signature of any kind, so the callback body is never evidence here. It is a prompt to re-read /receive/{address} — which is what observe does, and what the rail settles from. A forged callback can, at most, make the store ask its own wallet a question it already knew the answer to.

MONERO_CALLBACK_SECRET is not a signature check. It is an HMAC-SHA256 over the invoice id, truncated to 32 hex characters, appended to the callback URL as a path segment when the rail opens the address:

SecretCallback URLA notice about invoice i
unset…/webhooks/monero/{invoice}always allowed to prompt a re-read
set…/webhooks/monero/{invoice}/{token}must present the token for i, compared in constant time

With no secret every notice may prompt a re-read, which is harmless because the answer comes from the wallet either way. With a secret, the token keeps a stranger from making the store hammer its own wallet. The comparison fails closed: wallet/token-valid? treats a blank secret and a blank presented token as failures, because a check that fails open is not a check.

Stripe signs its payloads

stripe/signature-valid? requires a stripe-signature header carrying a t timestamp and at least one v1 value, and recomputes HMAC-SHA256 over "{t}.{raw body}" under STRIPE_WEBHOOK_SECRET, compared in constant time.

Four things about it are deliberate:

  • The raw body is signed. Reserializing a parsed body changes bytes and every signature over them, so the handler keeps the untouched request body.
  • A blank secret accepts nothing. An unconfigured endpoint that fails open is worse than one that is off.
  • The timestamp must fall within default-tolerance-seconds (300, Stripe's own recommendation) of receipt — on both sides. Stripe's tolerance is one-sided; this store adds a future bound, because a notice stamped a year ahead is not a notice.
  • A malformed header is refused, not parsed loosely.

The reader then accepts only known events, and reads the invoice from client_reference_id:

Events
settledcheckout.session.completed, invoice.payment_succeeded
failedcheckout.session.expired, invoice.payment_failed

The manual rail has no HTTP surface

manual/profile sets :provider/webhook-auth :none, which makes provider/webhook-settleable? false for it. Its notice-subject, verify-notice and poll all return nil. There is nothing to authenticate because there is nothing to send: a manual invoice settles through the operator surface, POST /api/admin/grants.

What a caller is told

pipeline.notice/apply-notice! returns one of three verdicts, and the route maps them:

VerdictStatusBody
:notice/applied200the SettlementOutcome variant
:notice/unauthenticated400unauthenticated settlement notice
:notice/unknown-invoice404unknown invoice

:notice/unknown-invoice covers three distinct situations — no such invoice, the claimed provider is not the invoice's own, and that rail admits no settlement over HTTP. They are collapsed into one verdict on purpose: telling a stranger which of them it was is telling them what to send next.


Reachability: is the service even there?

src/monero_store/collect/reachability.clj answers the question a telnet by hand answers — but as a value, and with the distinction that matters preserved. A telnet answers it once, for one person, and leaves nothing behind.

probe opens a TCP connection to an Endpoint (:endpoint/host, :endpoint/port, optional :endpoint/label), closes it, and returns a ReachabilityReport:

{:reach/label      "moneropay:5000"
 :reach/host       "moneropay"
 :reach/port       5000
 :reach/outcome    <Reachability>
 :reach/elapsed-ms 3
 :reach/detail     nil}

It never throws. An unreachable service is a value, because the caller's job is to report it.

The outcome is a Reachability, a closed sum with five variants. What a probe throws decides which:

VariantThrownWhat it means operationally
:reach/opennothingthe connection was accepted and closed
:reach/refusedConnectExceptionsomething answered. The route works; nothing is listening on that port
:reach/timeoutSocketTimeoutExceptionnothing answered. The path is blocked
:reach/unknown-hostUnknownHostExceptionthe name never resolved; nothing was dialled
:reach/erroranything elsekept distinct rather than folded into a refusal

Why a refusal and a timeout are not the same failure

They look identical from the application's side — no connection either way — and they call for opposite responses.

A refusal is an answer. A packet reached the host, the host's TCP stack replied with RST, and the reply came back. Routing, DNS and firewalls are all working. What is wrong is at the other end: the service is down, the container has not started, or the port is wrong. The fix is on the service.

A timeout is the absence of an answer. The store cannot tell whether the packet arrived, whether the service is healthy, or whether anything exists at that address at all, because nothing came back. That is the signature of a dropped packet: a firewall rule, a security group, a network the two processes do not share. The fix is on the path, and restarting the service will not touch it.

reachability/blocked? is exactly this predicate — it is true for :reach/timeout and nothing else. A refusal is a service that is down; a timeout is a path that never carried the question.

:reach/unknown-host separates a third case that is easy to misread as either: nothing was dialled, because the name did not resolve. In a compose deployment that usually means a service name that does not match the one in docker-compose.yml, not a network fault.

Probes

ConstructorBacking
socket-probea real TCP connect, with a timeout
fake-probea map of [host port]Throwable-or-nil

fake-probe is what makes the classification testable without a network: the suite asserts that a refusal and a timeout produce different reports from the same code path.

Nothing in system.clj calls this namespace. It is not on the boot path — it is a diagnostic, for the moment when settlement has stopped and you need to know which of the systems on this page is the reason.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close