Developer Guide · v1

TXC L2 / Omni Layer

A field guide to issuing, minting, and moving Omni-Layer tokens on the Texitcoin (TXC) chain. Everything below is what we actually had to do to ship POP — the docs that exist elsewhere are sparse and a few of the defaults from upstream Omni do not apply here.

1. Network parameters

TXC is a Bitcoin-derived chain. The Omni Layer protocol runs on top of it the same way it runs on Bitcoin — by encoding token operations into OP_RETURN outputs of regular TXC transactions.

P2PKH version byte0x42
Address prefix (decoded)starts with T
Message-sign prefix"Texitcoin Signed Message:\n"
Mempool / Esplora APIhttps://mempool.texitcoin.org/api
Dust threshold (effective)10,000 sats
Suggested fee rate5 sat/vB
Omni magic bytes"omni" (4 ASCII bytes)

2. Quickstart

You need three things:

  1. A funded TXC address with the issuer's private key (the address that originally created the token via omni_sendissuancemanaged).
  2. Access to a TXC full node with the Omni Layer module enabled, reachable over JSON-RPC (auth: HTTP Basic).
  3. The property ID of your token (e.g. POP is 21).

Then the loop is: build payload → assemble tx → sign → broadcast. The node only helps with step 1 — you assemble, sign, and broadcast yourself (the wallet RPCs assume the issuer key lives in the node's wallet, which is not how most app integrations work).

3. RPC reference

Standard Bitcoin-style JSON-RPC. Content-Type: text/plain, HTTP Basic auth, body is { jsonrpc, id, method, params }.

curl -u user:pass -H 'content-type: text/plain' \
  -d '{"jsonrpc":"1.0","id":"x","method":"omni_getproperty","params":[21]}' \
  https://your-txc-node.example/

Methods we use day-to-day:

4. Minting (Grant)

For managed tokens, the issuer mints new units via a Grant operation. The flow:

  1. Ask the node for the Omni payload bytes.
  2. Pull UTXOs for the issuer address from the Esplora API.
  3. Build a tx with three outputs in this exact order:OP_RETURN(payload), dust to the receiver, change back to issuer.
  4. Sign every input with the issuer key.
  5. POST the raw hex to the Esplora /tx endpoint.

Build the payload

const payloadHex: string = await rpc("omni_createpayload_grant", [
  21,             // property id
  "100.00000000", // amount as a decimal string (8 dp for divisible)
  "",             // grantdata — REQUIRED, even when empty (see Gotchas)
]);

Assemble & broadcast

import * as bitcoin from "bitcoinjs-lib";

const opReturnData = Buffer.concat([
  Buffer.from("omni", "ascii"),     // magic
  Buffer.from(payloadHex, "hex"),   // payload from RPC — DO NOT prepend extra bytes
]);
const opReturn = bitcoin.payments.embed({ data: [opReturnData] }).output!;

const psbt = new bitcoin.Psbt({ network: TXC });
// ... addInput() for each issuer UTXO with nonWitnessUtxo ...

psbt.addOutput({ script: opReturn, value: 0n });
psbt.addOutput({ address: receiver, value: 10_000n }); // dust = 10k sats on TXC
psbt.addOutput({ address: issuer,   value: changeSats });

psbt.signAllInputs(signer);
psbt.finalizeAllInputs();

const rawHex = psbt.extractTransaction().toHex();
const txid = await fetch("https://mempool.texitcoin.org/api/tx", {
  method: "POST", headers: { "content-type": "text/plain" }, body: rawHex,
}).then(r => r.text());

5. Sending tokens

A wallet-to-wallet transfer is exactly the same shape as a mint — just swap the payload method to omni_createpayload_simplesend. The signing key becomes the sender's key (not the issuer), and the dust output goes to the recipient.

const payloadHex = await rpc("omni_createpayload_simplesend", [
  21,
  "5.00000000",
]);
// ...same OP_RETURN + dust + change construction as the Grant flow...

6. Reading balances

type Bal = { balance: string; reserved: string; frozen: string };
const bal: Bal = await rpc("omni_getbalance", [address, 21]);
// bal.balance is a decimal string like "100.00000000"

Note: the balance only updates after the funding tx confirms. Until then it appears as 0. If you need optimistic UI, mirror the expected balance in your own DB and reconcile periodically against omni_getbalance.

7. OP_RETURN format (Class C)

Omni Class C transactions encode the entire operation in the OP_RETURN payload. The byte layout is:

+------+----------------------------------------------+
| omni |  <payload from omni_createpayload_*>          |
+------+----------------------------------------------+
  4 B          variable (already includes version+type)

That's it. The payload returned by the omni_createpayload_* family already contains the version bytes and the operation type byte — so you only need to prepend the 4-byte "omni" magic. Do not add any other framing bytes (see the next section).

8. Gotchas (the stuff that cost us a day)

Don't prepend extra zero bytes to the payload

A common pattern in older Omni examples is "omni" + 0x00 0x00 + payload. On TXC that causes the node to interpret your Grant as a Simple Send for some random property ID (the version+type bytes get shifted). Symptom: omni_decodetransaction shows the wrong type, the receiver balance never moves, and there's no error in the broadcast.

Concatenate just "omni" + payload. Nothing else.

The grantdata argument is not optional (and it's a memo)

Upstream Omni docs say the third argument to omni_createpayload_grant is optional. On the TXC node it's required — the 2-arg form returns the help text instead of a payload. It's also more useful than it looks: grantdata is a free-form attribution memo embedded inside the Omni payload itself, so it travels with the grant on-chain and shows up in omni_gettransaction output. Cap it at ~60 bytes — the entire OP_RETURN (magic + payload + memo) has to fit under the node's datacarrier size limit (80 bytes by default).

rpc("omni_createpayload_grant", [propertyId, amount, ""]);                 // no memo
rpc("omni_createpayload_grant", [propertyId, amount, "claim:abc123"]);     // attribution memo

Chain your own change for back-to-back mints

TXC blocks take real time to land. If you mint twice in quick succession, the second mint's only spendable coin is the unconfirmed change output from the first. The symptom is confusing: the second mint fails with "issuer has no UTXOs" even though a block explorer clearly shows the address is funded.

The fix is to not filter out unconfirmed UTXOs when reading from Esplora's /address/:addr/utxo endpoint. Sort confirmed-first so settled coins are preferred, and fall through to your own unconfirmed change only when needed:

const utxos = raw
  .sort((a, b) => Number(b.status.confirmed) - Number(a.status.confirmed))
  .map(({ txid, vout, value }) => ({ txid, vout, value }));

Caveat: spending unconfirmed change builds a tx chain. If the parent gets evicted or RBF'd, every child mint becomes invalid along with it. For high-throughput minting, either batch grants into a single tx or pre-fund several issuer UTXOs so each mint can spend a settled coin.

Dust threshold is 10,000 sats, not 546

The Bitcoin default is 546 sats. TXC's mempool policy is stricter and rejects anything under 10,000 sats as dust. If your reference output (the dust output to the recipient) is below that, the broadcast fails with a generic mempool error. Use 10,000 and budget for it in your fee math.

Amount formatting

For divisible tokens, pass the amount as a decimal string with up to 8 decimal places: "100" and "100.00000000" both work. For indivisible tokens use a plain integer string. Don't pass numbers — the JSON-RPC layer will round large values.

Wallet RPCs aren't useful for app integrations

omni_send and friends require the signing key to be in the node's wallet. For a hosted app where keys live in your backend (or in the user's own wallet), use the omni_createpayload_* family and assemble the tx yourself, exactly like the snippet above.

Confirmations

Broadcasted Omni txs show up immediately in the mempool but Omni only parses them after confirmation. Don't rely on omni_gettransaction to return data for a 0-conf tx — poll until the block lands, or watch the address via Esplora's websocket / address endpoints.


Built and battle-tested while shipping POP. Back to CryptoPOP.