Read DigiDollar state
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
- DigiByte Core v9.26.5 or later, built with the DigiDollar softfork (BIP9 bit 23), running with
digidollar=1andtxindex=1.txindexis not optional here: Step 5 fetches arbitrary funding transactions by txid, not just your wallet's. - The RPC surface, named honestly. Fourteen methods carry "digidollar" in the name:
getdigidollaraddress,getdigidollarbalance,getdigidollardeploymentinfo,getdigidollarstats,importdigidollaraddress,listdigidollaraddresses,listdigidollarpositions,listdigidollartxs,listdigidollarunspent,listdigidollarutxos,mintdigidollar,redeemdigidollar,senddigidollar,sendmanydigidollar. Four more belong to the feature and are easy to miss because their names do not say so:estimatecollateral,getredemptioninfo,validateddaddress, and the oracle pairgetoracleprice/getoracles. - Two method names that look plausible and do not exist.
getdigidollarsystemstatusandgetdigidollarposition(singular) both answer-32601 Method not found. Usegetdigidollarstatsandlistdigidollarpositions. - An ElectrumX server, only if you need balances for addresses you do not own (Step 5) — newline-delimited JSON-RPC over TCP, port 50001 plaintext or 50002 TLS. Everything else in this guide is node-only.
- A willingness to render "unknown" instead of a number. Three of the six steps below end in a deliberate abstention, and every one of them exists because the alternative is a confidently wrong figure about a stablecoin's backing.
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 adigibyte-cliflag, 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
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_centsis present, typed, and always0. It reads0on a healthy node at the same momentoracle_price_micro_usdreads 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 youoracle.price_usd,oracle.is_staleandoracle.last_update_height— but noage. Theparams.oracle.agethis component reads comes frombuildMintParams, a different assembler in the same controller, which carriesstats.oracle_price_agethrough under that name. Feed Step 1's object to this markup unchanged and the badge silently renders without its age, becauseundefinedfails the!= nullguard rather than throwing. Pick one builder per surface, or forwardoracle_price_ageexplicitly.
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 DigiDollar address is 52 base58 characters beginning DD on mainnet. Underneath it is 38 raw bytes:
- a 2-byte prefix,
0x5285on mainnet, confirmed againstvalidateddaddress; - a 32-byte taproot output key;
- a 4-byte checksum, the first four bytes of double-SHA256 over the 34-byte body.
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/RDprefix 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 — anetwork: 'testnet'label on an address you have not actually identified invites someone to send mainnet value to it.unknownis the correct answer until a node says otherwise.
Step 4 — Read amounts out of a DigiDollar transaction
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:
blockchain.scripthash.get_history→[{ tx_hash, height }], unsorted. Sorting and limiting are yours; mempool entries arrive withheight <= 0.blockchain.scripthash.listunspent→[{ tx_hash, tx_pos, height, value }], wherevalueis in satoshis and, for a DD output, is always0.
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_balancefor a DD scripthash is therefore meaningless — it will report{confirmed: 0, unconfirmed: 0}for an address holding a large $DD balance, andlistunspent'svaluefield will read0on 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.
Confirm the feature is live on your node and see the unnormalized shape with your own eyes:
digibyte-cli getdigidollarstats digibyte-cli getoraclepriceRead
total_dd_supplyandoracle_price_micro_usdraw, apply the Step 1 conversions by hand, and confirm your application shows the same two numbers. Note thatoracle_price_centsreads0whileoracle_price_micro_usdreads a real number — that is the healthy state, not a fault.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 foundLet 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
digidollarobject carryingtype_id. Now run your Step 4 decoder over the same transaction'svoutarray and compare: your magic must be17476, your type must equaltype_id, and your per-output amounts must sum to the total the payload declares.Prove the
hex-not-asmrule 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
asmtoken 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 yourasmparser produces a wrong number where yourhexparser produces the right one.Round-trip a DD address through your decoder and the node:
digibyte-cli validateddaddress <address>Expected: your decoder's prefix is
5285, itsscriptstarts5120, and its 32-byte key matches the taproot key the node reports. Then confirm the reversal: hash yourscriptbytes with sha256, reverse them, and check that ElectrumX returns a history for the reversed form and nothing for the un-reversed one.Prove the
address_countdistinction, 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, withaddress_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
total_dd_supplyis cents. Divide by 100. Untouched it reads about 100× too large.oracle_price_micro_usdis micro-USD, and it is the DGB price. Divide by 1e6, and never label it as the price of $DD.oracle_price_centsis always0. It reads0on a healthy node while the micro-USD field carries a live price. Never map it.total_collateral_dgbis already DGB. The RPC help text implies satoshis. It is not satoshis; do not divide by 1e8.24h_high/24h_loware cents. They sit in the same response as a micro-USD spot price. Two units, one object.system_collateral_ratioandhealth_percentageare integer percents.385means 385%, not 3.85.oracle_price_agecounts blocks. Multiply by about 15 seconds. An age of2is roughly 30 seconds, not 2.- Parse pushes from
hex, neverasm. Above2^31−1 cents ($21.5M) Core renders the push as hex, and an all-digit hex string parses as a plausible, completely wrong number — precisely in the large-reserve case you built the tool to audit. - A DD address is not bech32 and not a standard P2TR address. It is base58 with a two-byte version, so
bs58checksilently mis-decodes it: the checksum verifies over the same 34-byte body, one version byte is stripped, and you are handed 33 bytes that are not a taproot key — with no error to tell you. - The scripthash is byte-reversed. The un-reversed digest returns an empty history, which is indistinguishable from an unused address.
getdigidollarbalancereturning0withaddress_count: 0means "not mine". It does not mean the address is empty.- ElectrumX balance is meaningless for $DD. The value lives in zero-DGB-value outputs; use history and unspent, decode the amounts yourself.
- Abstain on type 3+, on a truncated MINT payload, and on an amount/output count mismatch. A single undecodable output poisons the whole balance — report
null, never a partial sum, and never0. getdigidollarsystemstatusandgetdigidollarpositiondo not exist. They answer-32601.- No RPC returns price history. Sample it forward or you will not have it.
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:
- Two decoders exist at all.
dd-address-lookup.jsand the transaction view were deliberately collapsed onto one shareddd-tx-decode.jsso they could not drift into disagreeing about the same transaction. The position indexer was not included in that extraction, so the invariant holds for two of three consumers. total_collateral_lockedis accepted as a fallback fortotal_collateral_dgbwithout a version check. If a future build ever changed the unit of the older field, the fallback would silently mis-scale the reserve figure.- The balance walk is capped at 200 unspent outputs and 50 listed transactions. Both are cost decisions on a public endpoint, not protocol limits, and both surface as an explicit abstention rather than a truncated answer — but an address past the cap simply has no balance shown.
- Testnet and regtest addresses decode to
network: 'unknown'. A decoder with node-verifiedTD/RDprefix bytes would name them; this one refuses to guess. - Price history starts when your sampler starts. There is no backfill, because there is nothing to backfill from.
Next: the write side, in Mint, send and redeem DigiDollar.