MyNEX Docs / NAQD Minting & Reserves
issuance ≠ token creation
The coin, deeply

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

Gram price formula
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.

Confirmed in code: only (b) is actually wired up

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:

internal/blockchain-service/polygon/erc20.go
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:

internal/blockchain-service/chain_client.go
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)
}
ImplementationSelected byNotes
sandboxChain (default)CHAIN_ENV unset or sandboxIn-memory map[address]decimal.Decimal; deterministic txRef; idempotent per (kind, reference) — see below
solanaChainCHAIN_ENV=liveSPL Token, 6 decimals; requires NAQD_TOKEN_MINT_ADDRESS + SOLANA_WALLET_PRIVATE_KEY; burn restricted to the treasury address
polygonChainCHAIN_ENV=liveERC-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

internal/blockchain-service/sandbox_chain.go
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:

internal/blockchain-service/keys.go
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_KEYENCRYPTION_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.

quote created usdEquivalent computed pending under approval threshold requires_approval usdEquivalent ≥ threshold admin approve rejected terminal — no reversal needed admin reject processing ClaimNextPending — guarded UPDATE completed tx_hash set, naqd.minted failed reverseAndFail + system.alert
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.
  1. Quote resolution. QuoteMint supports both amount-first (amount_naqd → USD cost) and spend-first (a fiat amount → NAQD out), via rate = silverPriceUSD / 31.1034768. No mint fee.
  2. Maker-checker gate, before any ledger write. usdEquivalent := quote.NAQDAmount.Mul(quote.RateUSDPerNAQD); if usdEquivalent ≥ NAQD_APPROVAL_THRESHOLD_USD (default 5000) the operation is created requires_approval instead of pending — identical pattern for mint, burn, and withdraw.
  3. Worker claims via a guarded UPDATEClaimNextPending reads the oldest pending row, then updates it to processing only if it's still pending; zero rows affected means another worker tick claimed it first between the read and the update:
    internal/stablecoin-service/repository.go
    result := 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
    }
  4. Ledger posts first, then the chain is called. Four-leg ledger.Post(KindNAQDMint, ...) (exact entries: Money Flows), then cc.Mint(ctx, cc.CustodyAddress(), op.NAQDAmount, op.Reference). Guarded by alreadyPosted(tx, reference) so a worker retry never double-posts.
  5. On chain success: finishOK sets Status: completed, TxHash, CompletedAt, publishes naqd.minted.
  6. On chain failure: reverseAndFail (quoted in full below) posts a compensating reversal and publishes system.alert — the fiat is never left debited with no NAQD delivered.
internal/stablecoin-service/worker.go — reverseAndFail, in full
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%):

internal/stablecoin-service/burning.go
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:

internal/blockchain-service/solana_chain.go (identical pattern in polygon_chain.go)
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-shapedledger.KindNAQDBurn, two NAQD-only legs, no fiat (Money Flows → on-chain withdrawal) — but the chain call is Transfer, not Burn:

internal/stablecoin-service/worker.go
// 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.

SourceWeight()Active when
CoinGeckoSource0.8always
MetalsAPISource1.0METALS_API_KEY set — a paid feed, skipped entirely rather than failing every aggregation when no key is configured
The weights are effectively decorative

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.

internal/oracle-service/aggregator.go — the function that actually runs
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.

internal/oracle-service/service.go — resolvePrice, in full
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.

Confirmed dead code: outlier detection and confidence scoring

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.

SurfaceAccessWhat it shows
GET /naqd/reservespublicLive reserve snapshot per currency
GET /naqd/supplypublicCirculating supply (derived from SumCompletedOperations, not the ledger — a separate, cross-checkable figure)
GET /admin/naqd/overviewadmin roleSupply 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 isCreating the SPL mint account / deploying the ERC-20 contractMinting NAQD to a specific user's balance when they buy
FrequencyOnce per chain, everEvery mint operation, forever
Cost driverNetwork/gas fee for one transactionThe actual market value of the silver the reserve must hold
Scales with supply?No — flat, independent of eventual circulating supplyYes — linearly, 1:1 with grams of silver
Wired in this codebase?No — DeployNAQDToken/DeployERC20Token are unwired/stubbed; assumed done out-of-bandYes — the entire mint worker path exists to do exactly this
The arithmetic that actually matters

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 outstandingSilver requiredSilver requiredReserve value @ $32.00/oz
1,0001,000 g1.0 kg$1,028.80
10,00010,000 g10 kg$10,288.00
100,000100,000 g100 kg$102,880.00
1,000,0001,000,000 g1,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 varDefaultGoverns
NAQD_MINT_MIN_USD10Minimum USD-equivalent per mint
NAQD_MINT_DAILY_LIMIT_USD10,000Sum of today's pending+processing+completed+requires_approval mints
NAQD_BURN_MIN10 NAQDMinimum per burn
NAQD_BURN_DAILY_LIMIT1,000 NAQDDaily burn ceiling
NAQD_BURN_FEE_BPS50 (0.5%)Burn fee, in basis points of gross fiat value
NAQD_APPROVAL_THRESHOLD_USD5,000Maker-checker gate — same threshold for mint, burn, withdraw
NAQD_MIN_COLLATERAL_RATIO110Below this, reserves are !isHealthy
NAQD_REBALANCE_THRESHOLD115Below this, rebalancingRequired even if still healthy
NAQD_WORKER_POLL_INTERVAL2sWorker tick cadence
NAQD_WORKER_STUCK_AFTER2mCrash-recovery requeue threshold for stuck processing rows
ORACLE_STALENESS_THRESHOLD15mLast-known-good acceptance window
ORACLE_SOURCE_TIMEOUT5sPer-source HTTP fetch timeout
CHAIN_ENVsandboxsandbox | live — selects the ChainClient implementation only
METALS_API_KEYunsetIf 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." sandboxChain closes 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. DeployNAQDToken and DeployERC20Token exist 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.go set NAQDName = "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.