Issue and transfer a DigiAsset

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

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

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

A version-1 DigiAsset issuance payload, byte by byte Script: OP_RETURN 0x6a, push length 0x3c, then the 60-byte payload: magic 0x44 0x41 (DA), version 0x01, opcode 0x01 (both hashes in the OP_RETURN), a 20-byte SHA-1, a 32-byte SHA-256 of the metadata JSON, the amount in SFFC (one to seven bytes), one transfer instruction naming output 0 with the same amount (two bytes here), and one flags byte whose top three bits are the divisibility, the next bit the lock flag, the next two the aggregation policy, and the last two unused. Asset 5387 was minted without the instruction, so DigiAsset Core credited its units to the last output. script 6a OP_RETURN 3c push 60 payload (60 bytes) 44 41 "DA" 01 version 01 opcode f2a90fb0 … 0a57dd65 SHA-1 · 20 bytes d4e192a6 … e9bec035 SHA-256 of metadata · 32 bytes 14 amount 20 00 14 output 0 gets 20 10 flags flags byte 0x10 = 0001 0000 000 · divisibility 0 1 · locked 00 · aggregatable 00 · unused Without the 00 14 instruction (asset 5387, 58 bytes) Core credits the units to the LAST output — the change, not the marker. Version 03 issuances drop the SHA-1 whatever the opcode; there the opcode selects the rules block (01 none, 03 rewritable, 04 immutable).
The 60 bytes DigiScope's builder emits, built on the real payload of asset 5387 plus the output-0 instruction that mint lacked. Modern wallets emit version 3, which drops the SHA-1 for every opcode; there the opcode selects the rules block (01 none, 03 rewritable, 04 immutable). Read the version byte, not the opcode, for both the hash layout and the amount rule.

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.

Deriving a locked asset's ID Take the UTF-8 string txid colon vout of the first input, hash it with SHA-256 then RIPEMD-160 to get 20 bytes, prepend a two-byte padding that encodes locked plus the aggregation policy (0x20ce for aggregatable), append the divisibility as two big-endian bytes, and base58check-encode the 24 bytes. Aggregatable locked IDs begin with La. "87940301d1f7…cfa74872:0" first input, as a UTF-8 string RIPEMD-160( SHA-256( … ) ) hash160 → 20 bytes 24 bytes 20 ce locked · aggregatable hash160 · 20 bytes hybrid = 21 02 · dispersed = 20 e4 00 00 divisibility, BE base58check → La5uiP7PEfAtiVRtXNie8SKewbZ8iXzREEgfGb
The real input of asset 5387's issuance, 8794…4872:0, derives La5uiP7PEfAtiVRtXNie8SKewbZ8iXzREEgfGb. Change the input and the ID changes.

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.

The issuance transaction DigiScope builds One input, the smallest treasury UTXO that covers dust plus fee. Three outputs in a fixed order: output 0 sends 10,000 sats of dust to the destination address and receives the issued units because the payload's transfer instruction names output 0; output 1 is the OP_RETURN payload; output 2 returns the change to a treasury-owned address. Without that instruction the protocol credits the whole supply to the last output, the change. The asset ID is derived from the input's txid and vout. input 0 smallest UTXO ≥ dust + fee txid:vout → asset ID vout 0 · 10,000 sats → destination address receives the units because the payload names output 0; spend it only with a DigiAsset payload vout 1 · 0 sats · OP_RETURN DA · 01 · 01 · SHA-1 · SHA-256 · amount · [00 amount] → output 0 · flags vout 2 · change → treasury (ismine) any output above 1 DGB must be ours, or nothing is broadcast fee = input − dust − change = 50,000 sats; unassigned units always land on the LAST output
Output 0 is the 10,000-sat marker, output 1 the payload, output 2 the change. The marker holds the units only because the payload's instruction names output 0; with no instruction Core credits them to the last output, the change. The order is part of the protocol, and so is the instruction.

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: false and decimals other 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.

The mint's money-path state machine A creation row is inserted as broadcasting in the same transaction that reserves the fee. A broadcast that throws refunds the fee once and marks the row failed. A broadcast that returns a txid marks it confirmed. A broadcast that returns nothing leaves the row broadcasting for the reconciler, which marks confirmed if the node knows the transaction, refunds and fails only on a definitive not-found, and leaves anything ambiguous alone. broadcasting INSERT + reserve fee, one transaction confirmed txid recorded · fee stays charged failed fee refunded exactly once txid returned · or reconciler finds the tx broadcast threw · or reconciler gets RPC −5 "not found" no txid or an ambiguous RPC error: stays broadcasting, never refunded on doubt
Only a thrown broadcast or a definitive not-found refunds. A missing txid or an ambiguous error parks the row for the reconciler.

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.

Anatomy of a DigiAsset transfer Inputs: one or more UTXOs carrying the asset, plus plain DGB UTXOs for the fee. Outputs: a 6,000-sat marker to the recipient named by a transfer instruction, the OP_RETURN with magic DA, version 3, opcode 0x15 and the instructions, an optional asset-change marker, and DGB change last. Units the instructions do not assign are credited to the last output, so either assign every unit explicitly or keep a spendable output you control in the last slot; an OP_RETURN there burns the remainder. asset UTXO(s) carry N units of the asset DGB UTXO(s) pay the fee and the markers vout 0 · 6,000 sats → recipient instruction: output 0, amount M (SFFC) vout 1 · OP_RETURN 44 41 · 03 · 15 · skip|range|percent · index · amount vout 2 · 6,000 sats → your asset change (optional) instruction: output 2, amount N − M last vout · DGB change → you implicit remainder lands on the last vout — assign every unit, or keep this output A plain DGB spend of an asset UTXO with no payload destroys its units; only asset-aware wallets keep them.
Recipient marker first, payload second, optional asset change, DGB change last when it clears dust (5,460 sats). The wallet assigns every unit explicitly, so a zero remainder is safe even when the DGB change is dropped; if you leave anything unassigned, the last output must be one you control, because an OP_RETURN in that slot burns the remainder.

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) × amount from the inputs. A burn is a separate opcode, not a special output index on a transfer: the payload header is 44 41, the version byte, then opcode 25, and the instruction names output 31 with range unset. Under the transfer opcode 15 that 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.cpp checks the index-31 sentinel only when the opcode is a burn).

Verify it

  1. Build an unsigned issuance with your own node and decode it before you sign:

    digibyte-cli decoderawtransaction <unsigned-hex>
    

    Expected: exactly three vout entries in this order: a small-value output to your destination, a nulldata output whose hex starts with 6a3c44410101 (60-byte payload; or 6a + length + 444103 followed by opcode 01, 03 or 04 if you emit version 3), and your change. Decode the payload and confirm payments names output 0 with your full amount. Anything else in slot 0 or 1 is wrong before it is expensive.

  2. 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:0 it returns La5uiP7PEfAtiVRtXNie8SKewbZ8iXzREEgfGb.

  3. Sign, broadcast, wait one confirmation (testnet is fine), then ask your DigiAsset Core for the record. listassetissuances takes the asset ID and returns the issuance row: compare its amount and cid to what you built, then feed its assetIndex to getassetdata and compare assetId, initialCount, decimals and cid. 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 ad50b42ab8551e7fcb54c17c68fda402529b7a78f6e5f61b398cb5479e0f040e with getrawtransaction <txid> 1. You should see the three-output order and the 58-byte, instruction-less payload from Step 2; getassetdata for asset 5387 reports initialCount: 20, decimals: 0, count: 0 and the cid derived 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

Where DigiScope's implementation differs from the ideal

Previous: the read side, in Read DigiAssets on any address.