Skip to content
Open
6 changes: 6 additions & 0 deletions packages/helpers/ts-templates/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ export type {
MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind,
MandalaAdminLockParams, MandalaAdminUnlockParams, AssetMetadata
} from './src/MandalaAdmin.js'
export { StasToken } from './src/StasToken.js'
export type { StasTokenDecoded } from './src/StasToken.js'
export { Bsv21Token } from './src/Bsv21Token.js'
export type { Bsv21TokenDecoded } from './src/Bsv21Token.js'
export { DstasToken } from './src/DstasToken.js'
export type { DstasTokenDecoded } from './src/DstasToken.js'
197 changes: 197 additions & 0 deletions packages/helpers/ts-templates/src/Bsv21Token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { LockingScript, Utils } from '@bsv/sdk'

/**
* Bsv21Token — decoder for BSV-21 (1Sat ordinals-style fungible token)
* locking scripts. The token is an ord-inscription envelope carrying a
* BSV-20 JSON payload, followed by a standard P2PKH owner lock:
*
* OP_FALSE OP_IF "ord" OP_1 "application/bsv-20" OP_0 <json> OP_ENDIF
* OP_DUP OP_HASH160 <owner_pkh:20> OP_EQUALVERIFY OP_CHECKSIG
*
* JSON: {"p":"bsv-20","op":"transfer"|"deploy+mint","id":"<txid_vout>","amt":"<int>",...}
*
* BSV-21 amounts are divisible bigints carried as strings; ownership is plain
* P2PKH. Decode-only — building transfers is the wallet's job (see the 1sat
* inscription builder). Mirrors the wallet's `parseBsv21LockingScript`.
*/

const ORD_TAG_HEX = '6f7264' // "ord"
const CONTENT_TYPE = 'application/bsv-20'
const OP_FALSE_HEX = '00'
const OP_IF_HEX = '63'
const OP_ENDIF_HEX = '68'
const OP_DUP_HEX = '76'
const OP_HASH160_HEX = 'a9'
const OP_EQUALVERIFY_HEX = '88'
const OP_CHECKSIG_HEX = 'ac'
const PKH_PUSH_LEN_HEX = '14'

export interface Bsv21TokenDecoded {
/** Token id `<txid>_<vout>` of the deploy+mint (empty for the mint output itself). */
id: string
/** Raw token amount as a stringified bigint. */
amt: string
/** Decimals (deploy+mint only). */
dec?: number
/** Symbol / ticker. */
sym?: string
/** Icon outpoint / URL. */
icon?: string
/** True when this is the deploy+mint output (no id in payload). */
isMint: boolean
/** Trailing P2PKH owner hash160 (hex). */
ownerHash160: string
}

class HexReader {
pos = 0
constructor (public readonly hex: string) {}
readByteHex (): string | null {
if (this.pos + 2 > this.hex.length) return null
const b = this.hex.substring(this.pos, this.pos + 2)
this.pos += 2
return b
}

readBytesHex (n: number): string | null {
if (this.pos + n * 2 > this.hex.length) return null
const out = this.hex.substring(this.pos, this.pos + n * 2)
this.pos += n * 2
return out
}

readPushHex (): string | null {
const op = this.readByteHex()
if (op === null) return null
const code = Number.parseInt(op, 16)
if (code === 0) return ''
if (code >= 0x01 && code <= 0x4b) return this.readBytesHex(code)
if (code === 0x4c) {
const lenHex = this.readByteHex()
if (lenHex === null) return null
return this.readBytesHex(Number.parseInt(lenHex, 16))
}
if (code === 0x4d) {
const b1 = this.readByteHex(); const b2 = this.readByteHex()
if (b1 === null || b2 === null) return null
return this.readBytesHex(Number.parseInt(b2 + b1, 16))
}
if (code === 0x4e) {
const b1 = this.readByteHex(); const b2 = this.readByteHex()
const b3 = this.readByteHex(); const b4 = this.readByteHex()
if (b1 === null || b2 === null || b3 === null || b4 === null) return null
return this.readBytesHex(Number.parseInt(b4 + b3 + b2 + b1, 16))
}
return null
}
}

function hexToUtf8 (hex: string): string {
if (hex === '') return ''
try {
return Utils.toUTF8(Utils.toArray(hex, 'hex'))
} catch {
return ''
}
}

/** Reads the ord-inscription envelope up to and including OP_ENDIF, returning its JSON payload. */
function readOrdEnvelope (r: HexReader): any {
if (r.readPushHex() !== ORD_TAG_HEX) throw new Error('not a BSV-21 script: missing "ord" tag')

// Content-type field id: accept canonical OP_1 (0x51) or non-minimal push-of-0x01.
const peek = r.hex.substring(r.pos, r.pos + 2)
if (peek === '51') {
r.pos += 2
} else if (r.readPushHex() !== '01') {
throw new Error('not a BSV-21 script: bad content-type field id')
}

const ctHex = r.readPushHex()
if (ctHex === null || hexToUtf8(ctHex) !== CONTENT_TYPE) throw new Error('not a BSV-21 script: wrong content-type')

if (r.readByteHex() !== '00') throw new Error('not a BSV-21 script: missing OP_0 separator')

const contentHex = r.readPushHex()
if (contentHex === null) throw new Error('not a BSV-21 script: missing JSON payload')
let payload: any
try {
payload = JSON.parse(hexToUtf8(contentHex))
} catch {
throw new Error('not a BSV-21 script: invalid JSON payload')
}
if (payload?.p !== 'bsv-20') throw new Error('not a BSV-21 script: not bsv-20')

if (r.readByteHex() !== OP_ENDIF_HEX) throw new Error('not a BSV-21 script: missing OP_ENDIF')
return payload
}

/** Reads the trailing standard P2PKH owner lock, returning the owner hash160 (hex). */
function readP2pkhOwner (r: HexReader): string {
const dup = r.readByteHex()
const hash160Op = r.readByteHex()
const pushLen = r.readByteHex()
if (dup !== OP_DUP_HEX || hash160Op !== OP_HASH160_HEX || pushLen !== PKH_PUSH_LEN_HEX) {
throw new Error('not a BSV-21 script: bad P2PKH owner lock')
}
const ownerHash160 = r.readBytesHex(20)
if (ownerHash160 === null) throw new Error('not a BSV-21 script: truncated owner hash')
if (r.readByteHex() !== OP_EQUALVERIFY_HEX || r.readByteHex() !== OP_CHECKSIG_HEX) {
throw new Error('not a BSV-21 script: bad P2PKH tail')
}
return ownerHash160
}

/** Parses the optional `dec` field, accepted as a number or a digit string in [0, 18]. */
function parseDecimals (payload: any): number | undefined {
if (typeof payload.dec === 'number' && Number.isFinite(payload.dec)) return payload.dec
if (typeof payload.dec === 'string' && /^\d+$/.test(payload.dec)) {
const n = Number.parseInt(payload.dec, 10)
if (n >= 0 && n <= 18) return n
}
return undefined
}

export class Bsv21Token {
static isBsv21 (script: LockingScript): boolean {
try {
Bsv21Token.decode(script)
return true
} catch {
return false
}
}

/**
* Decodes a BSV-21 locking script.
* @throws if the script is not a recognisable BSV-21 output.
*/
static decode (script: LockingScript): Bsv21TokenDecoded {
const lower = script.toHex().toLowerCase()
if (lower.length < 60) throw new Error('not a BSV-21 script: too short')
if (!lower.startsWith(OP_FALSE_HEX + OP_IF_HEX)) throw new Error('not a BSV-21 script: missing OP_FALSE OP_IF')

const r = new HexReader(lower)
r.pos = 4 // past OP_FALSE OP_IF

const payload = readOrdEnvelope(r)
const ownerHash160 = readP2pkhOwner(r)

const amt: string | undefined = payload.amt
if (typeof amt !== 'string' || !/^\d+$/.test(amt)) throw new Error('not a BSV-21 script: bad amount')

const isMint = payload.op === 'deploy+mint'
const dec = parseDecimals(payload)
const id = !isMint && typeof payload.id === 'string' ? payload.id : ''

return {
id,
amt,
dec,
sym: typeof payload.sym === 'string' ? payload.sym : undefined,
icon: typeof payload.icon === 'string' ? payload.icon : undefined,
isMint,
ownerHash160
}
}
}
105 changes: 105 additions & 0 deletions packages/helpers/ts-templates/src/DstasToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { LockingScript } from '@bsv/sdk'

/**
* DstasToken — decoder for DSTAS (Divisible STAS / STAS 3.0) locking scripts.
*
* DSTAS uses the dxs-bsv-token-sdk template:
*
* <owner pkh:20> <action data> [ENGINE ~2.9KB] OP_RETURN
* <redemption/protoID pkh:20 = tokenId> <flags> <service field per flag> <optional data...>
*
* Unlike the dxs SDK's full `LockingScriptReader` (which template-matches the
* whole body), this is a minimal *structural* recogniser sufficient for an
* overlay indexer: it extracts the owner, the tokenId (redemption pkh), the
* flags, and the frozen marker. DSTAS is satoshi-denominated, so the token
* amount is the containing output's satoshi value (read by the caller).
*
* Recognition signals (validated against real dxs SDK output):
* - the script opens with a 20-byte push (the owner) — `14 <20 bytes>`;
* - the body is large (the ~2.9KB engine);
* - `6a 14 <20 bytes>` (OP_RETURN + redemption push) appears once, near the
* end — the engine body contains no `6a14`.
*
* Decode-only; building DSTAS scripts is the dxs SDK's job.
*/

export interface DstasTokenDecoded {
/** Token identity for indexing + conservation — the redemption/protoID pkh. */
assetId: string
/** Same value as assetId; the redemption (protoID) pkh, hex. */
tokenId: string
/** Owner public-key hash (20-byte hex). */
ownerHash160: string
/** Flags byte(s), hex (bit 0x01 = freezable, 0x02 = confiscatable). */
flagsHex: string
freezeEnabled: boolean
confiscationEnabled: boolean
/** True when the action-data marker indicates a frozen UTXO. */
frozen: boolean
}

const OWNER_PUSH_OP = '14' // push 20 bytes
// DSTAS scripts carry the ~2.9KB engine; anything smaller is not DSTAS.
const MIN_HEX_LEN = 4000

function isSinglePush (op: string): boolean {
const code = Number.parseInt(op, 16)
return code >= 0x01 && code <= 0x4b
}

export class DstasToken {
static isDstas (script: LockingScript): boolean {
try {
DstasToken.decode(script)
return true
} catch {
return false
}
}

/**
* Decodes a DSTAS locking script's identity fields.
* @throws if the script is not a recognisable DSTAS script.
*/
static decode (script: LockingScript): DstasTokenDecoded {
const hex = script.toHex().toLowerCase()
if (hex.length < MIN_HEX_LEN) throw new Error('not a DSTAS script: too short')
if (!hex.startsWith(OWNER_PUSH_OP)) throw new Error('not a DSTAS script: missing 20-byte owner push')

const ownerHash160 = hex.substring(2, 42)

// OP_RETURN (0x6a) + redemption push (0x14) — unique in a DSTAS body.
const ri = hex.lastIndexOf('6a14')
if (ri < 0) throw new Error('not a DSTAS script: missing OP_RETURN + redemption')
const tokenId = hex.substring(ri + 4, ri + 44)
if (tokenId.length !== 40) throw new Error('not a DSTAS script: truncated redemption')

// Flags push immediately follows the redemption push.
let flagsHex = ''
const flagsLenOp = hex.substring(ri + 44, ri + 46)
if (isSinglePush(flagsLenOp)) {
const len = Number.parseInt(flagsLenOp, 16)
flagsHex = hex.substring(ri + 46, ri + 46 + len * 2)
}
const flagsByte = flagsHex.length >= 2 ? Number.parseInt(flagsHex.substring(0, 2), 16) : 0
const freezeEnabled = (flagsByte & 0x01) !== 0
const confiscationEnabled = (flagsByte & 0x02) !== 0

// Action-data marker sits right after the owner push:
// OP_0 (00) = neutral; OP_2 (52) = frozen; push prefixed 0x02 = frozen.
const actionOp = hex.substring(42, 44)
const frozen =
actionOp === '52' ||
(isSinglePush(actionOp) && hex.substring(44, 46) === '02')

return {
assetId: tokenId,
tokenId,
ownerHash160,
flagsHex,
freezeEnabled,
confiscationEnabled,
frozen
}
}
}
Loading
Loading