// CashTokens (CHIP-2022-02) primitives — decode + encode the prefix byte // that wraps a token-carrying scriptPubKey. Pure functions, no wallet or // network state. Used by: // - wallet.js → classify UTXOs (bare BCH vs fungible vs NFT vs both) // - tx.js → build token outputs // - panel.js → render token balances / send flows // // Prefix layout (CashTokens spec): // // 0xef — PREFIX_TOKEN marker // category_id (32 bytes) — genesis txid of the token, LE-serialised // token_bitfield (1 byte) — see BITS below // [commitment_length (varint)] — present iff HAS_COMMITMENT_LENGTH // [commitment (bytes)] — length equal to commitment_length // [amount (varint)] — present iff HAS_AMOUNT (fungible token) // — the "real" P2PKH / P2SH / … script // // Bitfield layout (spec §"Token Prefix Encoding"): // Upper nibble = STRUCTURE bits (which fields are present): // 0x10 HAS_AMOUNT — fungible token amount is encoded // 0x20 HAS_NFT — NFT is present (commitment optional) // 0x40 HAS_COMMITMENT_LENGTH — commitment_length is present // 0x80 reserved (must be 0) // Lower nibble = NFT CAPABILITY (meaningful only when HAS_NFT): // 0x00 none / immutable // 0x01 mutable // 0x02 minting // 0x03-0x0F reserved (must be 0) const PREFIX_TOKEN = 0xef; // Bit masks (STRUCTURE). const HAS_AMOUNT = 0x10; const HAS_NFT = 0x20; const HAS_COMMITMENT_LENGTH = 0x40; const STRUCTURE_RESERVED = 0x80; // NFT capabilities. Values are read from bitfield & 0x0f. const CAP_NONE = 0x00; // immutable NFT (or "no NFT" when HAS_NFT bit is off) const CAP_MUTABLE = 0x01; const CAP_MINTING = 0x02; const CAP_LABEL = { 0: "immutable", 1: "mutable", 2: "minting" }; // Varint (compact size) encode/decode used for commitment length AND for // the fungible-token amount. Amounts up to 9,223,372,036,854,775,807 sats // (2^63-1) are legal; larger values are consensus-invalid, so we cap and // throw on encode. function readVarint(bytes, pos) { if (pos >= bytes.length) throw new Error("cashtokens: truncated varint"); const first = bytes[pos]; if (first < 0xfd) return { value: BigInt(first), next: pos + 1 }; if (first === 0xfd) { if (pos + 3 > bytes.length) throw new Error("cashtokens: truncated 0xfd varint"); return { value: BigInt(bytes[pos + 1] | (bytes[pos + 2] << 8)), next: pos + 3 }; } if (first === 0xfe) { if (pos + 5 > bytes.length) throw new Error("cashtokens: truncated 0xfe varint"); return { value: BigInt(bytes[pos + 1]) | (BigInt(bytes[pos + 2]) << 8n) | (BigInt(bytes[pos + 3]) << 16n) | (BigInt(bytes[pos + 4]) << 24n), next: pos + 5, }; } // 0xff = 8-byte little-endian u64 if (pos + 9 > bytes.length) throw new Error("cashtokens: truncated 0xff varint"); let v = 0n; for (let i = 0; i < 8; i++) v |= BigInt(bytes[pos + 1 + i]) << BigInt(8 * i); return { value: v, next: pos + 9 }; } function writeVarint(v) { const n = typeof v === "bigint" ? v : BigInt(v); if (n < 0n) throw new Error("cashtokens: negative varint"); if (n < 0xfdn) return Uint8Array.from([Number(n)]); if (n <= 0xffffn) return Uint8Array.from([0xfd, Number(n & 0xffn), Number((n >> 8n) & 0xffn)]); if (n <= 0xffffffffn) { return Uint8Array.from([ 0xfe, Number(n & 0xffn), Number((n >> 8n) & 0xffn), Number((n >> 16n) & 0xffn), Number((n >> 24n) & 0xffn), ]); } if (n > (1n << 63n) - 1n) throw new Error("cashtokens: amount exceeds i64 max"); const out = new Uint8Array(9); out[0] = 0xff; let x = n; for (let i = 1; i <= 8; i++) { out[i] = Number(x & 0xffn); x >>= 8n; } return out; } // Split a scriptPubKey into { token, lockingScript, rawPrefix }. token is // null when the script is NOT prefixed by 0xef. lockingScript is the // tokenless portion — every downstream check (P2PKH, P2SH, OP_RETURN, // electrum scripthash) works off THAT, so token-carrying and bare UTXOs // stay comparable through the existing wallet code. function decodePrefixedScript(script) { const bytes = script instanceof Uint8Array ? script : Uint8Array.from(script); if (!bytes.length || bytes[0] !== PREFIX_TOKEN) { return { token: null, lockingScript: bytes, rawPrefix: null }; } if (bytes.length < 1 + 32 + 1) throw new Error("cashtokens: prefix truncated at category"); let pos = 1; const category = bytes.slice(pos, pos + 32); pos += 32; const bitfield = bytes[pos]; pos += 1; if (bitfield & STRUCTURE_RESERVED) throw new Error("cashtokens: reserved structure bit set"); const hasAmount = !!(bitfield & HAS_AMOUNT); const hasNft = !!(bitfield & HAS_NFT); const hasCommitLen = !!(bitfield & HAS_COMMITMENT_LENGTH); const capability = bitfield & 0x0f; // Structure invariants (spec): // - Commitment-length present implies HAS_NFT (a commitment without an // NFT is meaningless) AND commitment_length ≥ 1. // - Capability lower nibble is only meaningful when HAS_NFT is set. // - At least one of HAS_AMOUNT / HAS_NFT must be set, otherwise the // prefix carries no useful info and should be rejected. if (!hasAmount && !hasNft) throw new Error("cashtokens: prefix carries neither amount nor nft"); if (hasCommitLen && !hasNft) throw new Error("cashtokens: commitment without NFT"); if (!hasNft && capability !== 0) throw new Error("cashtokens: capability bits set on fungible-only prefix"); if (hasNft && capability > 2) throw new Error(`cashtokens: unknown NFT capability ${capability}`); let commitment = null; if (hasCommitLen) { const clen = readVarint(bytes, pos); pos = clen.next; if (clen.value === 0n) throw new Error("cashtokens: zero-length commitment"); if (clen.value > 40n) throw new Error(`cashtokens: commitment exceeds 40 bytes (${clen.value})`); const length = Number(clen.value); if (pos + length > bytes.length) throw new Error("cashtokens: commitment truncated"); commitment = bytes.slice(pos, pos + length); pos += length; } let amount = 0n; if (hasAmount) { const av = readVarint(bytes, pos); pos = av.next; if (av.value === 0n) throw new Error("cashtokens: zero fungible amount"); if (av.value > (1n << 63n) - 1n) throw new Error("cashtokens: fungible amount overflow"); amount = av.value; } const lockingScript = bytes.slice(pos); const rawPrefix = bytes.slice(0, pos); return { token: { category, categoryHex: toHex(category), amount, hasAmount, hasNft, capability, capabilityLabel: hasNft ? CAP_LABEL[capability] : null, commitment, commitmentHex: commitment ? toHex(commitment) : null, }, lockingScript, rawPrefix, }; } // Encode a { category, amount, nft: { commitment, capability } } spec into // the prefix bytes ready to be prepended to a locking script. Absent fields // mean "not present" — e.g. { amount: 100n } → fungible only. function encodePrefix({ category, amount = 0n, nft = null }) { const cat = category instanceof Uint8Array ? category : Uint8Array.from(String(category).match(/../g).map((h) => parseInt(h, 16))); if (cat.length !== 32) throw new Error("cashtokens: category must be 32 bytes"); const amt = typeof amount === "bigint" ? amount : BigInt(amount || 0); if (amt < 0n) throw new Error("cashtokens: negative amount"); const hasAmount = amt > 0n; const hasNft = !!nft; const commitment = hasNft && nft.commitment ? (nft.commitment instanceof Uint8Array ? nft.commitment : Uint8Array.from(String(nft.commitment).match(/../g).map((h) => parseInt(h, 16)))) : null; const hasCommitLen = hasNft && commitment && commitment.length > 0; if (commitment && commitment.length > 40) throw new Error("cashtokens: commitment > 40 bytes"); const capability = hasNft ? (Number(nft.capability) || 0) : 0; if (capability > 2) throw new Error(`cashtokens: bad NFT capability ${capability}`); if (!hasAmount && !hasNft) throw new Error("cashtokens: must have amount or NFT"); let bitfield = 0; if (hasAmount) bitfield |= HAS_AMOUNT; if (hasNft) bitfield |= HAS_NFT; if (hasCommitLen) bitfield |= HAS_COMMITMENT_LENGTH; bitfield |= capability & 0x0f; const parts = [Uint8Array.from([PREFIX_TOKEN]), cat, Uint8Array.from([bitfield])]; if (hasCommitLen) { parts.push(writeVarint(commitment.length)); parts.push(commitment); } if (hasAmount) parts.push(writeVarint(amt)); return concat(...parts); } // Prepend a token prefix to an existing locking script (P2PKH etc). function wrapScript(prefix, lockingScript) { return concat(prefix, lockingScript); } // Concise helper: given a JSON-serialisable descriptor and a P2PKH pubkey // hash, produce the full token-carrying scriptPubKey ready for an output. function tokenP2PKHScript({ category, amount = 0n, nft = null }, h160) { const prefix = encodePrefix({ category, amount, nft }); const locking = Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); return wrapScript(prefix, locking); } // Utilities (kept private to this file to avoid coupling with tx.js). function concat(...parts) { const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; } function toHex(b) { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); } module.exports = { PREFIX_TOKEN, HAS_AMOUNT, HAS_NFT, HAS_COMMITMENT_LENGTH, CAP_NONE, CAP_MUTABLE, CAP_MINTING, CAP_LABEL, decodePrefixedScript, encodePrefix, wrapScript, tokenP2PKHScript, readVarint, writeVarint, };