ecash-lib
Version:
Library for eCash transaction building
387 lines (359 loc) • 11.7 kB
text/typescript
// Copyright (c) 2025 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import { bytesToStr } from '../io/str.js';
import { toHex } from '../io/hex.js';
import { OP_RETURN } from '../opcode.js';
import { isPushOp } from '../op.js';
import { Script, ScriptOpIter } from '../script.js';
import {
BURN_STR,
GENESIS_STR,
GenesisInfo,
MAX_DECIMALS,
MINT_STR,
SEND_STR,
TOKEN_ID_NUM_BYTES,
UNKNOWN_STR,
} from './common.js';
import {
SLP_ATOMS_NUM_BYTES,
SLP_FUNGIBLE,
SLP_GENESIS_HASH_NUM_BYTES,
SLP_LOKAD_ID_STR,
SLP_MAX_SEND_OUTPUTS,
SLP_MINT_VAULT,
SLP_MINT_VAULT_SCRIPTHASH_NUM_BYTES,
SLP_NFT1_CHILD,
SLP_NFT1_GROUP,
SlpTokenType_Number,
} from './slp.js';
/** Parsed SLP GENESIS OP_RETURN Script */
export interface SlpGenesis {
/** "GENESIS" */
txType: typeof GENESIS_STR;
/** Token type of the token to create */
tokenType: SlpTokenType_Number;
/** Info about the token */
genesisInfo: GenesisInfo;
/** Number of token atoms to initially mint to out_idx=1 */
initialAtoms: bigint;
/** Output index to send the mint baton to, or undefined if none */
mintBatonOutIdx?: number;
}
/**
* Parsed SLP MINT (token type 0x01 and 0x81) OP_RETURN Script.
* Note: Token type 0x41 has no mint batons.
**/
export interface SlpMintClassic {
/** "MINT" */
txType: typeof MINT_STR;
/** Token type of the token to mint */
tokenType: typeof SLP_FUNGIBLE | typeof SLP_NFT1_GROUP;
/** Token ID of the token to mint */
tokenId: string;
/** Number of token atoms to mint to out_idx=1 */
additionalAtoms: bigint;
/** Output index to send the mint baton to, or undefined to destroy it */
mintBatonOutIdx?: number;
}
/** Parsed SLP MINT (token type 0x02) OP_RETURN Script */
export interface SlpMintVault {
/** "MINT" */
txType: typeof MINT_STR;
/** Token type of the token to mint (0x02) */
tokenType: typeof SLP_MINT_VAULT;
/** Token ID of the token to mint */
tokenId: string;
/** Array of the number of token atoms to mint to the outputs at 1 to N */
additionalAtomsArray: bigint[];
}
/** Parsed SLP MINT OP_RETURN Script */
export type SlpMint = SlpMintClassic | SlpMintVault;
/** Parsed SLP SEND OP_RETURN Script */
export interface SlpSend {
/** "SEND" */
txType: typeof SEND_STR;
/** Token type of the token to send */
tokenType: SlpTokenType_Number;
/** Token ID of the token to send */
tokenId: string;
/** Array of the number of token atoms to send to the outputs at 1 to N */
sendAtomsArray: bigint[];
}
/** Parsed SLP BURN OP_RETURN Script */
export interface SlpBurn {
/** "BURN" */
txType: typeof BURN_STR;
/** Token type of the token to burn */
tokenType: SlpTokenType_Number;
/** Token ID of the token to burn */
tokenId: string;
/** How many tokens should be burned */
burnAtoms: bigint;
}
/** New unknown SLP token type or tx type */
export interface SlpUnknown {
/** Placeholder for unknown token type or tx type */
txType: typeof UNKNOWN_STR;
/** Token type number */
tokenType: number;
}
/** Parsed SLP OP_RETURN Script */
export type SlpData = SlpGenesis | SlpMint | SlpSend | SlpBurn | SlpUnknown;
/**
* Parse the given SLP OP_RETURN Script.
*
* For data that's clearly not SLP it will return `undefined`.
* For example, if the OP_RETURN or LOKAD ID is missing.
*
* For an unknown token type, it'll return SlpUnknown.
*
* For a known token type, it'll parse the remaining data, or throw an error if
* the format is invalid or if there's an unknown tx type.
*
* This behavior mirrors that of Chronik for consistency.
**/
export function parseSlp(opreturnScript: Script): SlpData | undefined {
const ops = opreturnScript.ops();
const opreturnOp = ops.next();
// Return undefined if not OP_RETURN
if (
opreturnOp === undefined ||
isPushOp(opreturnOp) ||
opreturnOp !== OP_RETURN
) {
return undefined;
}
// Return undefined if LOKAD ID is not "SLP\0"
const lokadId = ops.next();
if (lokadId === undefined || !isPushOp(lokadId)) {
return undefined;
}
if (bytesToStr(lokadId.data) !== SLP_LOKAD_ID_STR) {
return undefined;
}
// Parse token type
const tokenTypeBytes = nextBytes(ops);
if (tokenTypeBytes === undefined) {
throw new Error('Missing tokenType');
}
if (tokenTypeBytes.length !== 1) {
throw new Error('tokenType must be exactly 1 byte');
}
const tokenType = tokenTypeBytes[0];
if (
tokenType !== SLP_FUNGIBLE &&
tokenType !== SLP_MINT_VAULT &&
tokenType !== SLP_NFT1_GROUP &&
tokenType !== SLP_NFT1_CHILD
) {
return {
txType: UNKNOWN_STR,
tokenType,
};
}
// Parse tx type (GENESIS, MINT, SEND, BURN)
const txTypeBytes = nextBytes(ops);
if (txTypeBytes === undefined) {
throw new Error('Missing txType');
}
const txType = bytesToStr(txTypeBytes);
// Handle tx type specific parsing.
// Advances the `ops` Script iterator
switch (txType) {
case GENESIS_STR:
return nextGenesis(ops, tokenType);
case MINT_STR:
return nextMint(ops, tokenType);
case SEND_STR:
return nextSend(ops, tokenType);
case BURN_STR:
return nextBurn(ops, tokenType);
default:
throw new Error('Unknown txType');
}
}
function nextGenesis(
ops: ScriptOpIter,
tokenType: SlpTokenType_Number,
): SlpGenesis {
// Parse genesis info
const tokenTicker = bytesToStr(nextBytesRequired(ops, 'tokenTicker'));
const tokenName = bytesToStr(nextBytesRequired(ops, 'tokenName'));
const url = bytesToStr(nextBytesRequired(ops, 'url'));
const hash = nextBytesRequired(ops, 'hash');
if (hash.length !== 0 && hash.length !== SLP_GENESIS_HASH_NUM_BYTES) {
throw new Error(
`hash must be either 0 or ${SLP_GENESIS_HASH_NUM_BYTES} bytes`,
);
}
const decimalsBytes = nextBytesRequired(ops, 'decimals');
if (decimalsBytes.length !== 1) {
throw new Error('decimals must be exactly 1 byte');
}
const decimals = decimalsBytes[0];
if (decimals > MAX_DECIMALS) {
throw new Error(`decimals must be at most ${MAX_DECIMALS}`);
}
// Parse mint data
let mintVaultScripthash: string | undefined = undefined;
let mintBatonOutIdx: number | undefined = undefined;
if (tokenType === SLP_MINT_VAULT) {
const scripthashBytes = nextBytesRequired(ops, 'mintVaultScripthash');
if (scripthashBytes.length !== SLP_MINT_VAULT_SCRIPTHASH_NUM_BYTES) {
throw new Error(
`mintVaultScripthash must be exactly ${SLP_MINT_VAULT_SCRIPTHASH_NUM_BYTES} ` +
'bytes long',
);
}
mintVaultScripthash = toHex(scripthashBytes);
} else {
mintBatonOutIdx = nextMintOutIdx(ops, tokenType);
}
const initialAtoms = parseSlpAtoms(nextBytesRequired(ops, 'initialAtoms'));
nextEnd(ops, 'GENESIS');
return {
txType: GENESIS_STR,
tokenType,
genesisInfo: {
tokenTicker,
tokenName,
url,
hash: hash.length !== 0 ? toHex(hash) : undefined,
mintVaultScripthash,
decimals,
},
initialAtoms,
mintBatonOutIdx,
};
}
function nextMint(ops: ScriptOpIter, tokenType: SlpTokenType_Number): SlpMint {
const tokenId = nextTokenId(ops);
if (tokenType === SLP_MINT_VAULT) {
const additionalAtomsArray = nextSlpAtomsArray(ops);
return {
txType: MINT_STR,
tokenType,
tokenId,
additionalAtomsArray,
};
} else if (tokenType === SLP_NFT1_CHILD) {
throw new Error('SLP_NFT1_CHILD cannot have MINT transactions');
} else {
const mintBatonOutIdx = nextMintOutIdx(ops, tokenType);
const additionalAtoms = parseSlpAtoms(
nextBytesRequired(ops, 'additionalAtoms'),
);
nextEnd(ops, 'MINT');
return {
txType: MINT_STR,
tokenType,
tokenId,
additionalAtoms,
mintBatonOutIdx,
};
}
}
function nextSend(ops: ScriptOpIter, tokenType: SlpTokenType_Number): SlpSend {
const tokenId = nextTokenId(ops);
const sendAtomsArray = nextSlpAtomsArray(ops);
return {
txType: SEND_STR,
tokenType,
tokenId,
sendAtomsArray,
};
}
function nextBurn(ops: ScriptOpIter, tokenType: SlpTokenType_Number): SlpBurn {
const tokenId = nextTokenId(ops);
const burnAtoms = parseSlpAtoms(nextBytesRequired(ops, 'burnAtoms'));
nextEnd(ops, 'BURN');
return {
txType: BURN_STR,
tokenType,
tokenId,
burnAtoms,
};
}
function nextBytes(iter: ScriptOpIter): Uint8Array | undefined {
const op = iter.next();
if (op === undefined) {
return undefined;
}
if (!isPushOp(op)) {
throw new Error('SLP only supports push-ops');
}
return op.data;
}
function nextBytesRequired(iter: ScriptOpIter, name: string): Uint8Array {
const bytes = nextBytes(iter);
if (bytes === undefined) {
throw new Error('Missing ' + name);
}
return bytes;
}
function nextMintOutIdx(
iter: ScriptOpIter,
tokenType: number,
): number | undefined {
const outIdxBytes = nextBytesRequired(iter, 'mintBatonOutIdx');
if (outIdxBytes.length > 1) {
throw new Error('mintBatonOutIdx must be at most 1 byte long');
}
if (outIdxBytes.length === 1) {
if (tokenType === SLP_NFT1_CHILD) {
throw new Error('SLP_NFT1_CHILD cannot have a mint baton');
}
const mintBatonOutIdx = outIdxBytes[0];
if (mintBatonOutIdx < 2) {
throw new Error('mintBatonOutIdx must be at least 2');
}
return mintBatonOutIdx;
}
return undefined;
}
function nextTokenId(iter: ScriptOpIter): string {
const tokenIdBytes = nextBytesRequired(iter, 'tokenId');
if (tokenIdBytes.length !== TOKEN_ID_NUM_BYTES) {
throw new Error(
`tokenId must be exactly ${TOKEN_ID_NUM_BYTES} bytes long`,
);
}
// Note: SLP token ID endianness is big-endian
return toHex(tokenIdBytes);
}
function nextSlpAtomsArray(iter: ScriptOpIter): bigint[] {
const atomsArray = [];
let bytes: Uint8Array | undefined = undefined;
while ((bytes = nextBytes(iter)) !== undefined) {
atomsArray.push(parseSlpAtoms(bytes));
}
if (atomsArray.length === 0) {
throw new Error('atomsArray cannot be empty');
}
if (atomsArray.length > SLP_MAX_SEND_OUTPUTS) {
throw new Error(
`atomsArray can at most be ${SLP_MAX_SEND_OUTPUTS} items long`,
);
}
return atomsArray;
}
function nextEnd(iter: ScriptOpIter, txType: string) {
if (iter.next() !== undefined) {
throw new Error(`Superfluous ${txType} bytes`);
}
}
function parseSlpAtoms(bytes: Uint8Array): bigint {
if (bytes.length !== SLP_ATOMS_NUM_BYTES) {
throw new Error(
`SLP atoms must be exactly ${SLP_ATOMS_NUM_BYTES} bytes long`,
);
}
let number = 0n;
for (let i = 0; i < SLP_ATOMS_NUM_BYTES; ++i) {
number <<= 8n;
number |= BigInt(bytes[i]);
}
return number;
}