Read DigiDollar state

Last verified against DigiByte Core v9.26.5

Read DigiDollar state

DigiDollar is DigiByte's protocol-native, over-collateralized stablecoin, and reading it needs no indexer, no API and no trusted third party: every full node recomputes the collateral behind every $DD each block, so the state is already on the machine in front of you. What makes the read side hard is not access, it is arithmetic. One getdigidollarstats response mixes cents, micro-USD, whole DGB, integer percents and block counts in a single flat object, and nothing in the field names tells you which is which. This guide walks the normalization DigiScope performs once, at the edge, and then the three decoders — address, transaction, balance — that let you read $DD for an address your wallet has never seen. Everything runs against your own node.

Who this is for. You are building a wallet, an explorer, a dashboard or a treasury report that has to show DigiDollar figures. You have, or can run, a DigiByte Core node built with the DigiDollar softfork.

What you will build. One normalizer every other component reads through, a freshness header no figure may render without, a DD-address decoder that yields the Electrum scripthash, an OP_RETURN decoder that maps amounts positionally onto zero-value taproot outputs, and a third-party balance path that knows when to abstain.

Before you start

Reaching the node over JSON-RPC

Every command in this guide is shown as digibyte-cli, which is a thin wrapper over the node's HTTP JSON-RPC endpoint. If your reader is a program — and it is — you want the endpoint directly. Put this in digibyte.conf and restart:

server=1
rpcuser=<user>
rpcpassword=<a long random string>
rpcallowip=127.0.0.1
rpcbind=127.0.0.1
txindex=1
digidollar=1

The endpoint is http://127.0.0.1:14022, HTTP basic auth with that user and password, one JSON object per POST:

curl -s --user <user>:<password> -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"getdigidollarstats","params":[]}' \
  http://127.0.0.1:14022/

Expected: {"result":{"total_dd_supply":…,"total_collateral_dgb":…},"error":null,"id":1}. Those are the raw, un-normalized fields Step 1 is about.

Keep the endpoint bound to loopback. There is no read-only RPC user in DigiByte Core: the same credentials that answer getdigidollarstats also answer sendtoaddress, so an RPC port reachable from anywhere is a wallet reachable from anywhere.

One routing detail matters for Step 5. Wallet-scoped RPCs are addressed by path, not by parameter — POST /wallet/<name> instead of POST /. The DigiDollar calls that touch wallet state — balances, addresses, positions, unspent, and every mint/send/redeem — need that path; the chain-level ones (getdigidollarstats, getdigidollardeploymentinfo, getoracleprice) answer a bare POST /. Send a wallet-scoped call to / and you either get an error or an answer about the default wallet, which is rarely the one you meant:

from backend/src/utils/rpcClient.js

    this.url = `http://${process.env.DGB_RPC_HOST || '127.0.0.1'}:${process.env.DGB_RPC_PORT || '14022'}`;
    this.auth = {
      username: process.env.DGB_RPC_USER,
      password: process.env.DGB_RPC_PASSWORD,
    };
// …
  /**
   * @param {?string} wallet  when set, the call is scoped to that loaded wallet
   *   (`/wallet/<name>`). Wallet RPCs such as getaddressinfo and gettransaction
   *   have no meaning at the node level and error without it.
   */
  async call(method, params = [], retries = this.maxRetries, wallet = null) {
// …
      const response = await axios.post(
        wallet ? `${this.url}/wallet/${encodeURIComponent(wallet)}` : this.url,
        {
          jsonrpc: '2.0',
          id: ++this.requestId,
          method,
          params,
        },
        {
          auth: this.auth,
          headers: { 'Content-Type': 'application/json' },
          timeout: 60000, // 60 second timeout for complex queries
        }

Trap: Do not reach for -rpcwallet= semantics on the HTTP endpoint; that is a digibyte-cli flag, and what it does is choose the URL path. If your client library has no notion of a wallet, append the path yourself.

Step 1 — Normalize the units before anything else

DigiDollar unit normalization: what each RPC field is actually denominated in getdigidollarstats and getoracleprice return raw integers on the wire. total_dd_supply divides by 100 to reach US dollars. oracle_price_micro_usd divides by 1e6 to reach USD per DGB. total_collateral_dgb needs no conversion — it is already DGB, not satoshis, despite the RPC help text. oracle_price_age multiplies by 15 seconds per block to reach elapsed time. system_collateral_ratio is already a percent integer. oracle_price_cents is a trap: it is always 0 and must never be used. getdigidollarstats / getoracleprice — on the wire RPC field operation actual unit total_dd_supply ÷ 100 US dollars oracle_price_micro_usd ÷ 1e6 USD per DGB total_collateral_dgb no conversion DGB (already DGB, not sats) oracle_price_age × 15s seconds since last update system_collateral_ratio no conversion percent, already an integer oracle_price_cents — always 0 — never use oracle_price_age is measured in BLOCKS on the wire, not seconds — multiply by the ~15s block interval before display. total_collateral_dgb is the one field the RPC help text mislabels: it is DGB already, never divide by 1e8.
Six fields from two RPCs, six different conventions. The conversion belongs in one function, not at each render site.

The node's DigiDollar RPCs are honest but terse, and their units are not discoverable from the field names. This is the whole table:

Field Unit on the wire Convert with What goes wrong if you don't
total_dd_supply cents ÷ 100 supply reads about 100× too large
oracle_price_micro_usd micro-USD ÷ 1e6 a six-figure price per DGB
oracle_price_cents — never use it it is 0 on a healthy node
total_collateral_dgb / total_collateral_locked already DGB nothing dividing by 1e8 buries the reserve
24h_high / 24h_low cents ÷ 100 a high/low 10,000× the spot price it sits next to
system_collateral_ratio, health_percentage percent, as an integer nothing 385 rendered as 3.85% reads as insolvency
oracle_price_age blocks, not seconds × 15 s a two-block-old price described as "2 seconds ago"

Two of those deserve to be said twice. oracle_price_micro_usd is the price of DGB, not the price of $DD — it is the oracle feed the collateral ratio is computed against, and labelling it "DigiDollar price" on a dashboard is the single most misleading thing you can do with this data. And 24h_high / 24h_low arrive in cents from getoracleprice while the spot price in the same response arrives in micro-USD: two units, one object, adjacent fields.

Do the conversion exactly once, in a pure function, and let every consumer read the normalized shape. DigiScope's normalizer takes the three raw RPC results — getdigidollarstats, getoracleprice, and getalloracleprices for the reporting-oracle counts — and returns one object in which every number is already in the unit its name claims:

from backend/src/controllers/digidollar.js

export function deriveDigiDollarStats(stats, price, oracles) {
  if (!stats) return null;
  const priceUsd = price ? (price.price_micro_usd ?? 0) / 1e6 : null;
  const collateralDgb = stats.total_collateral_dgb ?? stats.total_collateral_locked ?? 0;
  return {
    total_collateral_dgb: collateralDgb,
    collateral_usd: priceUsd != null ? collateralDgb * priceUsd : null,
    dd_supply_cents: stats.total_dd_supply ?? 0,
    dd_supply_usd: (stats.total_dd_supply ?? 0) / 100,
    collateral_ratio_pct: stats.system_collateral_ratio ?? null,
// …
    oracle: {
      price_usd: priceUsd,
      is_stale: price ? !!price.is_stale : null,
      last_update_height: price?.last_update_height ?? null,
      price_24h_high_usd: price?.['24h_high'] != null ? price['24h_high'] / 100 : null,
      price_24h_low_usd: price?.['24h_low'] != null ? price['24h_low'] / 100 : null,

Note what the function does not do. It never reads oracle_price_cents. It falls back from total_collateral_dgb to total_collateral_locked without rescaling either, because both are already DGB despite RPC help text that implies satoshis. And it carries is_stale and last_update_height out of the raw price response into the normalized oracle block, because Step 2 requires them at every render site.

Trap: oracle_price_cents is present, typed, and always 0. It reads 0 on a healthy node at the same moment oracle_price_micro_usd reads a live, non-zero price — the feed is fine, the field is not. A dashboard that prefers the "simpler" cents field therefore renders a zero DGB price, which makes every collateral ratio it derives read as catastrophic. Treat the field as reserved and never map it.

Step 2 — Adopt the freshness rule

Here is the rule, and it is not a style preference: never render a peg, supply, collateral or price figure without a visible as-of marker and a stale indicator. A block height that is five minutes behind is a cosmetic bug. A collateral ratio that is five minutes behind is a claim about solvency, and your users cannot tell the two apart by looking.

The node hands you everything you need. getoracleprice returns is_stale and last_update_height, and the normalizer in Step 1 forwards both. Bind them to the figure itself, in the same visual unit, so the number and its provenance cannot be separated by a screenshot:

from src/components/digidollar/MintCalculator.jsx

        {/* Freshness invariant: price + as-of + live/stale, always visible */}
        <div className="flex items-center gap-2 text-sm">
          {forecast ? (
            <>
              <span className="inline-block w-2 h-2 rounded-full bg-warning" />
              <span className="font-mono text-text-primary">
                {price != null ? `${(price / 1e6).toFixed(6)}` : '—'}
              </span>
              <span className="text-text-secondary">/DGB</span>
              <span className="text-xs px-1.5 py-0.5 rounded border border-warning text-warning font-semibold">MODEL</span>
            </>
          ) : params?.oracle?.price_usd != null ? (
            <>
              <span className={`inline-block w-2 h-2 rounded-full ${stale ? 'bg-warning' : 'bg-positive animate-pulse'}`} />
              <span className="font-mono text-text-primary">${params.oracle.price_usd.toFixed(6)}</span>
              <span className="text-text-secondary">/DGB</span>
              <span className={`text-xs px-1.5 py-0.5 rounded border ${stale ? 'border-warning text-warning' : 'border-border-primary text-text-secondary'}`}>
                {stale ? 'STALE' : 'LIVE'}{params?.oracle?.age != null ? ` · ${params.oracle.age} blk` : ''}
              </span>
            </>
          ) : (
            <span className="text-warning text-sm">oracle unavailable</span>
          )}

Three things are load-bearing in that markup. The indicator sits inside the same header as the price, so no crop can separate them. The state is four-way, not a boolean: MODEL when the figure comes from a forecast rather than the oracle at all, then LIVE, STALE, and absent — and the absent case prints oracle unavailable rather than a dash that could be mistaken for zero. A modelled number wearing a live badge is the same lie as a stale one, so the forecast branch gets its own colour and its own word. And the age is labelled blk, because oracle_price_age counts blocks: at roughly 15 seconds per block, an age of 2 is about 30 seconds, and printing "2s" would understate staleness by an order of magnitude the moment the feed actually lags.

Trap: Wire the two builders deliberately, because they do not emit the same fields. deriveDigiDollarStats (Step 1) gives you oracle.price_usd, oracle.is_stale and oracle.last_update_height — but no age. The params.oracle.age this component reads comes from buildMintParams, a different assembler in the same controller, which carries stats.oracle_price_age through under that name. Feed Step 1's object to this markup unchanged and the badge silently renders without its age, because undefined fails the != null guard rather than throwing. Pick one builder per surface, or forward oracle_price_age explicitly.

Security: A stale stablecoin figure is not a stale figure, it is a false solvency claim. If your oracle feed stops and your dashboard keeps rendering the last collateral ratio in the same confident type it used a minute ago, you are telling every reader that the system is backed right now — and you will keep telling them that for as long as the outage lasts. Fail loud: grey the number, show the age, and say which block it came from.

Step 3 — Decode a DD address

A DD address, byte by byte, and its Electrum scripthash A DigiDollar address is 38 raw bytes: a 2-byte prefix 0x5285, the 32-byte taproot output key, and a 4-byte checksum taken from the first four bytes of double-SHA256 of the preceding 34 bytes. Base58-encoding those 38 bytes yields a 52-character string beginning DD. Separately, the output script itself is OP_1 PUSH32 followed by the same 32-byte key, hex 5120 then the key; hashing that script with SHA256 and reversing the byte order gives the Electrum scripthash used to query balances and history for the address. DD address — 38 raw bytes 5285 prefix · 2 bytes a1b2c3…e9f0d4 (32 bytes) taproot output key 9f 3a 7c 01 checksum · 4 bytes checksum = dSHA256(prefix ‖ key)[0:4] — double SHA-256 of the first 34 bytes, first 4 bytes kept DDx9k…q7m2 (52 chars) base58(prefix ‖ key ‖ checksum), leads “DD” the output script — and the Electrum scripthash it derives 51 OP_1 20 PUSH32 a1b2c3…e9f0d4 = 5120<key> on the wire sha256 reverse bytes = scripthash The Electrum scripthash is sha256(script) with the byte order reversed — the same convention used for every other output type. The 38-byte address and the scripthash key off the same 32-byte taproot output key; they are two views of one position.
38 raw bytes in, base58 out. The same 32-byte key becomes the on-chain script and, sha256'd and reversed, the Electrum scripthash.

A DigiDollar address is 52 base58 characters beginning DD on mainnet. Underneath it is 38 raw bytes:

That is the Base58Check construction exactly — with a two-byte version instead of Bitcoin's one. Which is precisely why you cannot reach for the bs58check package already sitting in your dependencies: it strips exactly one version byte, so the second prefix byte stays glued to the front of the key and you are handed 33 bytes that are not a taproot key — and, because the checksum still verifies over the same 34-byte body, it does so without an error. Write the decode yourself; the constants are the whole specification:

from backend/src/services/dd-address.js

const MAINNET_PREFIX_HEX = '5285';
const DECODED_LENGTH = 38; // 2 prefix + 32 key + 4 checksum
const BODY_LENGTH = 34; // prefix + key

Trap: A DD address is not bech32 and not a "standard P2TR address". It shares the taproot key with one, but it is base58 with a two-byte version, and any bech32m decoder will reject it. That mistake has appeared in DigiByte tooling more than once — including in this repo, which is why the decoder carries the construction in its file header rather than in a comment somewhere downstream.

The decode itself: base58 to bytes, length check, checksum check, then slice. Note that the checksum is verified before the prefix is even looked at, so a typo never reaches the network-detection branch:

from backend/src/services/dd-address.js

export function decodeDdAddress(address) {
  if (typeof address !== 'string' || address.length === 0) {
    return { valid: false, error: 'address must be a non-empty string' };
  }
// …
  const body = decoded.subarray(0, BODY_LENGTH);
  const checksum = decoded.subarray(BODY_LENGTH, DECODED_LENGTH);
  const expectedChecksum = doubleSha256(body).subarray(0, 4);

  if (!checksum.equals(expectedChecksum)) {
    return { valid: false, error: 'checksum mismatch' };
  }

  const prefixBytes = body.subarray(0, 2);
  const prefixHex = prefixBytes.toString('hex');
  const key = body.subarray(2, BODY_LENGTH);
  const keyHex = key.toString('hex');
// …
  const script = Buffer.concat([Buffer.from([0x51, 0x20]), key]).toString('hex');
  const scriptHashBytes = crypto.createHash('sha256').update(Buffer.from(script, 'hex')).digest();
  const scripthash = Buffer.from(scriptHashBytes).reverse().toString('hex');

The last three lines are the part you actually need for chain lookups. The DD-carrying output's script is OP_1 PUSH32 <key> — 0x51 0x20 followed by the 32-byte key, which is the hex string 5120<key>. The Electrum scripthash convention is sha256(script) byte-reversed, and that reversal is not decorative: send the un-reversed digest to an ElectrumX server and it will cheerfully answer with an empty history, which reads exactly like an address that has never been used.

Network detection is where an honest decoder earns its keep. Only mainnet's prefix bytes have been verified against a node. The RPC help text names testnet (TD) and regtest (RD) prefixes, but their byte values were never confirmed, so DigiScope recognises those two by leading characters alone and reports the network as unknown rather than asserting a chain it has not checked:

from backend/src/services/dd-address.js

  let network;
  if (prefixHex === MAINNET_PREFIX_HEX) {
    network = 'mainnet';
  } else {
    // No verified byte values for testnet ("TD") / regtest ("RD") prefixes.
    // Recognize by leading characters only, and mark the network 'unknown'
    // rather than assert a specific chain we have not confirmed on a node.
    const leading = address.slice(0, 2);
    if (leading === 'TD' || leading === 'RD') {
      network = 'unknown';
    } else {
      return { valid: false, error: `unrecognized address prefix 0x${prefixHex}` };
    }
  }

Trap: Do not invent the TD / RD prefix bytes to make a decoder look complete. A guessed version byte that happens to round-trip through your own encoder will still produce addresses no node accepts, and — worse — a network: 'testnet' label on an address you have not actually identified invites someone to send mainnet value to it. unknown is the correct answer until a node says otherwise.

Step 4 — Read amounts out of a DigiDollar transaction

Anatomy of a DigiDollar OP_RETURN: mint versus transfer Both payloads open with magic 17476 (0x4444, ASCII DD). A type-1 mint carries dd_cents, unlock_height, tier and a hash, and pairs with two transaction outputs: vout 0 holds the real DGB collateral, vout 1 is a zero-value witness_v1_taproot output carrying the whole DD amount. A type-2 transfer carries a list of amounts, and amount k maps positionally to the k-th zero-value taproot output in ascending vout order — amt0 to the first such output, amt1 to the second, and so on. magic 17476 (0x4444 “DD”) every DigiDollar OP_RETURN starts with this two-byte tag type 1 — MINT 17476 · 1 · dd_cents · unlock_height · tier · hash vout 0 collateral — real DGB value vout 1 zero-value witness_v1_taproot — carries whole DD amount type 2 — TRANSFER 17476 · 2 · amt0 · amt1 · … vout k0 1st zero-value taproot output vout k1 2nd zero-value taproot output … ascending vout index other outputs (fee change, etc.) are skipped amt0 → 1st zero-value P2TR amt1 → 2nd zero-value P2TR "Zero-value" refers to the DGB amount carried by the output — the DD units live only in the OP_RETURN, positionally, never in the output's satoshi field. A closed position is observed on-chain as the mint's vout 0 collateral output being spent — not any change to vout 1.
The OP_RETURN carries the amounts; the zero-value taproot outputs carry the identities. Position is the only thing that joins them.

Every DigiDollar transaction carries an OP_RETURN whose first push is the magic number 17476 — 0x4444, "DD" in ASCII. The second push is the type:

from backend/src/services/dd-tx-decode.js

const DD_MAGIC = 17476; // 0x4444, "DD"
const DD_TYPE_MINT = 1;
const DD_TYPE_TRANSFER = 2;
const MAX_PUSH_BYTES = 6; // 2^48-1 magnitude ceiling — comfortably above any realistic cents figure, well inside Number safe-integer range

Type 1, MINT. The payload is 17476 1 <dd_cents> <unlock_height> <tier> <hash>: six pushes, all of them required. The transaction has exactly one zero-value witness_v1_taproot output, and it receives the whole dd_cents. The mint's vout 0 is the collateral output — it holds real DGB value, it is not zero-value, and it is therefore excluded from the DD mapping entirely. Collateral and balance are different quantities about different people; folding one into the other is how a reader ends up "holding" the backing for their own position.

Type 2, TRANSFER. The payload is 17476 2 <amt0> <amt1> …, and the amounts map positionally, in ascending vout index, onto the transaction's zero-value taproot outputs. Amount k belongs to the k-th such output. There is no key, no index, and no label in the payload tying an amount to a recipient — position is the entire join, which is why the decoder refuses to proceed when the two counts disagree.

Type 3 and beyond. Redemption is type 3, and its payload shape has not been verified. It is not decoded. Neither is any future type. Abstaining is the whole design: a decoder that guesses at an unverified layout does not fail visibly, it reports a plausible wrong number.

Now the part that will bite you if you take one shortcut. Parse the pushes from scriptPubKey.hex, never from scriptPubKey.asm.

from backend/src/services/dd-tx-decode.js

export function opReturnPushes(tx) {
  const outs = Array.isArray(tx?.vout) ? tx.vout : [];
  for (const vo of outs) {
    if (vo?.scriptPubKey?.type !== 'nulldata') continue;
    return parseOpReturnPushes(vo.scriptPubKey?.hex);
  }
  return null;
}

/** Ascending-index list of a tx's zero-value witness_v1_taproot outputs. */
export function zeroValueTaprootIndices(tx) {
  const outs = Array.isArray(tx?.vout) ? tx.vout : [];
  const indices = [];
  outs.forEach((v, i) => {
    if (v?.scriptPubKey?.type === 'witness_v1_taproot' && Number(v?.value) === 0) {
      indices.push(i);
    }
  });
  return indices;
}

Core's ScriptToAsmStr renders a script push of four bytes or fewer as a decimal CScriptNum and anything longer as raw hex. A dd_cents value of 2^31 or more — about $21.5 million, which is exactly the large-reserve case an auditor is looking at — needs a five-byte push, so its asm token is a hex string like 1122334455. (2^31−1 itself is 0x7FFFFFFF, still four bytes and still decimal in asm; the cliff is the next cent.) Feed that to Number() and you get either NaN, which at least abstains, or, for an all-digit hex string, a completely fabricated number that looks entirely reasonable. Walking the raw hex avoids the ambiguity: read OP_RETURN, then each standard push opcode, then decode each numeric push as an unsigned little-endian integer.

from backend/src/services/dd-tx-decode.js

export function pushToUint(buf) {
  if (!buf) return null;
  if (buf.length === 0) return 0;
  if (buf.length > MAX_PUSH_BYTES) return null;
  let n = 0;
  for (let j = buf.length - 1; j >= 0; j--) {
    n = n * 256 + buf[j];
  }
  return Number.isSafeInteger(n) ? n : null;
}

The unsigned little-endian read is exact for these fields because they are all non-negative: any sign-padding byte Core's CScriptNum serialization adds is a trailing 0x00, which contributes zero to the sum. The six-byte ceiling keeps every decoded value inside JavaScript's safe-integer range, and a longer push abstains rather than silently losing precision.

Put together, resolving the DD amount for one specific output is a sequence of refusals with an answer at the end:

from backend/src/services/dd-tx-decode.js

export function decodeDdAmountForOutput(tx, voutIndex) {
  const pushes = opReturnPushes(tx);
  if (!pushes || pushes.length < 2) return { ok: false };

  const magic = pushToUint(pushes[0]);
  if (magic !== DD_MAGIC) return { ok: false };

  const type = pushToUint(pushes[1]);

  let amounts;
  if (type === DD_TYPE_MINT) {
    // OP_RETURN 17476 1 <dd_cents> <unlock_height> <tier> <hash> — full
    // 6-push layout required; a truncated payload abstains.
    if (pushes.length !== 6) return { ok: false };
    const cents = pushToUint(pushes[2]);
    if (cents === null) return { ok: false };
    amounts = [cents];
  } else if (type === DD_TYPE_TRANSFER) {
    // OP_RETURN 17476 2 <amt0> <amt1> ... — positional, ascending vout index
    const amountPushes = pushes.slice(2);
    if (amountPushes.length === 0) return { ok: false };
    amounts = amountPushes.map(pushToUint);
    if (amounts.some((a) => a === null)) return { ok: false };
  } else {
    // Redemption (3) and any other/future type are not decoded — abstain
    // rather than assume a payload shape that hasn't been verified.
    return { ok: false };
  }

  if (!amounts.every((a) => Number.isInteger(a) && a >= 0)) return { ok: false };

  const indices = zeroValueTaprootIndices(tx);
  if (amounts.length !== indices.length) return { ok: false };

  const position = indices.indexOf(voutIndex);
  if (position === -1) return { ok: false };

  return { ok: true, ddCents: amounts[position] };
}

Trap: Count the amounts against the zero-value taproot outputs and abstain when they disagree. That single check is what keeps a positional mapping honest: if a transaction shape you have not seen puts an extra zero-value taproot output in the middle, every amount after it shifts by one, and without the count check your decoder will confidently attribute one holder's balance to another.

Step 5 — Balances for an address you do not own

There is no chain-wide DigiDollar balance RPC. Every DD RPC that reports holdings is wallet-scoped — the /wallet/<name> path from the transport section — and importdigidollaraddress explicitly does not change wallet state in V1, so there is no "watch this address" path that makes those calls answer for a stranger's address. This is the single most surprising thing about the read side, and the most important to get right.

The consequence has teeth: getdigidollarbalance against an address your wallet does not own returns 0 — with address_count: 0 alongside it. That zero means "not mine", not "empty". Render it as a balance and you have published a false statement about someone else's holdings. Check address_count before you believe any figure that call returns.

The only chain-wide path is the one you built in Steps 3 and 4: take the scripthash from the decoded address, ask ElectrumX for history and unspent outputs, fetch each funding transaction from your node, and decode the DD amount for each unspent output positionally.

Connecting. ElectrumX is not HTTP. It speaks newline-delimited JSON-RPC over a raw TCP socket — plaintext on port 50001, TLS on 50002 — one JSON object per line, one line per response. DigiScope's server runs on loopback, and any TCP client will do:

printf '{"id":1,"method":"server.version","params":["dd-reader","1.4"]}\n' \
  | nc 127.0.0.1 50001

The two calls. Both take the byte-reversed scripthash from Step 3 as their only parameter:

printf '{"id":1,"method":"blockchain.scripthash.listunspent","params":["<scripthash>"]}\n' \
  | nc 127.0.0.1 50001

tx_hash and tx_pos are exactly the two arguments Step 4's decoder wants: decodeDdAmountForOutput(tx, voutIndex) where tx is getrawtransaction tx_hash 2 from your own node and voutIndex is tx_pos. That is the whole join between the two services.

DigiScope's wrapper names both methods and pins their return shapes in its own contract, so a client-library change cannot quietly alter them:

from backend/src/services/electrum-service.js

   * Returns `[{ tx_hash, height }]`, unsorted (caller's responsibility to
   * sort/limit), matching the raw ElectrumX response shape.
   */
  async getHistoryByScripthash(scripthash) {
// …
      return await this._withRetry(async (client) => {
        return client.blockchainScripthash_getHistory(scripthash);
      }, 'getHistoryByScripthash');
// …
   * Returns raw ElectrumX shape: `[{ tx_hash, tx_pos, height, value }]`
   * (value in satoshis, NOT converted to DGB — callers that need DGB-value
   * semantics should use the address-based `listUnspent`).
   */
  async listUnspentByScripthash(scripthash) {
// …
      return await this._withRetry(async (client) => {
        return client.blockchainScripthash_listunspent(scripthash);
      }, 'listUnspentByScripthash');

Cap your concurrency: DigiScope allows three in-flight ElectrumX requests per process, and issues the two lookups for one address as a single Promise.all pair. Any failure on either degrades the whole result rather than half-answering:

from backend/src/services/dd-address-lookup.js

  let history;
  let unspent;
  try {
    [history, unspent] = await Promise.all([
      electrum.getHistoryByScripthash(decoded.scripthash),
      electrum.listUnspentByScripthash(decoded.scripthash),
    ]);
  } catch (error) {
    return errorResult(error?.message || 'ElectrumX lookup failed');
  }

Trap: DigiDollar value sits in outputs whose DGB value is zero. ElectrumX's blockchain.scripthash.get_balance for a DD scripthash is therefore meaningless — it will report {confirmed: 0, unconfirmed: 0} for an address holding a large $DD balance, and listunspent's value field will read 0 on every DD output for the same reason. Its history and unspent list are authoritative; its balance is not. Use it for identity and enumeration only, and derive the amount from the OP_RETURN yourself.

Two rules govern the arithmetic, and both are about honesty rather than accuracy. First, an address with no unspent outputs provably holds nothing — that zero is backed by the same chain evidence a nonzero balance would be, and it is reported as a real zero. Second, a single undecodable output poisons the entire balance: null, never a partial sum, and never 0 standing in for "unknown".

from backend/src/services/dd-address-lookup.js

  // Vacuously true: an address with no unspent outputs provably holds 0 DD
  // right now — that zero is backed by the same chain evidence as a
  // nonzero balance would be, not a stand-in for "we don't know".
  if (unspentArr.length === 0) {
    return { ...base, dd_balance_cents: 0, balance_derivable: true };
  }
// …
    const decodedAmount = decodeDdAmountForOutput(tx, voutIndex);
    if (!decodedAmount.ok) {
      balanceDerivable = false;
      break;
    }
    ddBalanceCents += decodedAmount.ddCents;
  }

  return {
    ...base,
    dd_balance_cents: balanceDerivable ? ddBalanceCents : null,
    balance_derivable: balanceDerivable,
  };

The same posture applies to cost. DigiScope caps the walk at 200 unspent outputs and abstains before making any RPC calls when an address exceeds it, so a pathological UTXO set on a public endpoint cannot be turned into a node-hammering loop. Transaction count and history still come back — those are cheap ElectrumX reads — only the balance is withheld, with an error string that says why.

Step 6 — Price history you have to build yourself

No RPC returns oracle price history. getoracleprice exposes the current spot price plus a rolling 24-hour high and low, and that is the entire time dimension the node offers. If you want a chart, you sample forward and keep the samples yourself.

from backend/src/services/digidollar-price-sampler.js

  async sample() {
    try {
      // Price is required (NOT NULL columns); collateral is best-effort.
      const [p, stats] = await Promise.all([
        rpcClient.getOraclePrice(),
        rpcClient.getDigiDollarStats().catch(() => null),
      ]);
      if (!p || p.price_micro_usd == null) return;
      const collateral = stats && stats.total_collateral_dgb != null ? stats.total_collateral_dgb : null;

      const db = getDatabase();
      db.prepare(
        'INSERT INTO digidollar_price_history (price_micro_usd, price_usd, height, total_collateral_dgb) VALUES (?, ?, ?, ?)'
      ).run(p.price_micro_usd, p.price_micro_usd / 1e6, p.last_update_height ?? null, collateral);

      db.prepare("DELETE FROM digidollar_price_history WHERE sampled_at < datetime('now', ?)").run(
        `-${RETENTION_DAYS} days`
      );

Three details are worth copying. The price columns are NOT NULL, so a failed price call aborts the sample rather than writing a hole; collateral is best-effort and stored NULL when its call fails, so one flaky RPC never costs you a price point. The height is stored alongside the price, which makes every row self-dating in chain terms rather than only in wall-clock terms. And retention runs on the same tick as the insert — a second, separate autocommitted statement, not part of a transaction with it — so the table cannot grow unbounded because a cleanup job was never wired. Two independent statements is the right call here (a failed prune must not roll back a good sample), but be clear-eyed that it is not atomic: a crash between them leaves the row written and the prune skipped until the next tick, which is harmless for a rolling window and would not be for anything you actually settle against.

The 5-minute cadence matches the oracle bundle cadence — sampling faster buys you duplicate rows, not resolution.

Verify it

You do not need to trust your own decoder, because the node will decode DigiDollar for you. getrawtransaction <txid> 2 returns a verbose decode that includes a digidollar object with type_id in {1, 2, 3}. That is the cheapest possible cross-check, and it is the first thing to reach for when your own decoder disagrees with a block explorer.

  1. Confirm the feature is live on your node and see the unnormalized shape with your own eyes:

    digibyte-cli getdigidollarstats
    digibyte-cli getoracleprice
    

    Read total_dd_supply and oracle_price_micro_usd raw, apply the Step 1 conversions by hand, and confirm your application shows the same two numbers. Note that oracle_price_cents reads 0 while oracle_price_micro_usd reads a real number — that is the healthy state, not a fault.

  2. Confirm the two methods that do not exist, so you never chase them again:

    digibyte-cli getdigidollarsystemstatus   # -32601 Method not found
    digibyte-cli getdigidollarposition       # -32601 Method not found
    
  3. Let the node decode a DigiDollar transaction, then decode the same one yourself:

    digibyte-cli getrawtransaction <txid> 2 | python3 -c 'import json,sys; t=json.load(sys.stdin); print(t.get("digidollar"))'
    

    Expected: a digidollar object carrying type_id. Now run your Step 4 decoder over the same transaction's vout array and compare: your magic must be 17476, your type must equal type_id, and your per-output amounts must sum to the total the payload declares.

  4. Prove the hex-not-asm rule to yourself rather than taking it on faith. Pull the OP_RETURN output of that transaction and look at both renderings:

    digibyte-cli getrawtransaction <txid> 2 | python3 -c 'import json,sys; t=json.load(sys.stdin); [print(o["scriptPubKey"]["asm"], "|", o["scriptPubKey"]["hex"]) for o in t["vout"] if o["scriptPubKey"]["type"]=="nulldata"]'
    

    For a small amount the asm token is a decimal you could have parsed. That is the trap: it works until it doesn't. Construct a five-byte push by hand and confirm your asm parser produces a wrong number where your hex parser produces the right one.

  5. Round-trip a DD address through your decoder and the node:

    digibyte-cli validateddaddress <address>
    

    Expected: your decoder's prefix is 5285, its script starts 5120, and its 32-byte key matches the taproot key the node reports. Then confirm the reversal: hash your script bytes with sha256, reverse them, and check that ElectrumX returns a history for the reversed form and nothing for the un-reversed one.

  6. Prove the address_count distinction, which is the one that silently publishes a lie:

    digibyte-cli -rpcwallet=<wallet> getdigidollarbalance <address>
    
    curl -s --user <user>:<password> -H 'Content-Type: application/json' \
      --data '{"jsonrpc":"2.0","id":1,"method":"getdigidollarbalance","params":["<address>"]}' \
      http://127.0.0.1:14022/wallet/<wallet>
    

    Run it against an address your wallet does not own. Expected: 0, with address_count: 0. Confirm your application refuses to render that as a balance. Note the URL path — drop the /wallet/<wallet> suffix and you are asking a different wallet the same question, which is its own way to get a meaningless zero.

Check: Point your own reader at any $DD figure and try to screenshot the number without the freshness marker coming along. If you can crop them apart, the marker is in the wrong place. Then stop your oracle sampler, wait, and confirm the figure visibly degrades instead of quietly ageing.

Traps

Where DigiScope's implementation differs from the ideal

The largest gap is the one Step 4 spends the most words on. digidollar-position-indexer.js predates the shared decoder and still reads OP_RETURN fields out of the asm string:

from backend/src/services/digidollar-position-indexer.js

function opReturnFields(tx) {
  const outs = Array.isArray(tx?.vout) ? tx.vout : [];
  for (const vo of outs) {
    if (vo?.scriptPubKey?.type !== 'nulldata') continue;
    const parts = String(vo.scriptPubKey.asm ?? '').trim().split(/\s+/);
    if (parts[0] !== 'OP_RETURN') continue;
    return parts.slice(1);
  }
  return null;
}

It is correct for every amount on chain today, and wrong the moment it meets one above ~2^31−1 cents — the exact case the address and transaction views were rebuilt to handle. It should be migrated onto dd-tx-decode.js's hex path; it has not been. The rest:

Next: the write side, in Mint, send and redeem DigiDollar.