OpenReceive v0.2.1
First test publish to NPM and RubyGems
Second audit sweep
A second full-repo audit fixed 24 correctness bugs, removed 22 trust-model
violations, closed 7 wrapper-parity gaps, and finished the order_id →
reference rename. The behavior changes worth knowing:
- Settlement. A notified
payment_receivednow checks pendingness BY HASH
(payments.findPendingAttempt) instead of membership in the oldest-200
batch, so a notified settlement no longer waits for a backlog to drain.
A reconcile result that reportssettledwith nopaidAtis reported as a
failure instead of skipped silently, and the request-path reconcile pass now
runs AFTER the body's cheap refusals so an anonymous garbage POST cannot
claim the gate. Rails exempts unauthenticatedGET /ratesfrom the pass,
matching the JS handler. - Wallet scans. A
list_transactionspage whose rows are all unusable now
fails the scan in both engines rather than reading as an empty wallet — an
empty-looking scan at expiry+grace closes unpaid attempts. Within a page, a
row whose PRESENT fields cannot be read (a non-hexpayment_hash, an
unparsable amount) is skipped and counted; absent fields still mean absent. - Invoice expiry. A wallet that clamps expiry no longer fails every
checkout: the ledger stores the wallet's ownexpires_atand logs
checkout.invoice_expiry.adjusted. A caller-suppliedexpirySeconds(only
the swap path sets one) stays a hard floor, and only when the wallet comes
in short. - Swaps. Provider transport failures on
/swaps,/swaps/statusand
/swaps/refundsmap to 502/503 instead of a generic 500. A provider order
without an expiry fails the create rather than inventing a 10-minute window,
and a provider amount that is present-but-unparsable throws instead of
vanishing. When every configured provider fails its catalog fetch, methods
reportprovider_unreachable, notprovider_unconfigured. A 429 backoff no
longer resets when the weight window rolls. - Checkout UI.
startSwapnow quotes before it starts, in the SHARED
session, so React and the custom element behave identically — an
out-of-range amount is an accepted-range panel in both, built from one
createSwapUnavailableModel. Expired-mid-swap keeps the wizard in both. The
refund review gate is explicitly client-side:refund_nonceand
refund_nonce_expires_atare gone from the browser wire types (the server
never sent them), andSwapDisplayModelcarriesrefundAllowed: boolean.
A swap deposit QR that cannot encode its amount now throws instead of
silently degrading to an amount-less payment URI. - Renames. Option types match their factories (
CreateHostOptions,
CreateStackOptions,CreateHttpHandlerOptions,DefineElementsOptions,
…).@openreceive/browserexportscreateLightningUri/createQrSvg/
createQrPngDataUrlunder those names on both entry points, and no longer
exports the internalreadJsonResponse. The adapter packages no longer
re-export the 14 generatedWire*body types (still on
@openreceive/http). Ad-hoc app-route console loggers are
createAppConsoleLogger/createAppBrowserConsoleLogger, soHostnames
only the persistence object. - New.
createStacktakesonBootFailure, and a failed boot answers
503 WALLET_UNAVAILABLEin the error contract instead of rethrowing the raw
cause.payInAssetNetworkin@openreceive/coreowns the
pay_in_asset→ network split that four call sites re-derived.
ORM handles wrap in one call: knexDb, prismaDb, typeOrmDb, sequelizeDb
@openreceive/httpships a namedSqlAdapterfactory per ORM whose handle
createSqlPaymentscannot accept directly. The parameter types are
structural (no ORM dependency);dialectstays a required argument. The
guide's copy-paste recipes are gone — and the shippedprismaDbfixes a bug
the Prisma recipe carried: only^selectstatements ran through
$queryRawUnsafe, so anUPDATE … RETURNINGfulfillment claim (the
guide's ownonPaidexample) lost its rows and never fulfilled.sequelizeDbcloses the last gap: Sequelize was a first-class scaffold flag
whose only documented wiring was "open a secondpgPool to the same
database, or hand-roll an adapter". It binds parameters through Sequelize's
bindoption and threads the managed transaction into every statement
inside it — Sequelize carries the transaction on the same instance, so a
hand-rolled adapter that missed that ran settlement outside the transaction.- The scaffold's wiring guide no longer prints hand-rolled
SqlAdapter
snippets for Prisma/Knex/TypeORM/Sequelize; each section is now the shipped
factory in one line. npm run test:ormsproves the factories against the real ORMs: knex,
typeorm, and prisma (7, via its better-sqlite3 driver adapter) each drive
the payments repository — commit, write-once settlement, reconcile-gate
CAS — on sqlite. Weeklyorm-adaptersjob indemos.yml; the ORMs are
devDependencies only. The no-database-driver gate now checks the root
workspace manifest on its runtime dependencies (package and example
manifests stay strict in full) so the lane's sqlite driver can exist at
dev time.
The browser's swap requests no longer throw Illegal invocation
- Every swap route call (
/swaps,/swaps/quote,/swaps/status,
/swaps/refunds) invoked the caller's fetch as a method of its options
object, rebindingthis— andwindow.fetch, the default, throws
Illegal invocationfor anythisbut the window. The swap flow was
broken in real browsers across all four wrappers; the e2e swap specs catch
it now. Checkout and status requests always bare-called a local and were
unaffected.
Cross-site requests are refused by the handler; the Rails engine inherits protect_from_forgery
- Every body-bearing route now answers
415for a body that is not
application/jsonand403for a request the browser labels
Sec-Fetch-Site: cross-site, in both engines and beforeauthorizeruns. A
cross-site form cannot set a JSON content type, a cross-originfetchthat
does is CORS-preflighted (never answered), and theSec-Fetch-Sitegate
covers the remainingno-corsforgery. Golden vectors11and12pin
both refusals; vectors may now declare extra requestheaders. OpenReceive::ApplicationControllerno longer calls
skip_forgery_protection: the host'sprotect_from_forgeryapplies to the
engine's routes as it does to the host's own, and a failed check is the
shared403instead of an opaque500. The browser client sends
X-CSRF-Tokenfrom<meta name="csrf-token">on every request when the
page renders one (csrf_meta_tags), from one sharedrequestHeaders
helper; a hostheadersvalue still wins. The Rails demo drops its
:null_sessionoverride and runs Rails' default.
reference, not order_id: the host's order is not part of the story
- The grouping key OpenReceive stores is now called
referenceeverywhere —
theopenreceive_paymentscolumn, every HTTP request and response body,
the browser snapshots, the<Checkout reference>prop (route-reference
for the element wrappers),AuthorizeResource.reference, and the
settlement passed toonPaid/config.on_paid(PaymentSettlement,
formerlyOrderSettlement). It is a string the host chooses — its order
id, one per payable thing and never reused — that OpenReceive groups
attempts under and fulfills at most once. Rails hosts recreate the engine tables
(bin/rails db:reseton a development database). loadOrder+amountForOrdercollapsed into one hook:amountFor(reference)
returns the trusted price ornullfor a 404 (Rails:config.amount_for,
nil). The host is consulted only where a price is minted or quoted; status
polls and refunds are answered from OpenReceive's own rows. This also fixes
the Rails engine selecting attempts by the loaded object's.idrather than
the id it was given, which broke hosts whose lookup key was not their
primary key.openreceive:installno longer takes--order-model; the generated
initializer names no model.authorizedefaults to possession of the
reference andconfig.amount_foris left for you to write (the engine
refuses to serve checkouts until it is set).- The fulfillment note rendered into every scaffolded migration, the Rails
initializer, and the wiring guide now says only what OpenReceive guarantees
aboutonPaidand what the host must guarantee. Its "optional foreign key"
recipe is gone, as is every "OpenReceive never reads, locks, or joins your
order table" paragraph in the docs and source — there is no relationship to
explain.npx openreceive scaffold paymentsrejects the long-removed
--order-model/--order-table/--order-id-type/--skip-foreign-keyflags
as plain unknown options.
One subpath under the UI: @openreceive/browser/headless
@openreceive/browser/internalis gone. It was public API with a
discouraging name — 130 values and 46 types that@openreceive/react,
@openreceive/elements, and the wrappers imported and nothing documented.
Those names now live on./headless, the one curated, documented surface
the renderers and headless integrations share; 15 names only tests used are
no longer exported.npm run check:example-importshad nothing left to
reject and is removed.docs/internal/display-boundary-findings.mdwas deleted. Its
display-boundary rule (formatters throw, display boundaries blank) is
superseded by the AGENTS.md trust model: our own server, the configured NWC
wallet, and the configured swap provider are trusted, and a throw in a
checkout panel is our own API surfacing, not a display-boundary bug class to
defend against.
The OpenReceive prefix is gone; the import path is the namespace
- 415 exported and internal names drop the
OpenReceive/openReceive
prefix:createOpenReceiveHttpHandler→createHttpHandler,
createOpenReceiveHost→createHost,createOpenReceiveStack→
createStack,OpenReceiveHttpError→HttpError,
OpenReceiveServiceError→ServiceError,OpenReceiveDecimalError→
DecimalError,openReceiveCheckoutLabels→checkoutLabels,
formatOpenReceiveMsats→formatMsats,OpenReceiveWire*→Wire*, and
so on, one rule throughout. Kept, each for a reason:OPENRECEIVE_*
constants (they read as config keys) and the custom-element tag names;
OpenReceive,createOpenReceive, andOpenReceiveError(OpenReceive is
the noun there, andErroris taken); the three mountsopenReceiveExpress,
openReceiveFastify,openReceiveNextHandlers; andmarkOpenReceivePaidOnce
/createOpenReceiveCheckoutElementAttributes, which sit beside an
unprefixed sibling that means something else. Where the bare name was
already taken the rename picks a clearer one:openReceiveRoutes→
checkoutRoutes,reconcileOpenReceivePayments→reconcileHostPayments,
openReceiveClientIp→resolveClientIp. statusisderiveStatus(its only in-tree caller already renamed it on
import), andOpenReceiveFiatAmount— a currency-tagged decimal that is
fiat OR BTC/SAT — isMoneyAmount.
Fewer exports: what nothing outside the package reaches for
- 51 names leave the public surface because no guide, example, smoke check,
or other package named them — only tests, which now read the source
modules directly.@openreceive/nodelists its public types explicitly
instead ofexport type *, so its service-internal types
(NodeOptions,OpenReceiveServiceContext,ResolvedCreateAmount,
NormalizedCreateCheckoutRequest) stay internal. Gone, by package:- core: the price-feed tuning constants (
OPENRECEIVE_PRICE_FEED_*,
OPENRECEIVE_*_PRICE_FEED_URL,OPENRECEIVE_INVOICE_QUOTE_TTL_SECONDS,
OPENRECEIVE_STATIC_BTC_FIAT_RATES) —spec/data/rates/price-sources.json
is their contract; the*_URL_ENVnames stay. - node:
normalizeNwcWalletError,summarizeWalletCapabilities, the LSC
env helpers (parseLscUri,readLscConnectionsFromEnvironment,
createLscSwapProvidersFromEnvironment,LSC_ENV_NAMES,
LSC_URI_PROTOCOL), the log-level helpers and
createOpenReceiveConsoleLogger,requireNwcFromEnvironment,
SPEND_CAPABILITY_WARNING_DELAY_MS,isOpenReceiveSwapTerminalState. - http:
resolveSqlAdapter,openReceivePaymentInsert,
openReceiveClientIpBucket,OPENRECEIVE_DEFAULT_IP_RATE_LIMIT_PER_HOUR,
OPENRECEIVE_ATTEMPT_REUSE_BUFFER_SECONDS. - browser: the console-logger level helpers and
createOpenReceiveBrowserConsoleLogger. - elements:
renderCheckoutCreatingHtml,renderOpenReceivePaymentWizardHtml,
wireTransactionDetailsCopy,OpenReceiveElementsSwapOption. - react:
useCheckoutResumeand its option/result types (unused,
undocumented);CheckoutEventHandlers. - provider-data:
listCryptoRoutes,getCryptoRoute,
listDisqualifiedProviders(no caller anywhere).
- core: the price-feed tuning constants (
checkPayment is gone; reconcilePayments is the only wallet-history read
service.checkPayment,@openreceive/core'scheckPayment, and Ruby
Service#check_paymentwere a one-hash wrapper around the same
list_transactionswalkreconcilePaymentsalready runs. The mounted
POST /payments/checkroute never called them. Check one invoice with
reconcilePayments({ attempts: [{ paymentHash, createdAt }] }). A truncated
walk omits that hash (retry next pass) instead of throwing
WALLET_UNAVAILABLE.
The all-in-one options say which mode they are
createOpenReceiveStackand the adapters' all-in-one form take
wallet: { nwc } | { service }andstorage: { db, onPaid, tableName? } | { payments, onPaid }instead of five optional, mutually-constrained
top-level keys.onPaid's parameter type follows the storage branch, the
"exactly one of nwc or service" runtime check is gone (the type says so),
and the cast that once landed a custom repository in db mode is gone with
it.
The spec's own response shapes are closed
PaymentMethod,FiatQuote, andPaymentDetailsare named, closed
component schemas;PrepareCheckoutResponse,Checkout, and
PaymentCheckreference them instead ofadditionalProperties: true.
SwapandSwapCheckoutcompose an openSwapBaseand close themselves
withunevaluatedProperties: false(OpenAPI 3.1 is JSON Schema 2020-12),
replacing the hand-copied field list. The generated wire types follow
(OpenReceiveWirePaymentMethod,OpenReceiveWireFiatQuote,
OpenReceiveWirePaymentDetails; every wire type is now closed).
One status vocabulary; one error vocabulary per layer
TransactionSettlementStatus(pending | settled | expired | failed) is
the base every status extends:PaymentStatusaddsnot_found,
OpenReceiveAttemptStatusaddsattention, the browser'sStatusis
exactly the base. The relationship is now in the types, not only the prose.
TransactionSettlementDetectionis readonly like everything else.- The host's
authorize()returningfalseis403 FORBIDDEN(was
UNAUTHORIZED, which in NIP-47 means the key has no wallet). The HTTP
error vocabulary dropsINSUFFICIENT_BALANCEandPAYMENT_FAILED— a
receive-only library can never send them — and a wallet's ownFORBIDDEN
still normalizes toRESTRICTED. Both engines and the vectors move
together; Ruby'sUnauthorizedErrorisForbiddenError.
Maintenance
-
swap.providersisswap.provider+swap.failoverProviders: the name
now says what the code always did — failovers are consulted only when the
primary throws, never to fill assets it omits. -
prepareCheckouttakesPrepareCheckoutOptions(order id, prefix, fetch,
headers): the type no longer accepts amemoit silently dropped. -
The spend-capability warning no longer pauses boot (the 5-second delay
defaults to 0; nothing in the repo constructs a wallet client from a
terminal, so there was no CLI site to keep it). The stack's boot-failure
line logs the error message, not the object and its raw cause. -
@openreceive/httpdescribes itself as framework-neutral over Web
Request/Response and built on the Node runtime.openReceivePaymentsIndexName
measures identifiers with.length(they are ASCII by construction) and
keeps the digest that makes truncated names unique. -
fixedfloat.ts(1,134 lines, nine jobs) is six modules along the seams
its siblings already used — transport, currencies, orders, quote, field
readers, and the provider assembly. Move-only: the conformance vectors
prove it. -
The hand-rolled Keccak-256 in the Ruby gem now has known-answer tests
(NIST/Keccak digests and the EIP-55 specification addresses).
openreceive is the CLI; the library is @openreceive/*
- The unscoped
openreceivepackage no longer re-exports the library. It
ships theopenreceivecommand only (npx openreceive scaffold payments,
npx openreceive doctor), forwarding to@openreceive/node/cli. Its 23
openreceive/*subpaths are gone: import the scoped package you installed
(@openreceive/express,@openreceive/react, …). One package per install,
one package per import, and 646 fewer symbols in the public-API snapshot.
Compatibility ranges are ranges that run
@getalby/sdk^8(was^7; v8's one breaking change is requiring Node
22, already this repo's floor).@openreceive/nextdeclares
next ^14 || ^15 || ^16(13 dropped; the adapter uses Web
Request/Response only).openreceive-railsrequires Rails>= 8.0(7.1
and 7.2 are past security support and were never run here; 8.1 is what CI
runs).
Wallet preflight proves receive-only from the connection's own list
- Receive-only is proved from NIP-47
get_info.methods— what this
connection may call — rather than the kind-13194 info event, which
advertises the wallet service at large. A receive-only connection on a
service that also serves spend-capable apps now boots; a connection whose
own list carriespay_invoiceis still refused. The event still supplies
encryption modes, and stands in for the method list only when the client
exposes noget_info(logged asnwc.info_event.methods_fallback). Ruby
already readget_infofirst; both engines now agree. AlbyNwcReceiveClient.close()waits for an in-flight client construction,
closes the relay client exactly once, and makes later calls reject.
Naming: camelCase TypeScript, Checkout everywhere
- Server-side TypeScript surfaces are all camelCase now: the
authorize
resource carriesorderId/paymentHash, and the rate quote carries
btcFiatPrice/amountSats/amountMsats/asOf/expiresAt. The wire
itself stays snake_case. - The minted invoice is
Checkoutat every layer: the service typeCheckout
(wasCheckoutInvoice), the generated wire body typeWireCheckout(was
OpenReceiveWireCheckout; from the OpenAPI document, shipped by
@openreceive/http), and the browser's client-held snapshot type
CheckoutSnapshot. - The advanced rate-limit hook option is
rateLimitHook(wasrateLimit), so
it reads as what it is and composes with the booleanrateLimiting.
onPaid in both host modes (onSettlement removed)
- The settlement hook is
onPaidin BOTH host modes;onSettlementno longer
exists. db mode receivesPaymentSettlement(was
OpenReceiveOrderSettlement:reference, wasorderId, plus the
transactionalquery); custom-repository mode receivesSettlementEvent
(wasOpenReceiveSettlementEvent:paymentHash/paidAt/details), with
write-once still enforced by the library.
Curated exports and the public-api gate
@openreceive/express,@openreceive/fastify, and@openreceive/next
re-export only the curated@openreceive/httpsurface: handler/stack
factories, the error surface, the notification worker, and the
options/context/hook types. The generatedWire*body types (was
OpenReceiveWire*) and the host-integration internals — the SQL payment
repository, the reconcile gate,createHost(wascreateOpenReceiveHost),
the rate-limit helpers — live only on@openreceive/http(and
openreceive/http).- The UI wrappers export only the wrapper factories plus props/theme types,
and@openreceive/coreno longer exports internal formatting helpers
(satsToFiatValue,formatBtcFromSats, …). - A new
npm run check:public-apigate pins every public surface in CI. trustProxyIpHeader(opt-in proxy-set client-IP header forrateLimiting)
now exists on all three adapters.
Scan topology
- Every scan entry point — the opportunistic request-path pass, the
notification worker's periodic pass, andstartReconciler(was
startOpenReceiveReconciler) — claims the durableopenreceive_meta
reconcile gate, so all of them share the one NWC scan budget.
UnauthenticatedGET /ratesnever triggers a scan. payments/checkservespayment_methodsfrom a 60-second per-amount warm
cache instead of one provider call per poll.- Superseded rows are excluded from live-attempt matching, and the 409 create
conflict no longer leaks the live/supersede vocabulary on the wire ("An
unpaid checkout for this payment method is already in progress for this
order.").
prefix is the only URL the browser takes
prefix— the base path the shipped router is mounted at — is now the
single URL input of@openreceive/browser,@openreceive/react,
@openreceive/elementsand the Vue/Svelte/Angular wrappers. All seven
routes are derived from it (/checkouts,/checkouts/prepare,
/payments/check,/swaps,/swaps/quote,/swaps/status,
/swaps/refunds), so create and settle can no longer point at different
mounts.- Removed:
checkoutUrl(both the string and the(orderId) => string
callback) onprepareCheckout/requestCheckout— passprefix, which is
now required, not optional. - Removed:
{orderId}/{order_id}templating in checkout URLs. The order
id travels in the request body, as it already did for every other route. - Removed: the
orderUrlprop (React<Checkout>,useCheckout,
PaymentWizard) and the matchingorder-urlelement attribute — pass
prefixinstead. - Removed:
orderUrl={false}as the polling switch. Usepolling={false}
(polling="false"on the element), which was already the documented knob.
Behaviour note:orderUrl={false}also cut the payment wizard off from
/swaps*, so it silently disabled swaps;polling={false}stops status
polling only and leaves the swap flow working.
Frontend
- The fiat/country wing and the crypto method tile are removed: the payment
method union is"bitcoin", and the swap flow is unchanged behind it. @openreceive/elementsand@openreceive/reactship self-contained
compiledstyles.cssfiles — a plain<link rel="stylesheet">works.- React snapshot mode polls through the default
/openreceiveprefix like
create mode;pollingandpoll-interval-ms(pollIntervalMs) knobs exist
on the element and every wrapper.
Schema
openreceive_paymentsgains a locally clockedinserted_atcolumn and
CHECK constraints, and the install migrations seed the shared
schema_versionrow inopenreceive_meta. The per-IP rate-limit budget
counts oninserted_atwith a(client_ip, inserted_at)index in both
engines (vector:rate-limit-window.json).
Ruby engine parity
- Truncation-safe reconcile: a wallet-history walk cut short (page cap, pass
deadline, or a wallet that ignoresoffset) omits undecided hashes instead
of reportingnot_found, so a truncated scan can never close a paid attempt
— pinned by the new cross-languagewallet-scan-truncation.jsonvector
family. Each pass takes the oldest 200 pending attempts
(RECONCILE_BATCH_SIZE). - Schema-version refusal: the engine refuses to operate a database whose
storedschema_versionis newer than the library. - The generated Rails migration supports MySQL alongside PostgreSQL and
SQLite. - Production boot builds the service (and its wallet preflight) eagerly, so a
bad deploy fails closed instead of surfacing checkout-time 500s. The
initializer template defaultsconfig.on_paidto
OpenReceive::LOGGING_ON_PAID, and the engine warns at every boot until it
is replaced. rake testworks from each gem directory, and the Ruby suites use glob
test discovery.
CI
- Per-push
rails-examplejob;check:public-apiruns per push; wrapper type
checks (vue-tsc,svelte-check) and real wrapper mount tests.
Release
npm run release:gem:buildworks on prerelease versions. RubyGems rewrites
an npm-style prerelease throughGem::Version(0.2.0-alpha.0becomes
0.2.0.pre.alpha.0), so the release script no longer guesses the artifact
filename from the workspace version — it normalizes through Ruby, builds
straight into the output directory withgem build --output, and compares
that same normalized version against rubygems.org when checking whether a
version is already published.