Skip to content

OpenReceive v0.2.1

Choose a tag to compare

@OpenReceive OpenReceive released this 24 Aug 19:51
· 115 commits to master since this release

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_received now 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 reports settled with no paidAt is 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 unauthenticated GET /rates from the pass,
    matching the JS handler.
  • Wallet scans. A list_transactions page 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-hex payment_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 own expires_at and logs
    checkout.invoice_expiry.adjusted. A caller-supplied expirySeconds (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/status and
    /swaps/refunds map 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
    report provider_unreachable, not provider_unconfigured. A 429 backoff no
    longer resets when the weight window rolls.
  • Checkout UI. startSwap now 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_nonce and
    refund_nonce_expires_at are gone from the browser wire types (the server
    never sent them), and SwapDisplayModel carries refundAllowed: 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/browser exports createLightningUri / createQrSvg /
    createQrPngDataUrl under those names on both entry points, and no longer
    exports the internal readJsonResponse. The adapter packages no longer
    re-export the 14 generated Wire* body types (still on
    @openreceive/http). Ad-hoc app-route console loggers are
    createAppConsoleLogger / createAppBrowserConsoleLogger, so Host names
    only the persistence object.
  • New. createStack takes onBootFailure, and a failed boot answers
    503 WALLET_UNAVAILABLE in the error contract instead of rethrowing the raw
    cause. payInAssetNetwork in @openreceive/core owns the
    pay_in_asset → network split that four call sites re-derived.

ORM handles wrap in one call: knexDb, prismaDb, typeOrmDb, sequelizeDb

  • @openreceive/http ships a named SqlAdapter factory per ORM whose handle
    createSqlPayments cannot accept directly. The parameter types are
    structural (no ORM dependency); dialect stays a required argument. The
    guide's copy-paste recipes are gone — and the shipped prismaDb fixes a bug
    the Prisma recipe carried: only ^select statements ran through
    $queryRawUnsafe, so an UPDATE … RETURNING fulfillment claim (the
    guide's own onPaid example) lost its rows and never fulfilled.
  • sequelizeDb closes the last gap: Sequelize was a first-class scaffold flag
    whose only documented wiring was "open a second pg Pool to the same
    database, or hand-roll an adapter". It binds parameters through Sequelize's
    bind option 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:orms proves 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. Weekly orm-adapters job in demos.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, rebinding this — and window.fetch, the default, throws
    Illegal invocation for any this but 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 415 for a body that is not
    application/json and 403 for a request the browser labels
    Sec-Fetch-Site: cross-site, in both engines and before authorize runs. A
    cross-site form cannot set a JSON content type, a cross-origin fetch that
    does is CORS-preflighted (never answered), and the Sec-Fetch-Site gate
    covers the remaining no-cors forgery. Golden vectors 11 and 12 pin
    both refusals; vectors may now declare extra request headers.
  • OpenReceive::ApplicationController no longer calls
    skip_forgery_protection: the host's protect_from_forgery applies to the
    engine's routes as it does to the host's own, and a failed check is the
    shared 403 instead of an opaque 500. The browser client sends
    X-CSRF-Token from <meta name="csrf-token"> on every request when the
    page renders one (csrf_meta_tags), from one shared requestHeaders
    helper; a host headers value still wins. The Rails demo drops its
    :null_session override 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 reference everywhere —
    the openreceive_payments column, 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 to onPaid / config.on_paid (PaymentSettlement,
    formerly OrderSettlement). 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:reset on a development database).
  • loadOrder + amountForOrder collapsed into one hook: amountFor(reference)
    returns the trusted price or null for 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 .id rather than
    the id it was given, which broke hosts whose lookup key was not their
    primary key.
  • openreceive:install no longer takes --order-model; the generated
    initializer names no model. authorize defaults to possession of the
    reference and config.amount_for is 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
    about onPaid and 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 payments rejects the long-removed
    --order-model/--order-table/--order-id-type/--skip-foreign-key flags
    as plain unknown options.

One subpath under the UI: @openreceive/browser/headless

  • @openreceive/browser/internal is 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-imports had nothing left to
    reject and is removed.
  • docs/internal/display-boundary-findings.md was 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: createOpenReceiveHttpHandlercreateHttpHandler,
    createOpenReceiveHostcreateHost, createOpenReceiveStack
    createStack, OpenReceiveHttpErrorHttpError,
    OpenReceiveServiceErrorServiceError, OpenReceiveDecimalError
    DecimalError, openReceiveCheckoutLabelscheckoutLabels,
    formatOpenReceiveMsatsformatMsats, 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, and OpenReceiveError (OpenReceive is
    the noun there, and Error is taken); the three mounts openReceiveExpress,
    openReceiveFastify, openReceiveNextHandlers; and markOpenReceivePaidOnce
    / 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, reconcileOpenReceivePaymentsreconcileHostPayments,
    openReceiveClientIpresolveClientIp.
  • status is deriveStatus (its only in-tree caller already renamed it on
    import), and OpenReceiveFiatAmount — a currency-tagged decimal that is
    fiat OR BTC/SAT — is MoneyAmount.

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/node lists its public types explicitly
    instead of export 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_ENV names 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: useCheckoutResume and its option/result types (unused,
      undocumented); CheckoutEventHandlers.
    • provider-data: listCryptoRoutes, getCryptoRoute,
      listDisqualifiedProviders (no caller anywhere).

checkPayment is gone; reconcilePayments is the only wallet-history read

  • service.checkPayment, @openreceive/core's checkPayment, and Ruby
    Service#check_payment were a one-hash wrapper around the same
    list_transactions walk reconcilePayments already runs. The mounted
    POST /payments/check route 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

  • createOpenReceiveStack and the adapters' all-in-one form take
    wallet: { nwc } | { service } and storage: { 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, and PaymentDetails are named, closed
    component schemas; PrepareCheckoutResponse, Checkout, and
    PaymentCheck reference them instead of additionalProperties: true.
    Swap and SwapCheckout compose an open SwapBase and close themselves
    with unevaluatedProperties: 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: PaymentStatus adds not_found,
    OpenReceiveAttemptStatus adds attention, the browser's Status is
    exactly the base. The relationship is now in the types, not only the prose.
    TransactionSettlementDetection is readonly like everything else.
  • The host's authorize() returning false is 403 FORBIDDEN (was
    UNAUTHORIZED, which in NIP-47 means the key has no wallet). The HTTP
    error vocabulary drops INSUFFICIENT_BALANCE and PAYMENT_FAILED — a
    receive-only library can never send them — and a wallet's own FORBIDDEN
    still normalizes to RESTRICTED. Both engines and the vectors move
    together; Ruby's UnauthorizedError is ForbiddenError.

Maintenance

  • swap.providers is swap.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.

  • prepareCheckout takes PrepareCheckoutOptions (order id, prefix, fetch,
    headers): the type no longer accepts a memo it 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/http describes 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 openreceive package no longer re-exports the library. It
    ships the openreceive command 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/next declares
    next ^14 || ^15 || ^16 (13 dropped; the adapter uses Web
    Request/Response only). openreceive-rails requires 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 carries pay_invoice is still refused. The event still supplies
    encryption modes, and stands in for the method list only when the client
    exposes no get_info (logged as nwc.info_event.methods_fallback). Ruby
    already read get_info first; 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 carries orderId/paymentHash, and the rate quote carries
    btcFiatPrice/amountSats/amountMsats/asOf/expiresAt. The wire
    itself stays snake_case.
  • The minted invoice is Checkout at every layer: the service type Checkout
    (was CheckoutInvoice), the generated wire body type WireCheckout (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 (was rateLimit), so
    it reads as what it is and composes with the boolean rateLimiting.

onPaid in both host modes (onSettlement removed)

  • The settlement hook is onPaid in BOTH host modes; onSettlement no longer
    exists. db mode receives PaymentSettlement (was
    OpenReceiveOrderSettlement: reference, was orderId, plus the
    transactional query); custom-repository mode receives SettlementEvent
    (was OpenReceiveSettlementEvent: 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/http surface: handler/stack
    factories, the error surface, the notification worker, and the
    options/context/hook types. The generated Wire* body types (was
    OpenReceiveWire*) and the host-integration internals — the SQL payment
    repository, the reconcile gate, createHost (was createOpenReceiveHost),
    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/core no longer exports internal formatting helpers
    (satsToFiatValue, formatBtcFromSats, …).
  • A new npm run check:public-api gate pins every public surface in CI.
  • trustProxyIpHeader (opt-in proxy-set client-IP header for rateLimiting)
    now exists on all three adapters.

Scan topology

  • Every scan entry point — the opportunistic request-path pass, the
    notification worker's periodic pass, and startReconciler (was
    startOpenReceiveReconciler) — claims the durable openreceive_meta
    reconcile gate, so all of them share the one NWC scan budget.
    Unauthenticated GET /rates never triggers a scan.
  • payments/check serves payment_methods from 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/elements and 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) on prepareCheckout/requestCheckout — pass prefix, 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 orderUrl prop (React <Checkout>, useCheckout,
    PaymentWizard) and the matching order-url element attribute — pass
    prefix instead.
  • Removed: orderUrl={false} as the polling switch. Use polling={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/elements and @openreceive/react ship self-contained
    compiled styles.css files — a plain <link rel="stylesheet"> works.
  • React snapshot mode polls through the default /openreceive prefix like
    create mode; polling and poll-interval-ms (pollIntervalMs) knobs exist
    on the element and every wrapper.

Schema

  • openreceive_payments gains a locally clocked inserted_at column and
    CHECK constraints, and the install migrations seed the shared
    schema_version row in openreceive_meta. The per-IP rate-limit budget
    counts on inserted_at with 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 ignores offset) omits undecided hashes instead
    of reporting not_found, so a truncated scan can never close a paid attempt
    — pinned by the new cross-language wallet-scan-truncation.json vector
    family. Each pass takes the oldest 200 pending attempts
    (RECONCILE_BATCH_SIZE).
  • Schema-version refusal: the engine refuses to operate a database whose
    stored schema_version is 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 defaults config.on_paid to
    OpenReceive::LOGGING_ON_PAID, and the engine warns at every boot until it
    is replaced.
  • rake test works from each gem directory, and the Ruby suites use glob
    test discovery.

CI

  • Per-push rails-example job; check:public-api runs per push; wrapper type
    checks (vue-tsc, svelte-check) and real wrapper mount tests.

Release

  • npm run release:gem:build works on prerelease versions. RubyGems rewrites
    an npm-style prerelease through Gem::Version (0.2.0-alpha.0 becomes
    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 with gem build --output, and compares
    that same normalized version against rubygems.org when checking whether a
    version is already published.