Read DigiAssets on any address
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
- DigiAsset Core running against a synced DigiByte Core (v9.26.5 at the time of writing). It listens on
127.0.0.1:14024and speaks JSON-RPC 1.0 with positional parameters. Itshelpmethod echoes DigiByte Core's help, not the asset methods; the method names are in the excerpts below and you verify them by calling them. - IPFS Kubo (0.32.1 here) on
127.0.0.1:5001. You will only ever ask it for bytes by CID. - Patience with concurrency. DigiAsset Core has a small worker pool and wedges at roughly seven simultaneous requests. DigiScope caps itself at two.
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:
getaddressholdingsfor the issuer of asset 4956 andgetassetholders 4956must agree on that address's quantity. On the day of writing both said496600000.
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:
- Version 3 issuances (what current wallets and DigiAsset Core emit): the encoded amount is
count. Divide by 10^decimalsto display. Asset 4956 encodes 5,500,000,000 withdecimals: 5, soinitialCount: 5500000000is 55,000.00000 tokens. - Version 1 issuances (the original 2015 encoder, which DigiScope's own builder still uses): the reference decoders divide the encoded amount by 10^
divisibilitybefore reporting it, and so does Core. Asset 2 ("DigiVaultTest") encodes 10,000 withdivisibility: 2and Core reportscount: 100.
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:
getassetdatadoes 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)
}
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 isbafybeihwi3p6…, a dag-pb root whose CID is not the SHA-256 of the file. Never "derive" a media CID; read it fromurls[].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;
}
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 sendX-Content-Type-Options: nosniff. Never fetch anhttp(s)://URL fromurls[]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.
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 smallsyncmeans your holdings answers are stale.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/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,
catblocks while it searches the network; a--only-hashadd of any local file with--cid-version=1 --raw-leavesis the offline way to test the derivation itself.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 asapplication/octet-streamwithX-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 asapplication/octet-stream. Read metadata throughgetassetdataand hand it to your client from your own JSON API.
Traps
- The key
"1".getaddressholdingsincludes DigiByte itself under key"1". The value is the satoshis in Core-indexed, asset-bearing outputs, not the address's balance. Delete it or you will list a phantom asset, or a wildly wrong DGB balance, on every address. rules.lockeddoes not exist. Derive lock status from the asset ID's first letter (Llocked,Uunlocked) or from the issuance flags byte, never fromgetassetdata.rules.- Positional parameters. Named parameters are silently wrong;
getassetdatawants[4956], andlistassetissuanceswants the asset id string, not the index. - Concurrency. Seven in-flight requests wedge DigiAsset Core. Queue to two per process; if you add a process, give it one.
- Wedge-safe caching. A record whose
ipfs.datais missing is not an error, it is a half-answer; cache it for minutes, not a day, so it heals. - Quantities are version-dependent. Version 1 amounts are divided by 10^divisibility by Core and by the reference decoders; version 3 amounts are raw base units. Decode the issuance if you must be exact.
- Art decay. Core pins metadata, never the art it points to. Do not promise users their art is preserved because "it is on IPFS"; pin it yourself, hash-first, under the CID form the metadata names.
- Never fetch
http(s)://art server-side, never serve SVG/HTML inline. One is SSRF, the other is stored XSS. - Do not derive media CIDs. Only single-chunk raw leaves satisfy the SHA-256 identity; media is usually chunked dag-pb.
Where DigiScope's implementation differs from the ideal
- Two IPFS routes with two content-type policies: the asset browser's proxy serves detected types and 302s to a public gateway when the local node cannot serve, while the wallet gateway route applies the raster allowlist and 404s fast. One policy would be cleaner; the split exists because the two consumers need different fallbacks.
- Two sync thresholds: the address and asset endpoints answer 503 when the index is more than 120 blocks behind, while the standalone sync endpoint reports
syncedonly at zero. - The asset browser's proxy does not send
X-Content-Type-Options: nosniff; the wallet gateway does. - Until this guide was written,
lockedwas read fromrules.locked, a field Core never returns, so every asset read as unlocked, the browse index held 0 locked rows against 511L…IDs, and the "Locked" filter was always empty. Both mapping sites now read the asset-ID prefix; the browse index'slockedcolumn refreshes as the nightly metadata sweep re-enriches rows. - Quantities are shown as Core returns them, without decoding the issuance version (Step 4).
- The concurrency cap is per process; a second indexer process would need its own, lower cap.
Next: the write side, in Issue and transfer a DigiAsset.