Issue and transfer a DigiAsset
Issue and transfer a DigiAsset
Issuing a DigiAsset is one DigiByte transaction with three outputs and a 60-byte note; transferring one is a transaction whose note says which output gets how many units. This guide walks DigiScope's own issuance builder step by step, decodes a real mainnet mint byte by byte, and then uses the open-source DigiByte Android wallet as the reference for transfers, because DigiScope offers no user-facing transfer (only an admin/Trust-L3 test harness on the legacy digiasset-transaction-builder 0.2.3), so the wallet is the better reference. It is also honest about what DigiScope's builder cannot do yet.
Who this is for. You are adding "create a token" or "send a token" to a wallet, a marketplace, or a service, and you want to build the transaction yourself against DigiByte Core and IPFS Kubo rather than trust a hosted API.
What you will build. Metadata JSON pinned as a raw leaf, its two hashes, an OP_RETURN payload, an unsigned transaction with the outputs in the right order, the asset ID derived before broadcast, and a money path that can never charge twice or refund on doubt. Then a transfer whose remainder lands where you intend.
Before you start
- DigiByte Core v9.26.5 with a funded wallet, and IPFS Kubo (0.32.1 here). DigiAsset Core is only needed to read the result back.
- The on-chain amount you encode is the protocol's integer. DigiAsset Core treats a version-3 amount as base units and divides a version-1 amount by 10^divisibility (see the read guide's Step 4). DigiScope's builder emits version 1, so it accepts whole units only.
- Dust rules. DigiByte Core 9.26 raised the dust floor; DigiScope's issuance marker is 10,000 sats and the Android wallet's transfer marker is 6,000. A 600-sat marker from old tutorials is rejected as dust.
Step 1 — Metadata: one JSON, pinned as a raw leaf
Wallets expect {"data": {…}} with assetName, description, an issuer name (not an address), and urls[] whose entries carry url, mimeType, and name.
from backend/src/services/digiasset-creation-service.js
function buildMetadataJSON(params, mediaCIDs) {
const inner = {
assetName: params.name,
description: params.description,
issuer: params.issuer_name || 'DigiScope',
urls: [],
site: { url: 'https://digiscope.me' },
};
if (mediaCIDs && mediaCIDs.length > 0) {
mediaCIDs.forEach((cid, i) => {
inner.urls.push({
url: `ipfs://${cid.hash}`,
mimeType: cid.mimeType || 'application/octet-stream',
name: i === 0 ? 'icon' : `media_${i}`,
});
});
}
if (params.metadata && Object.keys(params.metadata).length > 0) {
inner.userData = { meta: params.metadata };
}
return { data: inner };
}
Upload media first, then the JSON, both with cid-version=1&raw-leaves=true. The raw-leaf flag is what makes the metadata's CID equal to its SHA-256 (the read guide derives it), so readers can find your JSON from the hash alone:
from backend/src/services/digiasset-creation-service.js
async function uploadToIPFS(buffer, filename) {
// …
// CIDv1 raw leaves so the pinned CID matches the SHA-256 the wallet
// reconstructs from the on-chain issuance opcode. See digiasset-test-service.js
// uploadToIPFS for the full explanation.
const res = await fetch(`${IPFS_API_URL}/add?pin=true&cid-version=1&raw-leaves=true`, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
body,
signal: AbortSignal.timeout(60000),
});
if (!res.ok) throw new Error(`IPFS upload failed: ${res.status}`);
const data = await res.json();
return data.Hash;
}
Hash the exact bytes you uploaded. The payload carries a SHA-256 and, in version 1, a 20-byte SHA-1 that the protocol calls the torrent hash:
from backend/src/services/digiasset-creation-service.js
const metadataBuffer = Buffer.from(JSON.stringify(metadataJSON));
const metadataCID = await uploadToIPFS(metadataBuffer, 'metadata.json');
// …
// 3. Compute torrent hash and SHA256 of metadata
const sha2 = crypto.createHash('sha256').update(metadataBuffer).digest('hex');
// For torrent hash, use first 20 bytes of SHA1
const torrentHash = crypto.createHash('sha1').update(metadataBuffer).digest('hex');
Trap: Hash the buffer you uploaded, not a re-serialised copy. A different key order or whitespace gives a different SHA-256, a different derived CID, and an asset whose metadata nobody can find.
Step 2 — The OP_RETURN payload
The header is 44 41 ("DA"), a version byte and an opcode. In version 1, opcode 01 means both hashes are in the OP_RETURN: 20 bytes of SHA-1, then 32 of SHA-256. Then the amount in SFFC, a variable-width integer (one byte for values under 32, otherwise a three-bit bucket prefix, a mantissa and a base-10 exponent, up to seven bytes), then zero or more transfer instructions, each a flags-and-output byte followed by an SFFC amount, and finally one flags byte: divisibility in the top three bits, the lock flag, two bits of aggregation policy, two unused. The instructions matter more than they look: DigiAsset Core credits any issued units the payload does not assign to the transaction's last output, which in the layout below is the change, not the recipient. DigiScope keeps the vendored 2015 encoder for exactly this packing and wraps it:
from backend/src/services/digiasset-encoder.js
const PROTOCOL_DIGIASSET = 0x4441; // "DA"
const PROTOCOL_VERSION = 0x01;
const OP_RETURN_BYTE_LIMIT = 80;
from backend/src/services/digiasset-encoder.js
const encoded = issuanceEncoder.encode(
{
protocol: PROTOCOL_DIGIASSET,
version: PROTOCOL_VERSION,
amount,
divisibility,
lockStatus,
aggregationPolicy,
sha2: toBuf(sha2),
torrentHash: toBuf(torrentHash),
payments,
},
OP_RETURN_BYTE_LIMIT
);
if (encoded.leftover && encoded.leftover.length > 0) {
throw new Error('Issuance payload exceeds OP_RETURN byte limit — would require multisig overflow (not supported in v1 of this encoder)');
}
return encoded.codeBuffer;
A real payload, from asset 5387's issuance transaction ad50b42ab8551e7fcb54c17c68fda402529b7a78f6e5f61b398cb5479e0f040e, minted in August 2026 before the instruction was added:
6a 3a OP_RETURN, push 58
44 41 01 01 "DA", version 1, opcode 1
f2a90fb07fbbc7ec44e8fbd63ec6555f0a57dd65 SHA-1 of the metadata (20)
d4e192a6a6cc44adc519a3d49872c332611986dfbcba614ddd335d57e9bec035 SHA-256 (32)
14 amount: SFFC single byte = 20
10 flags: 000 1 00 00 → divisibility 0, locked, aggregatable
Decoding the same bytes with the vendored encoder gives { amount: 20, divisibility: 0, lockStatus: true, aggregationPolicy: 'aggregatable', payments: [] }, and the SHA-256 derives to the asset's cid, bafkreigu4gjknjwmisw4kgnd2smhfqzsmemynx54xjqu3xjtlvl6tpwagu. With no instruction, Core credited all 20 units to the last output, the treasury change, and the recipient's marker held nothing. The builder now emits two more bytes between the amount and the flags, 00 14: flags-and-output 00 (skip 0, range 0, percent 0, output 0) and SFFC amount 20. The 60-byte payload decodes with payments: [{ output: 0, amount: 20 }], and the units land on the marker.
from backend/src/services/digiasset-tx-builder.js
const opReturnHex = buildIssuanceOpReturn({
amount,
divisibility,
lockStatus,
aggregationPolicy,
sha2,
torrentHash,
// DigiAsset Core credits any units the payload does not assign to the
// transaction's LAST output (DigiByteTransaction.cpp: lastOutput =
// _outputs.size() - 1). Without this instruction every DigiScope mint before
// 2026-09 landed on the treasury change output and was destroyed by the next
// treasury spend. Name output 0 — the recipient's dust marker — explicitly.
payments: [{ skip: false, range: false, percent: false, output: 0, amount }],
}).toString('hex');
Step 3 — The asset ID comes from your first input
A locked asset's ID is deterministic: hash160 of the string txid:vout of the transaction's first input, wrapped with a two-byte padding that encodes locked plus the aggregation policy and a two-byte divisibility, base58check-encoded. You can compute it before you broadcast, which is how DigiScope records asset_id on the creation row up front.
from backend/src/services/digiasset-encoder.js
// Locked-asset padding values per DigiAsset v3 spec. Locked = the asset's
// supply is fixed at issuance (most common case — what /assets/create uses).
const LOCKED_PADDING = {
aggregatable: 0x20ce,
hybrid: 0x2102,
dispersed: 0x20e4,
};
function hash160(buf) {
const sha = crypto.createHash('sha256').update(buf).digest();
return crypto.createHash('ripemd160').update(sha).digest();
}
export function computeLockedAssetId({
firstInputTxid,
firstInputVout,
aggregationPolicy,
divisibility,
}) {
const padding = LOCKED_PADDING[aggregationPolicy];
if (padding === undefined) {
throw new Error(`Unknown aggregationPolicy: ${aggregationPolicy} (expected aggregatable|hybrid|dispersed)`);
}
const payload = Buffer.from(`${firstInputTxid}:${firstInputVout}`, 'utf8');
const hashed = hash160(payload);
const paddingBuf = Buffer.alloc(2);
paddingBuf.writeUInt16BE(padding, 0);
const divBuf = Buffer.alloc(2);
divBuf.writeUInt16BE(divisibility, 0);
const concat = Buffer.concat([paddingBuf, hashed, divBuf]);
return bs58check.encode(concat);
}
Step 4 — Assemble the transaction: one input, three outputs, and an instruction that names output 0
DigiScope lets DigiByte Core build the transaction with createrawtransaction, which understands every address type, so the destination is never rewritten. The input is chosen smallest-first, for a reason you will read in the Traps.
from backend/src/services/digiasset-tx-builder.js
if (!lockStatus) {
throw new Error('Unlocked issuance not supported by this builder (locked-only in v1)');
}
if (divisibility !== 0) {
throw new Error('Divisible issuance not supported by this builder (version-1 payload: Core divides the amount by 10^divisibility)');
}
validateDestinationAddress(destinationAddress);
// Smallest-first selection: pick the smallest single UTXO that still covers
// dust + fee. This bounds the blast radius of any future encoder bug (a burn
// can only ever touch one small UTXO), per the 2026-05-17 burn defense — the
// caller (getTreasuryUtxos) already sorts smallest-first; we re-sort
// defensively in case a different caller passes an unsorted list.
const required = dustSats + feeSats;
const utxo = utxos
.filter((u) => u.spendable !== false)
.sort((a, b) => a.amount - b.amount)
.find((u) => Math.round(u.amount * SATS_PER_DGB) >= required);
from backend/src/services/digiasset-tx-builder.js
const assetId = computeLockedAssetId({
firstInputTxid: utxo.txid,
firstInputVout: utxo.vout,
aggregationPolicy,
divisibility,
});
// …
// Output order: [asset dust -> user dest, OP_RETURN data, change -> treasury].
const outputs = [
{ [destinationAddress]: Number(dustDgb) },
{ data: opReturnHex },
{ [changeAddress]: Number(changeDgb) },
];
const txHex = await rpc('createrawtransaction', [
[{ txid: utxo.txid, vout: utxo.vout }],
outputs,
]);
The call site fixes the economics: a 10,000-sat marker, a 50,000-sat miner fee, aggregatable, and change to a fresh treasury address:
from backend/src/services/digiasset-creation-service.js
const changeAddress = await dgbRpc('getnewaddress', ['digiasset-change', 'legacy'], TREASURY_WALLET);
const issueResult = await buildIssueTransaction({
utxos,
destinationAddress: params.destination_address,
amount: params.supply,
divisibility: params.decimals || 0,
lockStatus: params.locked !== false,
aggregationPolicy: 'aggregatable',
sha2,
torrentHash,
feeSats: 50000,
dustSats: 10000,
changeAddress,
rpc: (method, paramsArr) => dgbRpc(method, paramsArr, TREASURY_WALLET),
});
Known limitation: DigiScope's builder is locked-only and whole-units-only. Since this guide shipped, the creation endpoints reject both
locked: falseanddecimalsother than 0 on the pre-flight and the real submission, before any media or metadata is pinned: the builder would otherwise throw only after the IPFS uploads, and a version-1 amount with decimals is divided by 10^decimals when it is read back (a "supply" of 100 with 2 decimals would mint one unit). Unlocked issuance and divisible supplies need a version-3 payload, which is on the follow-up list. The request validation that enforces both:
from backend/src/controllers/digiasset-creation.js
export function validateCreationFields({ name, description, supply, decimals, locked, destination_address } = {}) {
const errors = [];
if (!name || !name.trim()) errors.push('Name is required');
if (name && name.length > 64) errors.push('Name must be 64 characters or less');
if (!description || !description.trim()) errors.push('Description is required');
if (description && description.length > 2000) errors.push('Description must be 2000 characters or less');
if (!supply || supply < 1) errors.push('Supply must be at least 1');
// Divisibility is version-dependent on-chain: DigiAsset Core divides a version-1
// amount by 10^decimals (a supply of 100 with 2 decimals becomes 1 unit), and this
// builder emits version 1. Until it emits version 3, whole units only.
if (decimals !== undefined && decimals !== null && Number(decimals) !== 0) {
errors.push('Decimals must be 0 — divisible supplies need a version-3 issuance, which this builder does not emit yet');
}
// The tx builder is locked-only (it throws on lockStatus:false) and that throw
// happens after the media + metadata have been pinned. Reject here instead.
if (locked === false) {
errors.push('Unlocked issuance is not supported yet — keep the supply fixed (locked)');
}
Step 5 — Guard the broadcast
Before signing and again after, decode the transaction and refuse to broadcast if any output above one DGB is not yours. This is the backstop that would have stopped the May 2026 burn described in the Traps.
from backend/src/services/digiasset-creation-service.js
const LEAK_THRESHOLD_DGB = 1.0;
async function assertNoTreasuryLeak(unsignedTxHex) {
const decoded = await dgbRpc('decoderawtransaction', [unsignedTxHex]);
for (const vout of decoded.vout) {
const spk = vout.scriptPubKey;
if (spk.type === 'nulldata') continue; // OP_RETURN payload — no value
const addr = spk.address || (spk.addresses && spk.addresses[0]);
const valueDgb = Number(vout.value);
if (!addr || valueDgb === 0 || valueDgb <= LEAK_THRESHOLD_DGB) continue;
const info = await dgbRpc('getaddressinfo', [addr], TREASURY_WALLET);
if (!info?.ismine) {
throw new Error(
`Encoder leak: ${valueDgb} DGB output to non-treasury address ${addr}. ` +
`This is the change-routing bug pattern from tx 89ffea72 (2026-05-11). ` +
`Aborting before broadcast.`
);
}
}
}
The destination is never defaulted from the user's profile either; a stale address on a profile is exactly how the burn started:
from backend/src/controllers/digiasset-creation.js
// Destination must be explicit. The previous silent fallback to
// req.user.digibyte_address was responsible for the May 11 burn — a stale
// Digi-ID signin had planted a no-longer-controlled address on the user
// record, and this fallback re-injected it after the user had cleared the
// form field. No silent defaults — make the caller send something.
const destinationAddress = (metadata.destination_address || '').trim();
if (!destinationAddress) {
return res.status(400).json({
error: 'Destination address is required. Enter the DigiByte address that should receive the asset — no default will be applied.',
});
}
Step 6 — The money path: reserve, broadcast, confirm, and never refund on doubt
DigiScope's minting is custodial: the treasury wallet signs, a Trust Level 2 account pays a flat 10 DGB from its tip balance, and the service must be exact about that balance. The pattern is a write-ahead row plus a conditional debit in one transaction, then the broadcast, then a status flip.
from backend/src/services/digiasset-creation-service.js
export function reserveCreationFee(db, userId, feeSats) {
const r = db.prepare(
`UPDATE user_tip_balances SET available_sats = available_sats - ?
WHERE user_id = ? AND available_sats >= ?`
).run(feeSats, userId, feeSats);
return r.changes > 0;
}
// …
export function refundCreationFee(db, createId, userId, feeSats) {
return db.transaction(() => {
const flag = db.prepare(
`UPDATE digiasset_creations SET fee_refunded = 1, updated_at = CURRENT_TIMESTAMP
WHERE create_id = ? AND fee_refunded = 0`
).run(createId);
if (flag.changes === 0) return false; // already refunded
db.prepare(`UPDATE user_tip_balances SET available_sats = available_sats + ? WHERE user_id = ?`)
.run(feeSats, userId);
return true;
})();
}
from backend/src/services/digiasset-creation-service.js
export async function commitCreation(db, intent, { feeSats, broadcast }) {
// …
const reserved = db.transaction(() => {
db.prepare(`
INSERT INTO digiasset_creations (
create_id, user_id, template, asset_name, metadata_json, metadata_cid,
media_cids, signed_tx, asset_id, destination_address, fee_dgb, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'broadcasting')
`).run(
intent.createId, intent.userId, intent.template, intent.assetName, intent.metadataJSON,
intent.metadataCID, JSON.stringify(intent.mediaCidHashes || []), intent.signedTxHex,
intent.assetId, intent.destinationAddress, intent.feeDgb,
);
return reserveCreationFee(db, intent.userId, feeSats);
})();
if (!reserved) {
markCreation(db, intent.createId, 'failed', { error_message: 'Insufficient tip-wallet balance for the creation fee' });
throw new Error('Insufficient tip-wallet balance for the creation fee');
}
let txid;
try {
txid = await broadcast();
} catch (broadcastErr) {
// Broadcast definitively failed (threw) → the mint never landed → refund.
refundCreationFee(db, intent.createId, intent.userId, feeSats);
// …
if (!txid || typeof txid !== 'string') {
throw new Error(`sendrawtransaction returned no txid (${JSON.stringify(txid)}); left 'broadcasting' for reconciler`);
}
markCreation(db, intent.createId, 'confirmed', { txid });
return txid;
}
The reconciler that sweeps broadcasting rows accepts exactly one proof of absence, DigiByte Core's RPC error −5 on a txindex node:
from backend/src/services/digiasset-creation-service.js
const TX_NOT_FOUND = /No such mempool or blockchain transaction/i;
// …
try {
await rpc('getrawtransaction', [txid]); // txindex=1 → authoritative for chain + mempool
onChain = true;
} catch (grtErr) {
if (TX_NOT_FOUND.test(grtErr.message || '')) {
definitelyAbsent = true;
} else {
throw grtErr; // ambiguous node/RPC error → outer catch → leave 'broadcasting', NO refund
}
}
Security: A self-custody wallet skips the fee ledger but keeps the shape: sign the same three outputs with your own key, decode before you sign, and treat "no txid" as unknown, not as failed. A refund issued on doubt is a double-spend of your own money the moment the transaction turns out to have landed.
Step 7 — Transfers: the last output gets the remainder
DigiScope has no user-facing transfer feature, only an admin/Trust-L3 test harness (backend/src/services/digiasset-test-service.js, POST /api/admin/digiassets/test/transfer) built on the legacy digiasset-transaction-builder 0.2.3, so the open-source DigiByte Android wallet's transfer code is the reference here. A transfer is a transaction whose OP_RETURN says 44 41, a version byte, and opcode 15, followed by instructions, each naming an output index and an amount. The wallet emits version 03 and its encoder accepts 2 or 3, but its decoder accepts any non-zero version because older transfers are on chain: block 11,000,208 carries 6a 07 44 41 02 15 00 20 13, a version-2 transfer of 1000 units to output 0, which is the wallet's own REAL_MAINNET_TRANSFER_V2 test fixture. Key a decoder on the magic and the opcode, not the version byte. Whatever the instructions do not assign is credited by the protocol to the transaction's last output, for transfers and issuances alike.
One caution before copying the wallet: it attributes an issuance's totalQuantity to the first non-OP_RETURN output (AssetTxQuantity.kt, ISSUANCE -> firstNonOpReturnVout), while DigiAsset Core, absent an instruction, credits the last output. For asset 5387 the wallet therefore displayed 20 SmileZ on a marker that Core had credited with zero, and the transfer it built from that marker moved nothing. Follow Core, and emit an explicit instruction for the marker.
from core/src/main/java/io/digibyte/core/asset/DigiAssetEncoder.kt
fun encodeTransferScript(version: Int, instructions: List<TransferInstruction>): ByteArray {
val payload = encodeTransferPayload(version, instructions)
require(payload.size <= MAX_OP_RETURN_PAYLOAD) {
"OP_RETURN payload ${payload.size}b exceeds max $MAX_OP_RETURN_PAYLOAD b"
}
// Standard Bitcoin PUSHDATA encoding: for 1..75 byte payloads the
// length is a single byte equal to the length; no OP_PUSHDATA1 needed.
val script = ByteArray(payload.size + 2)
script[0] = OP_RETURN
script[1] = payload.size.toByte()
System.arraycopy(payload, 0, script, 2, payload.size)
return script
}
// …
val w = BitWriter(initialCapacityBytes = 16)
w.writeByte(DA_MAGIC_0)
w.writeByte(DA_MAGIC_1)
w.writeByte(version.toByte())
w.writeByte(OPCODE_TRANSFER)
for (inst in instructions) writeInstruction(w, inst)
return w.toByteArray()
Each instruction is three flag bits, an output index (5 bits, or 13 when range is set) and an amount:
from core/src/main/java/io/digibyte/core/asset/DigiAssetEncoder.kt
private fun writeInstruction(w: BitWriter, inst: TransferInstruction) {
// Flag bits (same order as the decoder reads).
w.writeBits(if (inst.skip) 1L else 0L, 1)
w.writeBits(if (inst.range) 1L else 0L, 1)
w.writeBits(if (inst.percent) 1L else 0L, 1)
// Output index: 5 bits normally, 13 bits when range=true.
if (inst.range) {
w.writeBits(inst.outputIndex.toLong(), 13)
} else {
w.writeBits(inst.outputIndex.toLong(), 5)
}
// Amount: exact byte when percent, SFFC otherwise.
if (inst.percent) {
w.writeBits(inst.amount, 8)
} else {
w.writeFixedPrecision(inst.amount)
}
}
The amount encoding is the same SFFC the issuance uses. Written out, it is a bucket table:
from core/src/main/java/io/digibyte/core/asset/BitWriter.kt
fun writeFixedPrecision(value: Long) {
require(value >= 0) { "value must be non-negative, got $value" }
// Bucket 0: 3-bit prefix `000` + 5-bit mantissa (no exp). Values 0..31.
if (value <= 31L) {
writeBits(0b000L, 3)
writeBits(value, 5)
return
}
// Factor out trailing base-10 zeros.
var m = value
var e = 0
while (m % 10 == 0L) {
m /= 10
e++
}
// …
val buckets = listOf(
Bucket(0b001, 9, 4), // 2-byte
Bucket(0b010, 17, 4), // 3-byte
Bucket(0b011, 25, 4), // 4-byte
Bucket(0b100, 34, 3), // 5-byte
Bucket(0b101, 42, 3), // 6-byte
)
// …
// Bucket 6: 2-bit prefix `11` + 54-bit mantissa, no exponent.
// Value stored as raw (m × 10^e), must fit in 54 bits.
val raw = m * BitReader.pow10(e)
require(raw in 0 until (1L shl 54)) { "value too large for SFFC: $value" }
writeBits(0b11L, 2)
writeBits(raw, 54)
}
The remainder rule, as the wallet computes it when reading any transfer:
from core/src/main/java/io/digibyte/core/asset/AssetTxQuantity.kt
fun implicitChange(header: DecodedAssetHeader, inputUnits: Long?, outputCount: Int): Long? {
if (header.operation == AssetOperation.ISSUANCE) return 0L
if (inputUnits == null) return null
var assigned = 0L
for (inst in header.transferInstructions) {
if (inst.percent) return null
assigned += if (inst.range) (inst.outputIndex.toLong() + 1L) * inst.amount else inst.amount
}
return (inputUnits - assigned).coerceAtLeast(0L)
}
/** The output index [implicitChange] lands on: the transaction's last output, verbatim.
* When that output is the OP_RETURN the reference credits it there anyway (an effective
* burn) — mirror the reference rather than "improving" it, or our view of the chain
* diverges from every other implementation's. */
fun implicitChangeVout(outputCount: Int): Int = outputCount - 1
And the output list the wallet's own send builds, with the marker size that clears the 9.26 dust floor:
from core/src/main/java/io/digibyte/core/asset/send/AssetCoinSelector.kt
* dust rejection**, which is exactly why asset sends could not be pushed
* through. The recipient's address type is arbitrary (could be legacy), so we
* clear the worst-case legacy floor with headroom. 6,000 sats ≈ 0.00006 DGB —
* negligible, and safely above 5,460 even if the recipient is legacy. */
const val DA_MARKER_SATS: Long = 6_000L
from core/src/main/java/io/digibyte/core/asset/AssetManager.kt
outAddresses += toAddress
outAmounts += markerSats
outScripts += ""
outAddresses += "" // empty address = use raw script below (OP_RETURN)
outAmounts += 0L
outScripts += opReturnScript.toHex()
if (hasAssetChange) {
// Use change index 1 to keep this distinct from the DGB change
// address — small privacy win + makes the wallet's own asset
// marker easier to identify in tx history.
val assetChangeAddr = NativeBridge.getChangeAddress(1, format = 2)
?: return TxResult.Error("Could not derive asset-change address")
outAddresses += assetChangeAddr
outAmounts += markerSats
outScripts += ""
}
val dgbChange = ok.dgbChangeSats
if (dgbChange > DGB_CHANGE_DUST_THRESHOLD) {
val changeAddr = NativeBridge.getChangeAddress(0, format = 2)
?: return TxResult.Error("Could not derive change address")
outAddresses += changeAddr
outAmounts += dgbChange
outScripts += ""
}
Note the conditional: the wallet's own send drops the DGB-change output when it would be dust (at or below 5,460 sats, DGB_CHANGE_DUST_THRESHOLD), which can leave the OP_RETURN last. That is safe there only because buildTransferInstructions assigns every input unit and fails otherwise. The wallet's recovery builder makes the rule explicit by refusing to build a transfer whose change would fall below dust, because squeezing the change out would leave the OP_RETURN last:
from core/src/main/java/io/digibyte/core/recovery/ForeignAssetTransferPlan.kt
val change = totalIn - DA_MARKER_SATS - feeSat
if (change <= CHANGE_DUST_THRESHOLD) {
// Squeezing the change out would put the OP_RETURN last and burn any residual units.
return Result.Refused(
Reason.INSUFFICIENT_FEE_FUNDS,
"need ${DA_MARKER_SATS + feeSat + CHANGE_DUST_THRESHOLD + 1} sats, have $totalIn",
)
}
val outputs = listOf(
Out(address = dest, amountSat = DA_MARKER_SATS, scriptHex = ""),
Out(address = "", amountSat = 0L, scriptHex = opReturnScript.toHex()),
// LAST on purpose: unassigned units are credited here, and here is the user's wallet.
Out(address = dest, amountSat = change, scriptHex = ""),
)
Trap: Amounts in instructions are protocol integers with no scaling applied. A range instruction credits its amount to every output from 0 to its index but consumes
(index + 1) × amountfrom the inputs. A burn is a separate opcode, not a special output index on a transfer: the payload header is44 41, the version byte, then opcode25, and the instruction names output 31 withrangeunset. Under the transfer opcode15that same instruction names an output that does not exist in a three- or four-output transaction, DigiAsset Core discards the whole instruction set, and every input unit lands on the last output instead of being destroyed (DigiByteTransaction.cppchecks the index-31 sentinel only when the opcode is a burn).
Verify it
Build an unsigned issuance with your own node and decode it before you sign:
digibyte-cli decoderawtransaction <unsigned-hex>Expected: exactly three
voutentries in this order: a small-value output to your destination, anulldataoutput whosehexstarts with6a3c44410101(60-byte payload; or6a+ length +444103followed by opcode01,03or04if you emit version 3), and your change. Decode the payload and confirmpaymentsnames output 0 with your full amount. Anything else in slot 0 or 1 is wrong before it is expensive.Derive the asset ID from the input you selected and keep it:
computeLockedAssetId({ firstInputTxid: '<txid>', firstInputVout: <n>, aggregationPolicy: 'aggregatable', divisibility: 0 })For the input
87940301d1f7d4fd83fa40fcdfac00aaea7110c31e04d27d1b545814cfa74872:0it returnsLa5uiP7PEfAtiVRtXNie8SKewbZ8iXzREEgfGb.Sign, broadcast, wait one confirmation (testnet is fine), then ask your DigiAsset Core for the record.
listassetissuancestakes the asset ID and returns the issuance row: compare itsamountandcidto what you built, then feed itsassetIndextogetassetdataand compareassetId,initialCount,decimalsandcid. Finally,getaddressholdings <destination>must list the new index with your full amount; if the address is empty, your units went to the last output.curl -s -u user:pass -H 'Content-Type: application/json' \ -d '{"jsonrpc":"1.0","id":1,"method":"listassetissuances","params":["<assetId>"]}' http://127.0.0.1:14024/ # -> [{"amount":<n>,"assetIndex":<idx>,"cid":"bafkrei…","height":…,"txid":"…"}] curl -s -u user:pass -H 'Content-Type: application/json' \ -d '{"jsonrpc":"1.0","id":1,"method":"getassetdata","params":[<idx>]}' http://127.0.0.1:14024/ # -> {"assetId":"…","assetIndex":<idx>,"cid":"…","count":…,"decimals":0,"initialCount":<n>,…}
Check: Decode the real mainnet issuance
ad50b42ab8551e7fcb54c17c68fda402529b7a78f6e5f61b398cb5479e0f040ewithgetrawtransaction <txid> 1. You should see the three-output order and the 58-byte, instruction-less payload from Step 2;getassetdatafor asset 5387 reportsinitialCount: 20,decimals: 0,count: 0and thecidderived from the payload's SHA-256. The zero is the lesson: the units were credited to the change output and destroyed when the treasury spent it.
Traps
- Largest-first UTXO selection plus a silent default. On 2026-05-11 a 549,499 DGB treasury UTXO funded a 0.01 DGB issuance. The destination had been silently defaulted from a stale profile address, a Digi-ID identity address that no DigiScope wallet controlled, and the old encoder library routed the change to that recipient instead of back to treasury, so the whole UTXO left custody in one transaction (tx
89ffea72…). Smallest-first bounds what any future bug can touch; the explicit-destination rule and the above-1-DGB leak guard close the other two holes. The funds came back 26 days later only because that address's key could be re-derived from the user's own wallet seed. - An issuance with no transfer instruction. Core assigns unassigned units to the last output; in DigiScope's layout that is the treasury change, not the recipient's marker. Every DigiScope mint before September 2026 landed there and was destroyed when the treasury next spent its change. Always emit an instruction naming the marker, and verify with
getaddressholdingsafter the first confirmation. - Silent address rewrites. Any library that cannot decode every DigiByte address type will "help" by converting. Build with
createrawtransactionor a modern library, and decode the result before you sign. - Spending an asset-bearing output as plain DGB destroys the units. A transaction with asset inputs and no valid DigiAsset OP_RETURN is an unintentional burn in DigiAsset Core. Asset 5387's 20 units died this way at height 24,133,005 on the treasury's own change output (
ae42cdd3…, a 20-input consolidation), because the instruction-less issuance had credited them there. The recipient's later spend of the marker (1b41c57e…) was a well-formed version-3 transfer that Core discarded because the input held nothing. Only asset-aware wallets keep units; warn users who receive to a wallet that is not, and never spend a DigiAsset-carrying UTXO with a plain-DGB builder. - The OP_RETURN as last output. The implicit remainder lands on the last output. Make it a spendable output you control.
- Old dust values. Markers below the 9.26 dust floor are rejected; use 6,000 sats or more.
- Multisig overflow. If your metadata hashes and rules do not fit in 80 bytes, the protocol moves hashes into a multisig output. DigiScope's builder refuses that case rather than implement it.
- Refunding on doubt. A broadcast that returns nothing is not a failure. Park it and let a reconciler ask the node.
- Decimals on a version-1 payload. Core and the reference decoders divide the amount by 10^divisibility; encode version 3 or stay at whole units.
Where DigiScope's implementation differs from the ideal
- Minting is custodial: the treasury wallet signs, and the user pays 10 DGB from an internal balance (Trust Level 2). A self-custody wallet signs the same transaction shape with its own key.
- The builder emits version 1 with a SHA-1 that nothing reads any more; modern wallets emit version 3.
- Locked, aggregatable, whole units only. The wizard no longer offers an unlocked toggle or decimals outside Advanced (where both are pinned), the endpoints reject
locked: falseanddecimals > 0, and the hybrid/dispersed policies are not offered. - Mints before September 2026 omitted the output-0 instruction; Core credited them to the change output and treasury spends destroyed all eight. The 60-byte payload with the
00 <amount>instruction is what ships now. - The
aggregationfield in the creation form is collected and ignored; the builder always usesaggregatable. - Transfers exist server-side only as an admin/L3 test harness on the legacy builder; nothing user-facing. The wallet is the reference.
Previous: the read side, in Read DigiAssets on any address.