theseus/bundled-addons/aegis/lib/cashtokens.js

207 lines
9.5 KiB
JavaScript
Raw Normal View History

// 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)
// <locking script bytes> — 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,
};