Skip to content

Latest commit

 

History

History
277 lines (220 loc) · 13.8 KB

File metadata and controls

277 lines (220 loc) · 13.8 KB

Architecture

How the DigiDollar Toolkit works, for a developer who wants to accept DigiDollar (DD) and DigiByte (DGB) without a hosted payment gateway.

New to DigiDollar? Start with WHAT_IS_DIGIDOLLAR.md, then come back here. Ready to run a node? Jump to the operator playbook, guides/ACCEPTING_DIGIDOLLAR_AND_DGB.md.

DigiDollar is exact USD on-chain: 1 DD = $1.00, by definition. No oracle, no rate-lock, no volatility window. That single fact — plus a handful of DigiByte Core protocol realities — shapes every layer of this toolkit. This document walks through the mental model, the two ways you can wire it up, the pieces of the library, and the exact sequence a payment travels through.


1. The mental model: brand · node · backend · browser

A DigiDollar checkout involves four cooperating parts. Keeping them separate is the whole design.

flowchart LR
  subgraph Customer
    B["Browser<br/>checkout page"]
  end
  subgraph You["Your infrastructure"]
    direction TB
    BR["Brand / storefront<br/>(the product you sell)"]
    BE["Backend<br/>(edge functions / API + DB)"]
    N["DigiByte Core node<br/>(hot DD wallet, localhost RPC)"]
  end

  B -- "create invoice" --> BE
  BE -- "invoice + DD address + amount" --> B
  B -- "pays on-chain (wallet)" --> N
  N -- "detects DD, POST /ingest (shared secret)" --> BE
  N -- "refills address pool (cron)" --> BE
  BE -- "credits membership / order" --> BR
Loading
  • Brand — your storefront and the thing a customer is buying (for the real-world example, BlockIndex.AI's $350 lifetime membership). The brand doesn't know or care about UTXOs; it just wants a "paid / not paid" signal.
  • Node — a DigiByte Core node running the DigiDollar build. It holds keys, mints receive addresses, and is the only component that can authoritatively see a DigiDollar payment. It lives on a machine you control, with its RPC bound to 127.0.0.1.
  • Backend — your web-facing API (Supabase edge functions, an Express app, a Node script — anything). It creates invoices, stores their state, and exposes a secret-gated ingest endpoint the node posts confirmed payments to. The backend never holds a wallet or a private key.
  • Browser — the customer's checkout page. It asks the backend to create an invoice, shows the address + amount, and polls for status until it flips to paid.

The important boundary: keys and chain-watching live with the node; web traffic lives with the backend; the two meet only at a small, authenticated ingest contract. The backend can be fully public and hold no secrets that could move money.


2. Two integration models: PUSH and PULL

The toolkit supports two topologies. They differ only in who initiates the detection handoff. Both post to the same ingest contract and preserve the same invariants.

PUSH (recommended) — the node notifies your backend

Your node runs DigiByte Core's walletnotify and blocknotify hooks. On each wallet transaction, a walletnotify script resolves the payment and POSTs it to your backend's ingest endpoint. On each block, a blocknotify script re-reports open payments so their confirmation count climbs. No watcher, wallet, or RPC client ever runs on the backend.

Reference implementation: guides/walletnotify-reference/ (walletnotify.mjs, blocknotify.mjs, refill-dd-pool.mjs, .env.example).

One wrinkle DigiDollar forces: walletnotify is not guaranteed to fire on a DigiDollar receive, because the value lives in an OP_RETURN and the output is 0-satoshi. So the authoritative DigiDollar path is blocknotify → poll listdigidollarunspent, not walletnotify alone.

PULL — a co-located watcher polls, then posts

If you'd rather not push from the node, run a small worker next to the node that polls listdigidollarunspent (and explorers for BTC/LTC) on a timer, reconciles each open invoice, and posts confirmed payments to the same ingest endpoint.

Reference implementation: guides/watcher-reference/ (watcher.mjs).

Push is preferred — it needs no always-on worker and keeps the backend wallet-free — but pull is a drop-in alternative when a push hook is inconvenient.

flowchart TB
  subgraph PUSH["PUSH model"]
    n1["Core node"] -- "walletnotify / blocknotify scripts" --> i1["POST /ingest"]
  end
  subgraph PULL["PULL model"]
    w["watcher worker<br/>(polls on a timer)"] -- "reconcileInvoice() → post" --> i2["POST /ingest"]
    w -. "listdigidollarunspent" .-> n2["Core node"]
  end
Loading

3. The address-pool model: why DD addresses come from the node

For BTC, LTC, and DGB coin, you can derive a fresh receive address from a cold xpub with pure math — no node required, keys never online. DigiDollar is different, and the difference is a protocol constraint, not a choice:

  • DigiByte Core v1 has no DigiDollar watch-only. The node that detects DigiDollar must hold the keys. There is no "import this xpub and watch it" path for DD.
  • Addresses come from the node via getdigidollaraddress, which mints a fresh, HD-derived DD… (mainnet) / TD… (testnet) / RD… (regtest) address per call — Base58Check, not bech32.

But your public backend usually can't reach the private node (and shouldn't). So the node runs slightly ahead of demand:

  1. A pool-refill cron on the node calls getdigidollaraddress repeatedly and pushes the resulting address strings (never keys) to a secret-gated pool-refill endpoint on your backend.
  2. The backend stores them in an address pool table.
  3. When a customer starts a DigiDollar checkout, invoice creation pops one address from the pool and locks it to that invoice.

Because addresses are minted ahead of time and only public strings cross the wire, the node stays private and the backend never needs RPC access. Guard the refill endpoint with its own secret — a poisoned pool would redirect customer funds.

DigiDollarAddressSource (in src/address/digidollar.ts) is the library's node-facing minter; it validates the network prefix and refuses to hand back a cross-network address. The pool itself is backend state — the demo and BlockIndex both model it as a simple table/JSON of unclaimed addresses.


4. The acceptance sequence

End to end, a single DigiDollar payment travels like this:

sequenceDiagram
    autonumber
    participant Cust as Browser
    participant BE as Backend (edge fns)
    participant Pool as DD address pool
    participant Node as DigiByte Core node
    participant Brand as Membership / order

    Note over Node,Pool: cron: getdigidollaraddress → pool-refill (public strings only)
    Node->>Pool: top up unclaimed DD addresses

    Cust->>BE: POST create-invoice { priceUsdCents }
    BE->>Pool: claim one DD address
    BE->>BE: buildInvoiceTerms() — lock cents + confs + expiry
    BE-->>Cust: { address, amount (cents), confirmationsRequired }

    Cust->>Node: pay on-chain (DD in OP_RETURN, fee in DGB)
    Note over Node: blocknotify → poll listdigidollarunspent
    Node->>BE: POST /ingest { asset, network, address, txid, vout, amount, confirmations } (shared secret)
    BE->>BE: re-verify amount + address; evaluatePayment()
    loop each new block
      Node->>BE: POST /ingest (confirmations ↑)
    end
    BE->>Brand: confirmations ≥ required → credit once (idempotent)
    Cust->>BE: GET status → "paid"
Loading

In prose:

  1. Create invoice. The browser posts a USD target (priceUsdCents). The backend claims a pool address, calls buildInvoiceTerms() to lock the expected cent amount, the required confirmations (USD-tiered), and a 20-minute expiry, and returns the address + amount to show the customer.
  2. Customer pays. Their wallet sends DigiDollar to the address. The DD value rides in an OP_RETURN on a 0-satoshi Taproot output; the miner fee is paid in DGB, not DD (say so in your UI).
  3. Node detects. On the next block, the node polls listdigidollarunspent, finds the receive, and POSTs it to the backend's ingest endpoint over TLS with a shared secret.
  4. Confirmations climb. Each subsequent block re-reports the payment with a higher confirmation count.
  5. Credit once. When confirmations reach the required threshold and the amount re-verifies server-side, the backend credits the membership/order — idempotent by (asset, network, txid, vout), so replayed notifications never double-credit.

5. Library module map

The library is @blockindex/crypto-payments — pure TypeScript (@scure/@noble), ESM, no ambient clock or randomness in the core, so the decision logic is deterministic and trivially testable. It does not grant memberships, own a database, or know about any specific backend.

Everything is re-exported from src/index.ts.

Module Path Responsibility
assets src/assets.ts Asset + network specs: decimals, address prefixes, which assets are xpub-derivable, and the identityUsd flag that marks DigiDollar as exact-USD.
amounts src/amounts.ts toBaseUnits / fromBaseUnits / formatAmount — the integer base-unit (BigInt) conversions. Never floats.
engine src/engine.ts The pure payment engine: quoteAmount (USD cents → base units; identity for DD), classifyPayment (exact / underpaid / overpaid / dust), and requiredConfirmations (the USD-tiered policy). No I/O.
address/ src/address/ Receive-address sources. xpub.ts derives BTC/LTC/DGB addresses from a cold xpub (deriveXpubAddress, XpubAddressSource); digidollar.ts mints DD addresses from the node (DigiDollarAddressSource); types.ts is the shared AddressSource interface.
watch/ src/watch/ Chain watchers that turn on-chain state into DetectedPayment[]. core-digidollar.ts (DigiDollarWatcher, via listdigidollarunspent), core-dgb.ts (DgbCoinWatcher), esplora.ts + explorer-btc.ts / explorer-ltc.ts (public explorers).
price/ src/price/ PriceSource implementations. identity.ts (DigiDollar = $1, the no-op quote), node-oracle.ts (DGB/USD from the on-chain oracle), coingecko.ts (+ FallbackPriceSource), blockindex.ts.
invoice src/invoice.ts The pure invoice state machine: buildInvoiceTerms (lock amount + confs + expiry), isQuoteExpired, evaluatePayment (is this payment creditable?). Caller passes now — fully deterministic.
notify/ src/notify/core.ts The testable core of the push scripts: parseCoreReceives (parse gettransaction for BTC/LTC/DGB) and digiDollarReceivesForTxid (select DD rows from listdigidollarunspent).
digibyte-core/rpc src/digibyte-core/rpc.ts Typed DigiByte Core RPC client (DigiByteCoreRpc) and the DigiDollar RPC row types. The only component that talks to the node.
worker/reconcile src/worker/reconcile.ts reconcileInvoice(terms, payments) — drives the watcher loop, returning 'credit' | 'wait' | 'underpaid' | 'double_spend_risk' | 'none' plus a suggested invoice status.

A typical backend uses assets + engine + invoice + reconcile for decisions, address/ to mint/claim, and either watch/ (pull) or notify/ (push) to observe the chain. digibyte-core/rpc is only imported where you actually touch the node.


6. The cents contract

Every amount in the toolkit is an integer number of base units, as a BigInt:

  • DigiDollar — the base unit is the US cent. $350.00 is 35000n cents. Quoting is the identity function (quoteAmount returns BigInt(priceUsdCents)), because there is no rate to look up.
  • BTC / LTC / DGB — the base unit is the satoshi (or DGB's smallest unit). quoteAmount converts a USD target into base units with ceil division so the buyer always covers the target, and a tolerance band absorbs the sub-unit remainder.

Comparisons (classifyPayment, evaluatePayment) are pure BigInt. Floats are a money bug — a fraction of a cent silently lost or rounded the wrong way is a real loss or a wrongly-rejected invoice. The library never introduces a float into an amount path, and neither should your backend.


7. Confirmation policy

requiredConfirmations(asset, priceUsdCents) encodes a USD-tiered policy — the more money at stake, the more confirmations before crediting:

Asset < $100 $100 – $10,000 > $10,000
DigiDollar / DGB 6 (≈ 90 s) 20 (≈ 5 min) 60
BTC 1 2 2
LTC 4 6 6

buildInvoiceTerms bakes the required count into the invoice at creation time, so the threshold is fixed for the life of that invoice. evaluatePayment only marks a payment creditable once confirmations >= confirmationsRequired, the amount is exact-or-over, and there's no unconfirmed RBF risk. 0-conf and RBF-pending are never trusted — they surface as double_spend_risk until buried. The thresholds are env-overridable at the operator layer for teams with a different risk appetite.


8. Where to go next