Mint, send and redeem DigiDollar

Last verified against DigiByte Core v9.26.5

Mint, send and redeem DigiDollar

Reading DigiDollar is arithmetic. Writing it is arithmetic with consequences, and the first consequence is a units convention that costs real money: in the DigiDollar RPCs a bare integer is cents, and only a decimal point makes it dollars. mintdigidollar 500 does not mint five hundred dollars. It mints $5.00, opens a real position, and locks real DGB collateral for the tier you named — and the way you find out is by reading a position back and seeing two decimal places where you expected none. This guide covers the write side: the preconditions that must hold before a mint is even possible, the dry runs that price a position without spending anything, the one fee rule that two published sources get wrong, why a DigiDollar transaction can sit unconfirmed while ordinary DGB transactions sail past it, and why redemption is all-or-nothing.

Who this is for. You are building a wallet, a treasury tool or a service that has to write DigiDollar transactions against your own node — open a position, pay someone in $DD, close a position out — rather than call a hosted API.

What you will build. A precondition check that refuses to mint when the network says no, a tier choice you can defend, a priced position derived from the node's own consensus math, a mint whose amount is unambiguous, a send that does not strand itself on a fee argument, and a redemption you simulated before you signed anything.

Before you start

Trap: In the DigiDollar RPCs a bare integer is cents; a value carrying a decimal point is dollars. mintdigidollar 500 4 mints $5.00; mintdigidollar 500.00 4 mints $500. getdigidollarbalance and listdigidollartxs report in cents on the way back out, so a round-trip through a naive client — integer in, integer out — is self-consistent and silently 100× off. Pick one representation at your edge, convert once, and never let a user-facing "amount" field reach an RPC without passing through it.

The bounds, in the same units. A single mint is $100 minimum and $100,000 maximum — 10000 to 10000000 cents. No RPC reports those bounds. getdigidollarstats does not carry them and neither does anything else on the DigiDollar surface, so they are consensus constants you have to hold in your own code while the node remains the only thing that actually enforces them. DigiScope holds them as a literal for exactly that reason — and, because the object around them is assembled entirely from live RPC results, refuses to answer at all rather than serve invented limits when the node cannot be reached:

from backend/src/controllers/digidollar.js

const TIER_LOCK_LABELS = ['1 hour', '30 days', '90 days', '180 days', '1 year', '2 years', '3 years', '5 years', '7 years', '10 years'];
const MINT_PARAMS_TTL_MS = 60_000;
const MINT_PROBE_CENTS = 10000; // $100 — protocol minimum, valid for every tier
// …
/** Pure assembler — exported for tests. estimates = one estimatecollateral
 *  result per tier 0..9; stats = raw getdigidollarstats (or null). */
export function buildMintParams(estimates, stats) {
  const first = estimates[0] || {};
  const isStale = !stats || stats.oracle_available !== true;
  const marginPct = Number.isFinite(first.collateral_safety_margin_dgb) && first.required_dgb > 0
    ? (first.collateral_safety_margin_dgb / first.required_dgb) * 100
    : null;
// …
    system_health: stats?.system_collateral_ratio ?? first.system_health ?? null,
    mint_bounds_cents: { min: 10000, max: 10000000 },
    wallet_safety_margin_pct: marginPct,
// …
  const estimates = await Promise.all(
    Array.from({ length: 10 }, (_, t) => rpcClient.call('estimatecollateral', [MINT_PROBE_CENTS, t]))
  );

The preconditions. Read getdigidollarstats before you build anything. Two fields decide whether a mint is possible at all:

Those two are network-wide gates, and they are not the same thing as your own position's collateral ratio. Keep them apart in your head and in your code — Step 2 explains why.

Step 1 — Choose a lock tier

The DigiDollar position lifecycle: locked, eligible, redeemed A minted position starts locked, gated on its unlock_height. Once the chain reaches that height it becomes eligible for redemption. Redeeming spends the mint's vout 0 collateral output, which is the on-chain signal that closes the position — there is no separate close marker to watch for. locked mint confirmed · below unlock_height eligible chain height ≥ unlock_height redeemed position closed awaiting unlock_height redeem broadcast observed on-chain as: mint's vout 0 (collateral) spent there is no separate close marker — a position is only ever known to be redeemed by watching its collateral output get spent
locked → eligible → redeemed. There is no separate close marker on chain: a position is known to be redeemed only by watching its collateral output get spent.

A DigiDollar position is minted into exactly one of ten lock tiers, chosen at mint time and fixed for the life of the position. Tier 0 is one hour; tier 9 is ten years. The trade is the whole design: a longer lock demands less collateral per dollar minted, because the protocol is being asked to carry less price risk per unit of time it cannot be unwound.

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

 * Lock ladder. CANONICAL SOURCE is consensus, via `digibyte-cli help mintdigidollar`:
 *   lock_tier 0-9 (0=1h, 1=30d, 2=90d, 3=180d, 4=1y, 5=2y, 6=3y, 7=5y, 8=7y, 9=10y)
// …
 * ⚠️ TIER 0 IS A REAL 1-HOUR LOCK — 240 blocks — not a "test" tier. It is a
 * first-class protocol tier and collateral in it is genuinely locked; someone who
 * mints tier 0 and never redeems stays locked exactly like any other tier.
// …
 * TIER_BLOCKS is the accurate ladder. TIER_DAYS is day-denominated for the
 * `lock_days` INTEGER column, so tier 0 necessarily rounds to 0 there — that 0
 * is a storage artifact and must NEVER be read as "no lock". Label from the tier,
 * never from lock_days.
// …
export const TIER_BLOCKS = Object.freeze({
  0: 240, 1: 172800, 2: 518400, 3: 1036800, 4: 2102400,
  5: 4204800, 6: 6307200, 7: 10512000, 8: 14716800, 9: 21024000,
});

Two things in that ladder deserve saying in prose, because both have already been got wrong in shipped software.

Tier 0 is a real lock. Two hundred and forty blocks at roughly fifteen seconds each is one hour, and during that hour the collateral behind a tier-0 position is locked exactly as hard as the collateral behind a ten-year one. It is not a sandbox, not a dry run, and not a "test tier" — it is simply the tier that pays for its short horizon with extreme overcollateralization. A UI that labels it "not a real lock" is telling a user their DGB is available when it is not.

Label from the tier, never from a day count. A day-denominated column rounds tier 0 to 0, and a 0 in a lock_days field is indistinguishable from "no lock" to any code that reads it later. Carry the tier index; derive the label from the tier.

The other end of the ladder matters for a different reason: unlock_height is when collateral becomes eligible for release, not when it is released. Redemption is voluntary. A matured position can sit unredeemed indefinitely, and a great many do. If you are drawing a forward curve of "DGB coming back", it is a ceiling on what could be released, never a schedule of what will be.

Step 2 — Price it with estimatecollateral (a dry run)

Minting a DigiDollar position, step by step Minting starts with choosing a DGB collateral amount, then reading the oracle price and refusing to proceed if it is stale, then computing the DD amount at the required collateral ratio, then building the type-1 OP_RETURN and broadcasting a transaction whose vout 0 holds the real DGB collateral and whose vout 1 is a zero-value taproot output carrying the DD. Two separate gates apply: the position's own collateral must meet its tier's required ratio, and network-wide minting halts entirely whenever the Emergency Redemption Ratio is active, which happens when system collateralization falls below 100%. The position sits locked until the chain reaches its unlock_height. choose collateral N DGB to lock getoracleprice oracle_price_micro_usd must not be stale compute dd_cents collateral value ÷ required collateral ratio build type-1 OP_RETURN 17476 1 dd_cents unlock_height tier hash broadcast: vout 0 collateral (real DGB) · vout 1 zero-value P2TR (DD) your collateral must meet your tier's required ratio and all minting halts while ERR is active (system collateralization below 100%) every node recomputes this on every block position: locked held until chain height reaches unlock_height stale oracle price aborts the mint before any tx is built A stale or rejected oracle read fails closed — no position is opened on an unverified price.
Collateral in, oracle price applied, ratio enforced, type-1 OP_RETURN built. A stale oracle read fails closed — no position is opened on an unverified price.

estimatecollateral dd_amount lock_tier ( oracle_price_micro_usd ) answers "how much DGB would I have to lock to mint this much, in this tier, right now". It builds nothing, signs nothing and spends nothing — it is the consensus mint math, run as a question. Use it liberally. The optional third argument lets you re-run the same math against a hypothetical price, which is how you model a position before the price you are worried about actually arrives.

The important property is that the ratio is amount-independent: doubling the dollars doubles the required collateral. That is what lets a client probe the ladder once with a fixed amount and reuse the answer for every amount the user might type. DigiScope probes all ten tiers with a fixed MINT_PROBE_CENTS = 10000 ($100) and derives its safety margin from the node's own collateral_safety_margin_dgb, so the ratios are never hardcoded on the client — the numbers a consensus change would actually move arrive with each probe. That is the property worth copying, and it is not the same as "no constants": the mint bounds in the same object are a literal, for the reason given in Before you start. See the excerpt there, whose Promise.all is precisely these ten probes.

Two gates, and they are not the same gate. Conflating them is the most common way to build a mint UI that lies:

  1. Per-mint, yours. Your position's collateral must satisfy your chosen tier's required ratio. The consensus expression is in src/digidollar/validation.cpp:1465 — DD amount = (collateral * oracle_price * 100) / (collateral_ratio * COIN) — and it is per-position, per-tier, evaluated against the oracle price at mint time. estimatecollateral is that formula, rearranged for the question you are actually asking.
  2. Network-wide, everybody's. All minting halts while ERR — the Emergency Redemption Ratio — is active, and ERR activates when system collateralization falls below 100% (validation.cpp:3026-3030, and the test itself at 1038-1039: bool errActive = systemCollateral < 100;). This gate has nothing to do with your tier or your collateral. You can be comfortably over-collateralized and still be unable to mint.

system_collateral_ratio from getdigidollarstats is reported state, not the per-mint gate — it is how you observe the second condition, not how you satisfy the first. And the reason the node can be trusted about either is that every full node recomputes the collateral behind every $DD on every block; the ratio is not a number somebody publishes, it is a number everybody derives.

Trap: Do not build a "will this mint succeed?" check out of system_collateral_ratio alone, and do not build one out of your own ratio alone. Read minting_restricted_reason — the node has already collapsed every network-wide reason into that one field, including ERR. Treat anything other than none as a refusal to be displayed verbatim, and re-check it immediately before you build the transaction rather than at page load.

Step 3 — Mint

digibyte-cli -rpcwallet=<wallet> mintdigidollar 10000 4

That is $100.00 — 10000 cents — into tier 4, the one-year lock. The node selects collateral from the wallet, builds the type-1 transaction (collateral at vout 0, a zero-value taproot output carrying the $DD, and the OP_RETURN payload the read guide decodes), signs it and broadcasts it. What comes back is a txid; what you want next is listdigidollarpositions, because the position id is how everything downstream refers to what you just created.

Then leave the fee alone.

Trap — the fee rule, stated once. MIN_DD_TX_FEE = 10000000 (0.1 DGB) lives at src/digidollar/txbuilder.cpp:37 in DigiByte Core and is applied at line 371 as std::max(ESTIMATED_TX_VSIZE * params.feeRate / 1000, MIN_DD_TX_FEE). That file is the wallet transaction builder, so 0.1 DGB is a builder floor, not a consensus rule: src/consensus/digidollar_transaction_validation.cpp contains no fee floor at all, only conservation (inputs == outputs + fee). A DD transaction paying less is not consensus-invalid — Core's own builder simply declines to construct one. 35,000,000 sat/kB is not a floor either; it is the default rate, picked so a ~300 vB transaction clears that floor (txbuilder.cpp:150-154: "For 0.1 DGB min fee on ~300 vB tx: need ~33,333,333 sat/kB (we use 35M)"), and ValidateFeeRate accepts the whole band 100,000 – 100,000,000 sat/kB. The unit is sat/kB, even though txbuilder.h comments feeRate as "sat/vB" at lines 46, 59 and 82 — CalculateFee computes (vsize * feeRate) / 1000 and ValidateFeeRate's own comment at line 149 says sat/kB. Core's header is mislabelled; the arithmetic is not. So: omit fee_rate and let the builder pick. On Core ≤ 9.26.4 a plausible-looking 100 or 1000 is honoured and is roughly 350,000× too low, and the transaction is rejected; on 9.26.5+ the argument is deprecated and ignored. There is no version of this where supplying it helps.

That trap is worth one more sentence because of where it doesn't come from. Published sources — including comments inside the open-source DigiByte Android wallet — describe the 0.1 DGB figure as a "consensus floor" while citing Core's builder in the same breath. The number is right and the category is wrong, and the category is what determines whether a third-party builder that pays less is producing invalid transactions (it is not) or transactions Core's wallet would never have produced (it is). Read digidollar_transaction_validation.cpp before you repeat anyone on this, including this guide.

Step 4 — Why confirmation arrives in bursts

Your mint is broadcast. Ordinary DGB transactions from the same wallet confirm in a block or two. Yours sits there for an hour. Nothing is wrong.

DigiDollar is its own consensus layer, and a miner needs DD-aware node software to validate a DD transaction and include it in a block template. Taproot capability alone is not sufficient — a node can relay the transaction shape perfectly well and still decline to build a block containing it, because it cannot evaluate the rules that make it valid. Until most pools upgrade, DD transactions accumulate in DD-aware mempools and confirm whenever a DD-aware pool happens to find a block. The result is bursty: long quiet stretches, then several DD transactions confirming close together.

This is the same shape SegWit had in its early months, where non-upgraded pools simply omitted the new transaction type from their templates, and it resolves the same way — by adoption, not by intervention. The practical consequences for anything you build:

Step 5 — Addresses and sending

getdigidollaraddress ( "label" ) returns a fresh DigiDollar receive address from your wallet; the optional label is the wallet's, not the chain's. validateddaddress "address" checks one.

Trap: The two address validators mutually reject. Generic validateaddress rejects a DD… address, and validateddaddress rejects an ordinary DGB address. Neither returns a soft "not mine" — each says the input is invalid for it. So a client that runs one validator over a mixed address book will mark half of it broken, and a form that validates a paste with validateaddress before handing it to senddigidollar will refuse every correct address a user can give it. Route by address family first, then validate with the matching call.

Sending is one call:

digibyte-cli -rpcwallet=<wallet> senddigidollar "<dd_address>" 2500

senddigidollar "address" amount ( "comment" fee_rate [inputs] ) — and 2500 there is $25.00, because the amount is cents like everywhere else. The optional inputs array is for callers who need to control coin selection; the fee_rate argument carries exactly the caveat from Step 3, which is to leave it out.

The wallet needs DGB as well as $DD. Fees are paid in DGB. A wallet holding a large $DD balance and no spendable DGB cannot send $DD at all, and the error it gets back is about funds, which reads — wrongly — as "you don't have the dollars". Check both balances before you offer a send button, and say which one is missing.

Trap: Immediately after a send, a DD balance read can come back empty for a moment while the wallet catches up with its own change. It is transient, and it is not a lost balance. The dangerous reaction is automated: a client that reads zero and retries the send, or that reads zero and reports the funds gone, does real damage from a reading that would have corrected itself. Re-read after the transaction appears in listdigidollartxs, and never fire a second write off a balance read taken seconds after the first.

Step 6 — Redeem

DigiDollar redemption is all-or-nothing A redeem request always asks whether the entire position is being redeemed. If yes, the node spends the mint's vout 0 collateral output and releases the collateral. If no — any attempt at a partial redemption — the node rejects the call with error -8; there is no partial-redeem path. redeem request redeem the FULL position? yes vout 0 spent collateral released no error -8 nothing is spent There is no partial-redeem path — any amount short of the full position fails closed with error -8. A failed redemption call spends nothing; the collateral stays locked and can be retried in full.
Full position or nothing. Any amount short of the whole position fails closed with error -8, and a failed redemption spends nothing.

A position cannot be redeemed before it matures. can_redeem stays false until the chain height passes that position's unlock_height; listdigidollarpositions reports both, along with a blocks_remaining countdown. Mint into tier 4 in Step 3 and there is nothing to redeem for a year, and no argument to redeemdigidollar shortens that. Note that this is the one failure the all-or-nothing rule below does not cover: when a partial redeem is rejected the recovery is to redeem the whole position, but when the position is immature the whole position is locked too, and the only move is to wait.

Then start with the dry run. Always.

digibyte-cli -rpcwallet=<wallet> getredemptioninfo "<position_id>"

getredemptioninfo "position_id" ( dd_amount ) tells you what a redemption would do before you attempt it — what it would consume, what it would release, and whether it can happen at all. It is the redemption equivalent of estimatecollateral, it costs nothing, and it is the single call that turns the trap below from an error message into a decision.

Then, when the answer is what you wanted:

digibyte-cli -rpcwallet=<wallet> redeemdigidollar "<position_id>" <dd_cents>

redeemdigidollar "position_id" dd_amount ( "redemption_address" fee_rate ). The optional address is where the released collateral lands; the fee argument carries the Step 3 caveat.

Trap: Redemption is all-or-nothing. A partial redemption against a position — asking for $200 back out of a $500 position — fails with error -8. There is no partial-redeem path to find, no flag that enables one, and no sequence of smaller calls that adds up to one. A failed redemption spends nothing: the collateral stays locked and the position is unchanged, so the recovery is to redeem the whole position, not to retry with a smaller number. If your product needs partial exits, the shape that works is several smaller positions minted separately, closed one at a time — decided at mint time, because it cannot be decided later.

Positions are enumerated with listdigidollarpositions ( active_only tier_filter min_amount count skip ). There is no getdigidollarposition singular — it answers -32601 Method not found, and reaching for it is the most common wrong turn on this surface. Filter the list.

Closure is the last thing to get right, and it is not marked. A redemption transaction does not name the position it closes; the link is structural, and the only reliable signal is that the mint's vout 0 — the collateral output — has been spent:

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

 * ⚠️ THE CLOSURE RULE: a position is closed if and only if its COLLATERAL
 * output (vout 0) is spent.
// …
export function closedPositionsFromRedeem(redeemTx, mintTxidSet) {
  const ins = Array.isArray(redeemTx?.vin) ? redeemTx.vin : [];
  const set = mintTxidSet instanceof Set ? mintTxidSet : new Set(mintTxidSet ?? []);
  const closed = new Set();
  for (const vi of ins) {
    if (Number(vi?.vout) !== 0) continue;      // vout 0 ONLY — collateral, not the DD token
    const txid = vi?.txid;
    if (txid && set.has(txid)) closed.add(txid);
  }
  return [...closed];

The vout 0 restriction is load-bearing, and DigiScope learned it from chain rather than from a spec. $DD tokens are fungible: a redemption can be paid with $DD that some other position minted, so the redeeming transaction may well spend a vout 1 belonging to a position whose collateral is still locked. Treat a vout 1 spend as closure and you delete a live position from your own accounting — its collateral vanishes from your totals and from every forward bucket, while remaining perfectly locked on chain. One redemption may also close several positions at once, which is why the function returns a list.

Verify it

Nothing below requires a real mint. estimatecollateral and getredemptioninfo are dry runs, the node will decode DigiDollar transactions for you, and the two validators can be exercised on addresses you do not own.

  1. Read the two preconditions with your own eyes, and confirm your client refuses to offer a mint when either says no:

    digibyte-cli getdigidollarstats
    

    Expected: minting_restricted_reason and oracle_available. Point your client at a captured copy with minting_restricted_reason set to anything other than none and confirm the mint path is closed and the reason is displayed verbatim, not replaced with a generic error.

  2. Prove the cents convention without spending anything. Price the same tier at two amounts that differ by exactly 100×:

    digibyte-cli estimatecollateral 10000 4
    digibyte-cli estimatecollateral 1000000 4
    

    Expected: the second required_dgb is one hundred times the first. 10000 is $100 and 1000000 is $10,000 — the ratio is amount-independent, so the collateral scales linearly with the cents you passed. If your client's "amount" field produces the same estimate for 500 and 500.00, it is not converting and it will one day mint a hundredth of what a user asked for.

  3. Walk the whole ladder in one pass and confirm the trade is real:

    for t in 0 1 2 3 4 5 6 7 8 9; do digibyte-cli estimatecollateral 10000 $t; done
    

    Expected: ten answers for the same $100, with required collateral falling as the tier rises. Confirm your UI labels tier 0 as a one-hour lock — not a test, not "no lock" — and that it derives that label from the tier index rather than from any day count.

  4. Confirm the two validators mutually reject, so your address routing never depends on one of them being lenient:

    digibyte-cli validateddaddress <dd_address>
    digibyte-cli validateaddress   <dd_address>
    digibyte-cli validateddaddress <dgb_address>
    

    Expected: the first succeeds; the second and third do not. Then confirm your form routes by address family before validating.

  5. Let the node decode a DigiDollar transaction for you. With txindex=1 this works on any DD transaction on chain, not just your own:

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

    Expected: a digidollar object whose type_id is 1 (mint), 2 (transfer) or 3 (redeem).

  6. Enumerate positions and dry-run a redemption. An empty list is a valid answer and your client must render it as one:

    digibyte-cli -rpcwallet=<wallet> listdigidollarpositions
    digibyte-cli -rpcwallet=<wallet> getredemptioninfo "<position_id>"
    

    Expected: a list (possibly empty), and — for any position you do hold — a redemption preview you never had to sign for. Confirm getdigidollarposition is not what you reached for; it does not exist.

  7. Confirm your closure detection reads vout 0. Take any redeem transaction and check which input index it spends against the mint you are tracking. A vout 1 spend is a $DD payment, not a closure, and code that treats it as one will quietly write off collateral that is still locked.

Check 5 above is the whole of DigiScope's on-chain proof that a pasted txid is a real DigiDollar transaction. It does not re-implement the decode — it asks the node, and then it distinguishes the three ways the answer can be no:

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

    let tx;
    try { tx = await rpcClient.getRawTransaction(txid, 2); }
    catch { unclaim(); return { error: 'We couldn\'t find that transaction on-chain yet. Wait for it to confirm, then try again.' }; }

    if (!tx || (tx.confirmations ?? 0) < 1) { unclaim(); return { error: 'That transaction isn\'t confirmed yet. Wait for one confirmation and try again.' }; }
    const typeId = tx.digidollar?.type_id;
    if (![1, 2, 3].includes(typeId)) { unclaim(); return { error: 'That\'s a valid DigiByte transaction, but not a DigiDollar mint, transfer, or redeem.' }; }

Not-found, then unconfirmed, then not-DigiDollar: three different messages, because they are three different things for the reader to do next. A single "invalid transaction" would send someone hunting for a typo when all they had to do was wait — and, given Step 4, waiting is the likeliest correct answer.

Check: Try to make your own client mint the wrong amount. Type 500 into whatever field a user types into and follow the value all the way to the RPC boundary. If what arrives is 500, you are about to mint $5.00 when the user meant $500 — and the position that results will be perfectly valid, perfectly confirmed, and perfectly wrong. Then do the same for a send.

Traps

Where DigiScope's implementation differs from the ideal

This is the one guide in the set where DigiScope's own code is not the worked example for the main action, and the honest reason is simple: DigiScope has no mint path. No backend code calls mintdigidollar, senddigidollar or redeemdigidollar — every reference to those methods in this repository is a comment or a test citing digibyte-cli help mintdigidollar as the canonical source for the lock ladder. The site's MintCalculator is exactly what its name says: its only network call is a read-only mint-params fetch built on estimatecollateral. There is no submit button, no signing, and no custody of anyone's position.

So what you are getting here is protocol instruction, not an implementation to copy. That is a real limitation and worth naming: the read guide could hand you DigiScope's decoder and let you diff your own against it, and this one cannot do the equivalent for a mint. The DigiScope code it can point at sits on either side of the write — buildMintParams, which turns the node's own estimatecollateral math into a client-safe shape, and verifyDigiDollarTx, which proves a txid really is a DigiDollar transaction by asking the node rather than re-deriving it. Between those two, you are on the RPCs directly.

The rest:

Previous: the read side, in Read DigiDollar state.