Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
182 changes: 182 additions & 0 deletions packages/helpers/ts-templates/src/Bsv21Token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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 = 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(parseInt(lenHex, 16))
}
if (code === 0x4d) {
const b1 = this.readByteHex(); const b2 = this.readByteHex()
if (b1 === null || b2 === null) return null
return this.readBytesHex(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(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 ''
}
}

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

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 = lower.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 == null || 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')

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')
}

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'

let dec: number | undefined
if (typeof payload.dec === 'number' && Number.isFinite(payload.dec)) {
dec = payload.dec
} else if (typeof payload.dec === 'string' && /^\d+$/.test(payload.dec)) {
const n = parseInt(payload.dec, 10)
if (n >= 0 && n <= 18) dec = n
}

return {
id: isMint ? '' : (typeof payload.id === 'string' ? payload.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 = 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 = parseInt(flagsLenOp, 16)
flagsHex = hex.substring(ri + 46, ri + 46 + len * 2)
}
const flagsByte = flagsHex.length >= 2 ? 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