Read DigiAssets on any address

Last verified against DigiAsset Core RPC · DigiByte Core v9.26.5 · Kubo 0.32.1

Read DigiAssets on any address

DigiAssets are tokens and NFTs carried by ordinary DigiByte outputs. The chain holds a few bytes per asset (an OP_RETURN with a hash), an indexer turns those bytes into balances and asset records, and IPFS holds the metadata and media the hash points at. This guide walks the read side the way DigiScope's asset browser does it: ask DigiAsset Core what an address holds, ask it for each asset's record, derive the metadata's IPFS address from the hash the chain stores, and fetch bytes from your own Kubo node. Nothing here depends on DigiScope; the numbers and hashes are real mainnet assets you can check yourself.

Who this is for. You are building a wallet, an explorer, a marketplace, or a bot that needs to show DigiAssets and their art. You have, or can run, a DigiByte node, DigiAsset Core and an IPFS Kubo node.

What you will build. A read pipeline with three calls and one derivation: holdings by address, asset record by index, issuance history by asset id, and SHA-256 → CIDv1 for the metadata. Plus the two safety rules that keep an asset explorer from becoming an XSS or SSRF surface.

Before you start

The DigiAsset read pipeline Your application asks DigiAsset Core over JSON-RPC on port 14024 for holdings, asset data and issuances, and asks a local IPFS Kubo node on port 5001 for the metadata and media bytes by CID. DigiAsset Core indexes the DigiByte node on port 14022. Calls to DigiAsset Core are queued so at most two are in flight. your application any language, any stack DigiAsset Core RPC :14024 JSON-RPC 1.0 · positional params getaddressholdings · getassetdata · listassetissuances IPFS Kubo :5001 /api/v0/cat?arg=<cid> · pinned bytes DigiByte Core :14022 blocks + txs it indexes ≤ 2 in flight by CID only Core answers with indexes and hashes; Kubo answers with bytes. Nothing here fetches an http(s) URL.
Your application talks to DigiAsset Core for indexes and hashes and to Kubo for bytes. Neither hop fetches a URL.

Step 1 — Talk to DigiAsset Core without wedging it

The RPC is plain JSON-RPC 1.0 over HTTP with basic auth. Two things matter more than the envelope: parameters are positional arrays, and you must bound concurrency. DigiScope's client is a fifty-line queue.

from backend/src/services/digiasset-rpc.js

// DigiAsset Core has a small worker pool and leaks CLOSE-WAIT sockets under
// concurrent load (observed Apr 22 2026: ~7+ concurrent in-flight requests
// exhausts the pool and wedges the RPC thread while block processing
// continues). Cap concurrency at 2 — sequential-ish but safe — and let
// callers queue. Pair with watchdog in ops/digiasset-core-watchdog/.
const MAX_CONCURRENT = parseInt(process.env.DIGIASSET_RPC_MAX_CONCURRENT || '2', 10);
const CALL_TIMEOUT_MS = parseInt(process.env.DIGIASSET_RPC_TIMEOUT_MS || '15000', 10);
// …
function acquireSlot() {
  if (inFlight < MAX_CONCURRENT) {
    inFlight++;
    return Promise.resolve();
  }
  return new Promise(resolve => queue.push(resolve));
}

function releaseSlot() {
  const next = queue.shift();
  if (next) {
    next();
  } else {
    inFlight--;
  }
}

from backend/src/services/digiasset-rpc.js

async function rpcCall(method, params = []) {
  await acquireSlot();
  const id = ++requestId;
  const body = JSON.stringify({ jsonrpc: '1.0', method, params, id });
// …
    const response = await fetch(RPC_URL, {
      method: 'POST',
      headers,
      body,
      signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
    });

    if (!response.ok) {
      const text = await response.text().catch(() => '');
      throw new Error(`DigiAsset RPC ${method} failed (${response.status}): ${text}`);
    }

    const data = await response.json();
    if (data.error) {
      throw new Error(`DigiAsset RPC ${method} error: ${data.error.message || JSON.stringify(data.error)}`);
    }

    return data.result;
  } finally {
    releaseSlot();
  }
}

Trap: The cap is not a performance knob. Raising it "for throughput" reproduces the wedge: Core stops answering RPC while it keeps indexing blocks, and the only cure is a restart. If you run more than one process against the same Core, split the budget between them.

Step 2 — Holdings by address, and the key you must drop

getaddressholdings <address> returns a map of assetIndex → quantity. It includes the key "1": Core's reserved asset index for DigiByte itself. Its value is the satoshis sitting in the UTXOs Core has indexed for that address — and with the default storenonassetutxo=0, Core only indexes outputs of transactions that carry a DigiAsset payload — so it is neither an asset nor the address's balance (DJENMF… reports "1": 13800, 0.000138 DGB, while the address holds about 256 DGB). Drop it before you do anything else, and never display it as a balance.

from backend/src/services/digiasset-rpc.js

export async function getAddressHoldings(address) {
  const result = await rpcCall('getaddressholdings', [address]);
  // Filter out assetIndex "1" (native DGB)
  delete result['1'];
  return result;
}

export async function getAssetData(assetIndex) {
  return rpcCall('getassetdata', [parseInt(assetIndex)]);
}

export async function getAssetHolders(assetIndex) {
  return rpcCall('getassetholders', [parseInt(assetIndex)]);
}

export async function listAssetIssuances(assetId) {
  return rpcCall('listassetissuances', [String(assetId)]);
}

export async function getSyncState() {
  return rpcCall('syncstate');
}

A real answer, for an address that holds a lot of assets:

{ "1": 13800, "2870": 1, "4956": 496600000, "4961": 100000000, "5150": 10, … }

Note the two big numbers. Asset 4956 has decimals: 5 and asset 4961 has decimals: 2; the quantities are what the protocol stored, not what a person expects to read. Step 4 explains when to divide.

Step 3 — The asset record and its issuance history

getassetdata <assetIndex> is the asset's record; listassetissuances <assetId> is the list of issuance transactions (one, for a locked asset). DigiScope gates both behind a sync check, caches the record for a day when the metadata resolved and for five minutes when it did not, and adds the issuance list to the response under issuances.

from backend/src/controllers/digiassets.js

// In-memory cache for asset metadata (24h TTL)
const ASSET_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms
const FAILED_ASSET_CACHE_TTL = 5 * 60 * 1000; // 5 min — half-resolved metadata (IPFS hiccup): re-check soon
const assetCache = new Map(); // assetIndex -> { data, expiry }

// In-memory cache for sync state (30s TTL)
const SYNC_CACHE_TTL = 30 * 1000; // 30 seconds in ms
// …
  const data = await getAssetData(assetIndex);
  // Core returns a successful result with ipfs.data absent when it can't resolve
  // metadata (e.g. IPFS wedged). Don't pin those nulls for the full 24h — give
  // half-resolved results a short TTL so they re-fetch and self-heal quickly.
  const metadataResolved = !!(data && data.ipfs && data.ipfs.data);
  const ttl = metadataResolved ? ASSET_CACHE_TTL : FAILED_ASSET_CACHE_TTL;
  assetCache.set(assetIndex, { data, expiry: now + ttl });
  return data;

from backend/src/controllers/digiassets.js

    // Check sync state first
    const syncState = await getCachedSyncState();
    const blocksBehind = Math.abs(syncState?.sync ?? 0);
    if (blocksBehind > 120) {
      return res.status(503).json({ syncing: true, blocksBehind });
    }

    // Fetch holdings (assetIndex 1 already filtered in RPC service)
    const holdings = await getAddressHoldings(address);

    // Fetch metadata for each held asset in parallel
    const assetEntries = Object.entries(holdings);
    const assetDataResults = await Promise.all(
      assetEntries.map(async ([assetIndex, quantity]) => {
        try {
          const assetData = await getCachedAssetData(assetIndex);
          return {
            assetIndex: parseInt(assetIndex),
            quantity,
            assetId: assetData?.assetId ?? null,
            name: assetData?.ipfs?.data?.assetName ?? null,
            description: assetData?.ipfs?.data?.description ?? null,
            mediaUrl: extractMediaCID(assetData),
            locked: lockedFromAssetId(assetData?.assetId),
            decimals: assetData?.decimals ?? 0,
          };

The record for asset 4956 ("The Fart Asset"), trimmed:

{
  "assetId": "…", "assetIndex": 4956,
  "cid": "bafkreidehqswhmtk7qklfbn7w5xs5grszyfyxopaczn5sfv6g4qcteetdu",
  "count": 5496900000, "initialCount": 5500000000, "decimals": 5,
  "height": 19941021, "issuer": { "address": "DJENMFWXGccx2jsjzJPWfprSzbA4xT7wbp" },
  "rules": { "changeable": false, "royalty": { … } },
  "ipfs": { "data": { "assetName": "The Fart Asset", "description": "…",
            "urls": [ { "mimeType": "image/jpeg", "name": "icon", "url": "ipfs://QmVcN1wYvZU7fcrUBi52nDwqvXFMdU8JNTAaCfYTrokqUr" } ] } }
}

cid is the metadata's IPFS address, derived by Core from the hash in the issuance (Step 5). rules is absent when an asset has none and never carries locked: Core's DigiAssetRules::toJSON serialises changeable, deflation, expiry, royalty, geofence, voting and approval only. The lock is encoded in two other places, the issuance payload's flags byte (bit 0x10, which the mint guide decodes) and the asset ID's first character (L locked, U unlocked; the second character is the aggregation policy, a/h/d), which is also how Core's own legacy-stream compatibility layer derives it. DigiScope read rules.locked until this guide was written and showed every asset as unlocked; the excerpt above is the corrected line. Promise.all over the holdings is safe only because every call funnels through the two-slot queue from Step 1.

from backend/src/controllers/digiassets.js

    const assetData = await getCachedAssetData(assetIndex);
    let issuances = [];
    if (assetData?.assetId) {
      try {
        issuances = await listAssetIssuances(assetData.assetId);
      } catch (e) {
        logger.warn(`Failed to fetch issuances for ${assetData.assetId}: ${e.message}`);
      }
    }

    return res.json({
      ...assetData,
      issuances,
    });

Holders come from getassetholders <assetIndex>, a map of address → quantity, which DigiScope sorts into an array:

from backend/src/controllers/digiassets.js

    const holdersMap = await getAssetHolders(assetIndex);

    // Convert map to sorted array (descending by quantity)
    const holders = Object.entries(holdersMap)
      .map(([address, quantity]) => ({ address, quantity }))
      .sort((a, b) => b.quantity - a.quantity);

    return res.json({ holders, total: holders.length });

Check: getaddressholdings for the issuer of asset 4956 and getassetholders 4956 must agree on that address's quantity. On the day of writing both said 496600000.

Step 4 — What a quantity means

Every quantity DigiAsset Core reports (count, initialCount, getassetholders values, getaddressholdings values, listassetissuances[].amount) is an integer straight from the protocol. Whether you divide it by 10^decimals for display depends on the version byte of the issuance, and Core follows the version:

The DigiByte Android wallet applies the same rule when it decodes an issuance itself:

from core/src/main/java/io/digibyte/core/asset/DigiAssetDecoder.kt

            // Fix amount for version 1: quantity was scaled by 10^divisibility
            if (version == 1 && totalQuantity != null && divisibility > 0) {
                val pow10 = pow10Table[divisibility]
                totalQuantity = totalQuantity / pow10
            }

Known limitation: getassetdata does not expose the issuance's version byte, so a reader cannot tell from the record alone which rule applies. To be exact, decode the issuance transaction's OP_RETURN (the mint guide shows the layout) and read byte 2. DigiScope's own browser shows Core's integers as they are, which reads correctly for version-1 assets and reads 10^decimals too large for version-3 assets with decimals.

Step 5 — From the on-chain hash to the metadata, without asking anyone

The issuance stores the SHA-256 of the metadata JSON. An IPFS CIDv1 for raw bytes is just that digest with a four-byte prefix, base32-encoded, so you can compute the metadata's address yourself and fetch it from your own node. Core does exactly this to fill cid; the Android wallet does it too:

from core/src/main/java/io/digibyte/core/asset/DigiAssetDecoder.kt

    private fun sha256ToCidV1(hash: ByteArray): String {
        // CID v1: 0x01 (version) + 0x55 (raw codec) + 0x12 (sha2-256) + 0x20 (32 bytes) + hash
        val cidBytes = ByteArray(4 + 32)
        cidBytes[0] = 0x01
        cidBytes[1] = 0x55
        cidBytes[2] = 0x12
        cidBytes[3] = 0x20
        hash.copyInto(cidBytes, 4)
        return "b" + base32Encode(cidBytes)
    }
From the on-chain SHA-256 to a CIDv1 Prefix the 32-byte SHA-256 with four bytes: 0x01 (CID version 1), 0x55 (raw codec), 0x12 (sha2-256) and 0x20 (32 bytes). Base32-encode the 36 bytes in lowercase without padding and prepend the letter b. Every such CID begins with bafkrei. 36 bytes 01 CID v1 55 raw codec 12 sha2-256 20 32 bytes 643c2563 b26afc14 … 2990931d the SHA-256 the issuance put on-chain base32, lowercase, no padding, then prepend the multibase letter b bafkreidehqswhmtk7qklfbn7w5xs5grszyfyxopaczn5sfv6g4qcteetdu bafkrei is the fixed prefix of every raw-leaf sha2-256 CIDv1; the rest is the digest.
Four fixed bytes, the digest, base32 without padding, and the multibase letter b. The result always starts with bafkrei.

The same derivation in four lines of Node, and in Python:

import { createHash } from 'node:crypto';
const A = 'abcdefghijklmnopqrstuvwxyz234567';
const b32 = (buf) => { let bits = '', out = ''; for (const b of buf) bits += b.toString(2).padStart(8, '0'); for (let i = 0; i < bits.length; i += 5) out += A[parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)]; return out; };
export const cidForSha256 = (digest) => 'b' + b32(Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]));
// cidForSha256(createHash('sha256').update(metadataBytes).digest())
import base64, hashlib
def cid_for_sha256(digest: bytes) -> str:
    return "b" + base64.b32encode(b"\x01\x55\x12\x20" + digest).decode().lower().rstrip("=")
# cid_for_sha256(hashlib.sha256(metadata_bytes).digest())

Two real vectors: asset 4956's metadata hashes to 643c2563b26afc14b285bfb76f2e9a32ce0b8bb9e0165bd916be37202990931d and its cid is bafkreidehqswhmtk7qklfbn7w5xs5grszyfyxopaczn5sfv6g4qcteetdu; asset 5387's metadata hashes to d4e192a6a6cc44adc519a3d49872c332611986dfbcba614ddd335d57e9bec035 and its cid is bafkreigu4gjknjwmisw4kgnd2smhfqzsmemynx54xjqu3xjtlvl6tpwagu.

Trap: The derivation only holds for content that IPFS stored as a single raw leaf, which means files up to the default 256 KiB chunk size added with --cid-version=1 --raw-leaves. Metadata JSON always qualifies. Media usually does not: asset 5387's icon is bafybeihwi3p6…, a dag-pb root whose CID is not the SHA-256 of the file. Never "derive" a media CID; read it from urls[].url.

Step 6 — Media: pick by MIME type, fetch by CID, serve safely

The metadata's urls[] entries carry a url and a mimeType. DigiScope takes the first entry whose type starts with image/, regardless of its name field, and strips the ipfs:// prefix:

from backend/src/controllers/digiassets.js

function extractMediaCID(assetData) {
  try {
    const urls = assetData?.ipfs?.data?.urls;
    if (!Array.isArray(urls)) return null;

    for (const entry of urls) {
      if (!entry || typeof entry !== 'object') continue;
      const mimeType = entry.mimeType || entry.mime || '';
      if (!mimeType.startsWith('image/')) continue;

      const url = entry.url || entry.cid || '';
      if (!url) continue;

      // Extract CID from ipfs:// URI or raw CID string
      if (url.startsWith('ipfs://')) {
        const cid = url.slice(7).split('/')[0];
        if (isValidCID(cid)) return cid;
      } else if (isValidCID(url)) {
        return url;
      }
    }
  } catch (err) {
    // Silently ignore parse errors
  }
  return null;
}
What DigiAsset Core pins, and what it does not The on-chain SHA-256 identifies the metadata JSON, which DigiAsset Core fetches and pins. Inside that JSON, urls[].url is a plain string such as ipfs://Qm… or https://…; it is not an IPFS link, so pinning the metadata never pins the artwork. Most historic asset art is therefore unpinned, and http(s) art can never be preserved by an IPFS node. issuance OP_RETURN 32-byte SHA-256 → bafkrei… (derived) metadata JSON {"data":{"assetName", "urls":[…]}} pinned by DigiAsset Core ipfs://Qm… art a string, not a DAG link: not pinned https://… art no IPFS node can preserve it derive + cat the pin boundary Measured on this index: metadata held for 99% of assets, image bytes held for about 0.5%.
Core pins the metadata it derived the CID for. The art behind urls[].url is a string to Core, so it is not pinned, and http(s) art cannot be pinned by anyone.

Validate the CID before you touch the network, then fetch from your Kubo:

from backend/src/services/ipfs-proxy.js

// Accepts CIDv0 (Qm...) and CIDv1 base32 (bafy... dag-pb, bafk... raw leaf).
// CIDv1 base32 is length 59 for sha-256; we use >=46 to be conservative.
const CID_REGEX = /^(Qm[1-9A-HJ-NP-Za-km-z]{44,}|baf[a-z0-9]{50,})$/;

export function isValidCID(cid) {
  return CID_REGEX.test(cid);
}

Security: IPFS content is user-uploaded. If you serve it from the same origin as your app with the MIME type it claims, an SVG or HTML "image" can carry a script and you have stored XSS. Sniff real raster magic bytes, force everything else to application/octet-stream, and send X-Content-Type-Options: nosniff. Never fetch an http(s):// URL from urls[] server-side either: that is an SSRF hole pointed at whatever your server can reach.

from backend/src/services/ipfs-proxy.js

  // Deliberately NOT sniffing SVG/XML/HTML: these can carry <script> and would let
  // user-uploaded IPFS content execute JS on our origin if ever served inline. They
  // fall through to application/octet-stream (and the route's safeInlineContentType
  // allowlist forces non-raster types to octet-stream regardless).
  return null;
}

export async function detectContentType(buffer) {
  // 1. Fast, dependency-free magic bytes (covers PNG/JPEG/GIF/WebP/BMP/PDF/SVG).
  const sniffed = sniffMagicBytes(buffer);
  if (sniffed) return sniffed;

  // 2. file-type (covers more formats) if available.
  try {
    const { fileTypeFromBuffer } = await import('file-type');
    const type = await fileTypeFromBuffer(buffer);
    if (type) return type.mime;
  } catch { /* file-type module absent or detection failed — fall through to next detector */ }

  // 3. Valid JSON (asset metadata) stays application/json.
  try {
    JSON.parse(buffer.toString('utf-8'));
    return 'application/json';
  } catch { /* not valid JSON — fall through to application/octet-stream */ }

  return 'application/octet-stream';
}

// Only these render inline as art and cannot execute scripts in an <img>.
const SAFE_INLINE_IMAGE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp']);
// …
export async function safeInlineContentType(buffer) {
  const detected = await detectContentType(buffer);
  return SAFE_INLINE_IMAGE.has(detected) ? detected : 'application/octet-stream';
}

The step-1 comment overstates itself: sniffMagicBytes never returns SVG. It recognises only the five raster types (PNG, JPEG, GIF, WebP, BMP) plus PDF, and the SAFE_INLINE_IMAGE allowlist keeps PDF out of inline rendering too, so SVG, HTML and PDF all reach the browser as application/octet-stream.

The route that serves the DigiByte Android wallet applies that allowlist, answers only for CIDs already pinned locally, and 404s fast so the wallet can fall through to public gateways:

from backend/src/server.js

app.get('/api/ipfs/:cid', walletLimiter, async (req, res) => {
  const { cid } = req.params;
  if (!isValidCID(cid)) {
    return res.status(400).json({ error: 'invalid cid format' });
  }
  try {
    const buf = await fetchLocalPinned(cid);
    if (!buf) return res.status(404).json({ error: 'cid not pinned locally' });
// …
    res.setHeader('Content-Type', await safeInlineContentType(buf));
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
    res.send(buf);
  } catch (e) {
    res.status(503).json({ error: e.message || 'ipfs unavailable' });
  }
});

Content addressed by CID never changes, so immutable caching is correct and a CDN in front of it is free scale.

Verify it

No DigiScope involved: your DigiAsset Core, your Kubo, one real asset.

  1. Confirm the index is live and synced:

    curl -s -u user:pass -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"1.0","id":1,"method":"syncstate","params":[]}' http://127.0.0.1:14024/
    

    Expected: {"result":{"count":<height>,"sync":0}, …}. Anything but a small sync means your holdings answers are stale.

  2. Fetch asset 4956's record and note cid, count, decimals:

    curl -s -u user:pass -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"1.0","id":1,"method":"getassetdata","params":[4956]}' http://127.0.0.1:14024/
    
  3. Fetch the metadata bytes from your own Kubo by that CID, hash them, and derive the CID back:

    curl -s -X POST "http://127.0.0.1:5001/api/v0/cat?arg=bafkreidehqswhmtk7qklfbn7w5xs5grszyfyxopaczn5sfv6g4qcteetdu" > meta.json
    sha256sum meta.json     # 643c2563b26afc14b285bfb76f2e9a32ce0b8bb9e0165bd916be37202990931d
    python3 -c 'import base64,hashlib; d=hashlib.sha256(open("meta.json","rb").read()).digest(); print("b"+base64.b32encode(b"\x01\x55\x12\x20"+d).decode().lower().rstrip("="))'
    

    Expected: the derived CID equals the one you fetched by. If your node has not seen the bytes, cat blocks while it searches the network; a --only-hash add of any local file with --cid-version=1 --raw-leaves is the offline way to test the derivation itself.

  4. Read the holdings map raw and confirm the "1" key is present, then that your code drops it:

    curl -s -u user:pass -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"1.0","id":1,"method":"getaddressholdings","params":["DJENMFWXGccx2jsjzJPWfprSzbA4xT7wbp"]}' http://127.0.0.1:14024/
    

Check: Serve the icon and a hand-made SVG you add to your node through your own inline route and look at the response headers. The JPEG must come back as image/jpeg; the SVG must come back as application/octet-stream with X-Content-Type-Options: nosniff. If the SVG renders in the browser, you have shipped an XSS. Do not serve the metadata JSON through this route: the raster allowlist you just built will, correctly, return it as application/octet-stream. Read metadata through getassetdata and hand it to your client from your own JSON API.

Traps

Where DigiScope's implementation differs from the ideal

Next: the write side, in Issue and transfer a DigiAsset.