Minting means two different things — and only one of them requires silver
NAQD covers the peg math, the oracle fallback chain, and the custodial design at a product level. This page goes underneath that: the exact custody and key-handling model, the full mint/burn/withdraw code path with the compensating-reversal logic quoted verbatim, and — the part almost everyone conflates — the difference between creating the NAQD token once versus issuing units of it to a user, with the real economics of each.
Token design
silverGramPriceUSD = silverPriceOzUSD / 31.1034768 // grams per troy ounce
// worked example: silver at $32.00/oz
silverGramPriceUSD = 32.00 / 31.1034768 = $1.0288 / gram
1 NAQD = 1 gram of silver, priced continuously off the oracle — the full peg rationale and why silver (rather than a fiat peg or gold) is on NAQD → Peg math. Two precision choices worth being explicit about, since they're easy to conflate:
Ledger / presentation precision: 4dp
NAQD amounts round to 4 decimal places at presentation/settlement boundaries (vs. 2dp for MVR/USD) — see Ledger → On rounding. A ten-thousandth of a gram of silver is already sub-cent-of-value precision at any plausible silver price, so 4dp is generous headroom, not a compromise.
On-chain base-unit precision: 6 or 18
Solana SPL tokens use 6 decimals (solanaNAQDDecimals = 6); the Polygon
ERC-20 uses the standard 18 (polygonNAQDDecimals = 18, hardcoded — ERC-20
has no universal decimals constant of its own). The same nominal NAQD amount converts
independently per chain via shared toBaseUnits/fromBaseUnits
helpers — see Custody below.
The two meanings of "minting"
This is the single most misunderstood part of a stablecoin like NAQD, so it's worth stating as plainly as the code itself does: "minting the NAQD token" and "minting NAQD to a user" are two entirely different operations, at two entirely different costs, and this codebase only actually exercises one of them.
(a) Token creation — one-time, cheap
Creating the SPL mint account on Solana, or deploying the ERC-20 contract on Polygon. This happens exactly once per chain, ever, regardless of whether the platform ever issues 1 NAQD or 10 million. It costs whatever that one on-chain transaction costs — a small, fixed, one-time network fee. It does not require holding any silver at all; it just makes the token able to exist.
(b) Issuance — recurring, and it's the real economics
Minting units to a user when they buy NAQD. Every issuance must be backed — the reserve must actually hold (or be topped up with) the equivalent value in silver- denominated fiat, debited from the user in the same ledger posting that credits their NAQD. This is not a network-fee cost; it's the coin's entire economic backing, and it scales linearly with supply. See Economics below.
Both concepts genuinely exist as separate functions — solana.Client.DeployNAQDToken
(which itself calls CreateTokenMint) and polygon.Client.DeployERC20Token.
But a repo-wide grep for their call sites turns up zero callers anywhere,
including the chain-client constructors. newSolanaChain and newPolygonChain
both require a pre-existing mint/contract address as configuration
(NAQD_TOKEN_MINT_ADDRESS / NAQD_ERC20_CONTRACT_ADDRESS)
and hard-fail construction if it's unset — they never create one. And
DeployERC20Token is a literal not-implemented stub:
func (c *Client) DeployERC20Token(...) (string, string, error) {
return "", "", fmt.Errorf("ERC20 deployment not implemented - use pre-deployed NAQD token")
}
In other words: token creation is treated as an out-of-band, one-time manual/external step this codebase assumes has already happened before it ever runs. Everything the running system actually does — mint, burn, transfer, balance queries — is issuance against an already-existing mint/contract.
Custody & the ChainClient interface
No per-user chain wallets anywhere — this is the custodial-first design covered at a product level in NAQD → Custodial-first design. Here's the exact interface every chain implements, and how each one is actually built:
type ChainClient interface {
Chain() string
CustodyAddress() string
Mint(ctx context.Context, toAddress string, amount decimal.Decimal, reference string) (*TxReceipt, error)
Burn(ctx context.Context, fromAddress string, amount decimal.Decimal, reference string) (*TxReceipt, error)
Transfer(ctx context.Context, toAddress string, amount decimal.Decimal, reference string) (*TxReceipt, error)
BalanceOf(ctx context.Context, address string) (decimal.Decimal, error)
TxStatus(ctx context.Context, txRef string) (TxStatus, error)
}
| Implementation | Selected by | Notes |
|---|---|---|
sandboxChain (default) | CHAIN_ENV unset or sandbox | In-memory map[address]decimal.Decimal; deterministic txRef; idempotent per (kind, reference) — see below |
solanaChain | CHAIN_ENV=live | SPL Token, 6 decimals; requires NAQD_TOKEN_MINT_ADDRESS + SOLANA_WALLET_PRIVATE_KEY; burn restricted to the treasury address |
polygonChain | CHAIN_ENV=live | ERC-20, 18 decimals; requires NAQD_ERC20_CONTRACT_ADDRESS + POLYGON_WALLET_PRIVATE_KEY; chain ID 137; receipt timeout POLYGON_RECEIPT_TIMEOUT (2m default) |
Deterministic idempotency — sandboxChain
func txRef(chain, kind, reference string) string {
sum := sha256.Sum256([]byte(chain + "|" + kind + "|" + reference))
return fmt.Sprintf("SBX%s-%s", strings.ToUpper(chain), hex.EncodeToString(sum[:8]))
}
func (s *sandboxChain) apply(kind, address string, delta decimal.Decimal, reference string) *TxReceipt {
s.mu.Lock()
defer s.mu.Unlock()
hash := txRef(s.chain, kind, reference)
if existing, ok := s.receipts[hash]; ok {
return existing // already applied — return the same receipt, don't double-move balance
}
s.balances[address] = s.balances[address].Add(delta)
receipt := &TxReceipt{TxHash: hash, Status: TxStatusConfirmed, Chain: s.chain, Timestamp: time.Now()}
s.receipts[hash] = receipt
return receipt
}
Mint/Burn/Transfer all delegate through apply, so a worker crash-and-retry with the same (kind, reference) returns the already-recorded receipt instead of double-crediting or double-debiting — confirmed by TestSandboxChain_MintIsIdempotentPerReference.
Key handling — corrected: ENCRYPTION_KEY, not CHAIN_ENV
Treasury private keys for both live chains are AES-256-GCM encrypted at rest. It's easy to
assume CHAIN_ENV is somehow involved in key derivation — it
isn't; it only selects which ChainClient implementation gets
constructed (sandbox vs. live). The actual passphrase is ENCRYPTION_KEY:
func deriveAESKey(passphrase string) []byte {
sum := sha256.Sum256([]byte(passphrase))
return sum[:]
}
func decryptTreasuryKey(passphrase, ciphertextB64 string) (string, error) {
key := deriveAESKey(passphrase)
raw, _ := base64.StdEncoding.DecodeString(ciphertextB64)
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
...
return string(plaintext), nil
}
Called as decryptTreasuryKey(cfg.Security.EncryptionKey, encrypted) identically for both SOLANA_WALLET_PRIVATE_KEY and POLYGON_WALLET_PRIVATE_KEY — ENCRYPTION_KEY is SHA-256'd into a 32-byte AES key, the same passphrase that also protects card PANs (Security → Key management). Custody address = treasury address in both live implementations — there is no separate "mint authority" identity distinct from the treasury signer in this codebase. For the full custody model — devnet vs. mainnet key handling, blast radius per key, and the .gitignore protections — see Tokens, Keys & Chain Choice → Key custody model.
Mint lifecycle, end to end
An API call only ever creates a row; a background worker does the real work. See the full sequence diagram at Data Flows → NAQD mint and the exact ledger entries at Money Flows → NAQD mint — this section is the orchestration code around that posting.
NAQDOperation.Status — same shape for mint, burn and withdraw_onchain; only requires_approval → rejected needs no ledger reversal, because nothing was ever posted at that stage.- Quote resolution.
QuoteMintsupports both amount-first (amount_naqd→ USD cost) and spend-first (a fiat amount → NAQD out), viarate = silverPriceUSD / 31.1034768. No mint fee. - Maker-checker gate, before any ledger write.
usdEquivalent := quote.NAQDAmount.Mul(quote.RateUSDPerNAQD); ifusdEquivalent ≥ NAQD_APPROVAL_THRESHOLD_USD(default 5000) the operation is createdrequires_approvalinstead ofpending— identical pattern for mint, burn, and withdraw. - Worker claims via a guarded UPDATE —
ClaimNextPendingreads the oldestpendingrow, then updates it toprocessingonly if it's stillpending; zero rows affected means another worker tick claimed it first between the read and the update:internal/stablecoin-service/repository.goresult := r.db.WithContext(ctx).Model(&models.NAQDOperation{}). Where("id = ? AND status = ?", candidate.ID, models.NAQDOperationStatusPending). Updates(map[string]interface{}{ "status": models.NAQDOperationStatusProcessing, "processing_started_at": now, }) if result.RowsAffected == 0 { return nil, false, nil // another worker claimed it first } - Ledger posts first, then the chain is called. Four-leg
ledger.Post(KindNAQDMint, ...)(exact entries: Money Flows), thencc.Mint(ctx, cc.CustodyAddress(), op.NAQDAmount, op.Reference). Guarded byalreadyPosted(tx, reference)so a worker retry never double-posts. - On chain success:
finishOKsetsStatus: completed,TxHash,CompletedAt, publishesnaqd.minted. - On chain failure:
reverseAndFail(quoted in full below) posts a compensating reversal and publishessystem.alert— the fiat is never left debited with no NAQD delivered.
func (w *Worker) reverseAndFail(ctx context.Context, op *models.NAQDOperation, kind, reason string) {
err := w.db.Transaction(func(tx *gorm.DB) error {
var original ledger.LedgerTransaction
tx.Preload("Entries").Where("reference = ?", op.Reference).First(&original)
reversalRef := op.Reference + "-REVERSAL"
if alreadyPosted(tx, reversalRef) { return nil }
entries := make([]ledger.Entry, 0, len(original.Entries))
for _, e := range original.Entries {
entries = append(entries, ledger.Entry{AccountID: e.LedgerAccountID, Currency: e.Currency, Amount: e.Amount.Neg()})
}
_, err := w.ledger.Post(ctx, tx, ledger.KindAdjustment, reversalRef, entries,
map[string]interface{}{"reverses_reference": op.Reference, "reason": reason})
return err // + wallet-projection correction per kind, same tx
})
if err != nil {
log.Printf("CRITICAL: failed to reverse ledger for operation %d: %v — manual reconciliation required", op.ID, err)
}
w.repo.MarkFailed(ctx, op.ID, reason)
events.Publish("system.alert", map[string]any{
"source": "naqd", "severity": "warning",
"operation_id": op.UUID, "user_id": op.UserID, "kind": op.Kind, "reason": reason,
})
}
Every entry from the original LedgerTransaction is negated and reposted under KindAdjustment, keyed by <reference>-REVERSAL — itself guarded by alreadyPosted, so the reversal is exactly as double-post-safe as the original. If even the reversal fails to write, the log line above is explicit that it needs a human, not a retry.
Burn lifecycle
Same claiming/reversal machinery as mint, with a fee. burnFeeRate is a
package-level var computed once from NAQD_BURN_FEE_BPS
(default 50 bps = 0.5%):
var burnFeeRate = decimal.NewFromInt(envInt64Local("NAQD_BURN_FEE_BPS", 50)).Div(decimal.NewFromInt(10000))
// QuoteBurn: gross = naqdAmount * rate [* mvrPerUSD if MVR]; fee = gross * burnFeeRate; net = gross - fee
Worker posts under ledger.KindNAQDBurn — five legs when the fee is positive (exact entries: Money Flows → NAQD burn). Chain call is cc.Burn(ctx, cc.CustodyAddress(), op.NAQDAmount, op.Reference), which on both live chains is restricted to the treasury address:
func (s *solanaChain) Burn(ctx context.Context, fromAddress string, amount decimal.Decimal, reference string) (*TxReceipt, error) {
if fromAddress != "" && fromAddress != s.treasuryAddress {
return nil, fmt.Errorf("solana burn: custodial burns are only supported from the treasury address")
}
...
}
Since there are no per-user chain wallets, this restriction is structural, not a safety net for a case that could otherwise happen.
On-chain withdrawal lifecycle (self-custody)
The one path that ever moves tokens to a user-supplied external address. Posted as
burn-shaped — ledger.KindNAQDBurn, two NAQD-only legs, no fiat
(Money Flows → on-chain withdrawal) — but the
chain call is Transfer, not Burn:
// mint calls cc.Mint(ctx, cc.CustodyAddress(), ...) — increases supply
// burn calls cc.Burn(ctx, cc.CustodyAddress(), ...) — decreases supply
// withdraw_onchain calls cc.Transfer(ctx, op.DestinationAddress, op.NAQDAmount, op.Reference)
// — supply unchanged, only custody changes
On success, the event published is still naqd.burned — withdrawal was folded into the burn event type by design rather than given its own, per the worker's own comment ("withdraw_onchain isn't in the contract's event list ... published as naqd.burned too").
Oracle internals
Product-level view (sources, the fallback diagram) is on NAQD → Oracle. Here's what's underneath the two live sources, and two details the product page doesn't cover.
| Source | Weight() | Active when |
|---|---|---|
CoinGeckoSource | 0.8 | always |
MetalsAPISource | 1.0 | METALS_API_KEY set — a paid feed, skipped entirely rather than failing every aggregation when no key is configured |
NewAggregator() always sets method: MethodMedian — the mission-specified default, chosen for resistance to a single misbehaving source. calculateWeightedAverage (which is where Weight() actually gets used numerically) is only reachable if something sets method = MethodWeighted, and nothing in the codebase ever does. So in the running system, every source's Weight() value is read only for display purposes (GetPriceSources) — the number that decides the resolved price is a plain median of whichever sources answered.
func (a *Aggregator) calculateMedian(prices []*PriceData) decimal.Decimal {
values := make([]decimal.Decimal, len(prices))
for i, p := range prices { values[i] = p.Price }
sort.Slice(values, func(i, j int) bool { return values[i].LessThan(values[j]) })
n := len(values)
if n%2 == 0 {
return values[n/2-1].Add(values[n/2]).Div(decimal.NewFromInt(2)).Round(2)
}
return values[n/2].Round(2)
}
Verified by TestAggregator_MedianResistsOutlier: a fake $999.00 source doesn't move a median of two real ~$32 sources.
func (s *Service) resolvePrice(ctx context.Context) (*SilverPrice, error) {
if price, err := s.fetchLive(ctx); err == nil {
return price, nil
}
if point, ok, err := s.lastKnownGood(ctx); err == nil && ok {
if time.Since(point.FetchedAt) <= s.stalenessThreshold {
events.Publish("system.alert", map[string]any{"source": "oracle", "severity": "warning",
"reason": "live silver price feeds unavailable, serving last-known-good", "age": time.Since(point.FetchedAt).String()})
return s.silverPriceFromPoint(ctx, point, SourceLastKnownGood), nil
}
}
if manual, ok, err := s.manualRate(ctx); err == nil && ok {
events.Publish("system.alert", map[string]any{"source": "oracle", "severity": "critical",
"reason": "live and last-known-good silver price both unavailable, serving admin manual rate"})
return &SilverPrice{PriceUSD: manual.PriceUSD, Source: SourceAdminManual, ...}, nil
}
events.Publish("system.alert", map[string]any{"source": "oracle", "severity": "critical",
"reason": "ORACLE_STALE: no live feed, no recent known-good price, and no admin manual rate is set"})
return nil, apperr.OracleStale("silver price oracle is stale: no live feed, no recent known-good price, and no admin manual rate is set")
}
All three fallback steps publish system.alert with source: "oracle" — warning for last-known-good, critical for manual rate and for the terminal stale error. Staleness gate is a plain time.Since(point.FetchedAt) <= s.stalenessThreshold against the most recent persisted aggregated price point, threshold ORACLE_STALENESS_THRESHOLD (default 15m).
How a stale oracle actually blocks minting
apperr.OracleStale(...) maps to error code ORACLE_STALE, HTTP 503.
Every quote path in stablecoin-service (mint, burn, withdraw) calls the same
resolvePrice chain to get a rate before it will construct a
NAQDOperation at all — so a fully-stale oracle doesn't produce a bad price, it
produces no operation, and a 503 to the client, before anything is queued
or posted.
Aggregator.DetectOutliers (IQR-based) and GetPriceConfidence
(coefficient-of-variation) exist and are unit-tested in isolation, but a repo-wide grep
finds zero callers anywhere outside their own test file — not from
resolvePrice, not from AggregatePrice, not from any admin handler.
They're real, working, and completely inert in the running system today — available for a
future admin dashboard signal, not load-bearing.
Reserve & proof-of-reserve
The collateral-ratio formula itself — and why it's a reconciliation-style computation — is
documented in full at Reconciliation → NAQD reserve
accounting, since it's read live from the ledger the same way every other integrity check
on this platform is. The short version: circulatingSupply = -balance(naqd_reserve, NAQD),
collateralRatio = totalReserveUSD / naqdValueUSD × 100, forced to 0
(fail-safe, not optimistic) whenever the oracle itself is down.
| Surface | Access | What it shows |
|---|---|---|
GET /naqd/reserves | public | Live reserve snapshot per currency |
GET /naqd/supply | public | Circulating supply (derived from SumCompletedOperations, not the ledger — a separate, cross-checkable figure) |
GET /admin/naqd/overview | admin role | Supply vs. reserve, collateral ratio, oracle status — the full admin proof-of-reserve view |
Economics: creation cost vs. issuance cost
The distinction from above, with numbers. These two costs are not the same order of magnitude, and conflating them is the single easiest way to misunderstand what backs a stablecoin.
| Token creation (one-time) | Issuance (per unit, recurring) | |
|---|---|---|
| What it is | Creating the SPL mint account / deploying the ERC-20 contract | Minting NAQD to a specific user's balance when they buy |
| Frequency | Once per chain, ever | Every mint operation, forever |
| Cost driver | Network/gas fee for one transaction | The actual market value of the silver the reserve must hold |
| Scales with supply? | No — flat, independent of eventual circulating supply | Yes — linearly, 1:1 with grams of silver |
| Wired in this codebase? | No — DeployNAQDToken/DeployERC20Token are unwired/stubbed; assumed done out-of-band | Yes — the entire mint worker path exists to do exactly this |
To have N NAQD outstanding, the reserve must hold N grams of
silver (or its cash equivalent, per the platform's fiat-backed model — the reserve
account holds USD/MVR sized to the silver value, not physical bars). This is a direct
consequence of the 1:1 peg and the mint ledger posting: every mint debits the user for
exactly the gram-priced fiat cost and credits naqd_reserve that same amount.
Assumption for the table below: silver at $32.00/oz (the same figure used in every worked example across this site — not live market data) → gram price $1.0288, from the formula at the top of this page.
| NAQD outstanding | Silver required | Silver required | Reserve value @ $32.00/oz |
|---|---|---|---|
| 1,000 | 1,000 g | 1.0 kg | $1,028.80 |
| 10,000 | 10,000 g | 10 kg | $10,288.00 |
| 100,000 | 100,000 g | 100 kg | $102,880.00 |
| 1,000,000 | 1,000,000 g | 1,000 kg (1 metric ton) | $1,028,800.00 |
At 1,000,000 NAQD outstanding the platform is, by construction, standing behind a full metric ton of silver's worth of reserve value — a concrete way to see that "backed" here means the ledger's naqd_reserve account genuinely carries that much fiat, not a promise.
Limits, fees, maker-checker thresholds
| Env var | Default | Governs |
|---|---|---|
| NAQD_MINT_MIN_USD | 10 | Minimum USD-equivalent per mint |
| NAQD_MINT_DAILY_LIMIT_USD | 10,000 | Sum of today's pending+processing+completed+requires_approval mints |
| NAQD_BURN_MIN | 10 NAQD | Minimum per burn |
| NAQD_BURN_DAILY_LIMIT | 1,000 NAQD | Daily burn ceiling |
| NAQD_BURN_FEE_BPS | 50 (0.5%) | Burn fee, in basis points of gross fiat value |
| NAQD_APPROVAL_THRESHOLD_USD | 5,000 | Maker-checker gate — same threshold for mint, burn, withdraw |
| NAQD_MIN_COLLATERAL_RATIO | 110 | Below this, reserves are !isHealthy |
| NAQD_REBALANCE_THRESHOLD | 115 | Below this, rebalancingRequired even if still healthy |
| NAQD_WORKER_POLL_INTERVAL | 2s | Worker tick cadence |
| NAQD_WORKER_STUCK_AFTER | 2m | Crash-recovery requeue threshold for stuck processing rows |
| ORACLE_STALENESS_THRESHOLD | 15m | Last-known-good acceptance window |
| ORACLE_SOURCE_TIMEOUT | 5s | Per-source HTTP fetch timeout |
| CHAIN_ENV | sandbox | sandbox | live — selects the ChainClient implementation only |
| METALS_API_KEY | unset | If unset, MetalsAPISource is skipped entirely (not treated as a failed source) |
Every one of these is read via local os.Getenv-wrapping helpers inside stablecoin-service/oracle-service themselves — not centralized in internal/core/config/config.go, which only holds the blockchain address/key vars. A reader searching only config.go would miss all of the above.
Honest limitations
- Live-chain dual-write gap. Quoted directly from
internal/blockchain-service/README.md: "there's a narrow window between a live chain call succeeding and the platform's DB recording its tx hash — a crash in that exact window causes stablecoin-service's worker to resubmit the chain call on recovery."sandboxChaincloses this for itself (idempotent per reference); Solana and Polygon have no equivalent dedup primitive yet. Real for anyone running CHAIN_ENV=live today, not hypothetical. - No automated token-creation path.
DeployNAQDTokenandDeployERC20Tokenexist but are never called by anything — the mint account / ERC-20 contract are assumed to already exist via configured addresses. The Polygon deploy function is a literal not-implemented stub. Bootstrapping the token is an out-of-band, manual step today — see Tokens, Keys & Chain Choice → SPL Token vs. Token-2022 for the token-program decision that step involves and the official CLI commands for it. - On-chain metadata previously said the wrong metal.
solana/naqd_token.gosetNAQDName = "MyNex Gold"for a silver-backed coin. Fixed — it now reads"MyNEX NAQD (Silver)". It was caught before any metadata was published on chain, which matters: token metadata is set at mint creation and is not something you quietly correct afterwards. - Sandbox-first posture, same as the rest of the platform. CHAIN_ENV=sandbox is the default and needs zero external keys, mirroring the same design used for payments and — separately, and unrelated to NAQD itself — cards and partner rails, neither of which has a production implementation yet (Runbook → Going-live checklist, Security → Known limitations). CHAIN_ENV=live hard-fails at boot rather than silently falling back if the required address/key pairs aren't set.
- Outlier detection and confidence scoring are unwired. See the callout under Oracle internals above — real, tested, and currently inert.