@bsv/templates 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cjs/mod.js +7 -1
  2. package/dist/cjs/mod.js.map +1 -1
  3. package/dist/cjs/package.json +1 -1
  4. package/dist/cjs/src/Bsv21Token.js +194 -0
  5. package/dist/cjs/src/Bsv21Token.js.map +1 -0
  6. package/dist/cjs/src/DstasToken.js +66 -0
  7. package/dist/cjs/src/DstasToken.js.map +1 -0
  8. package/dist/cjs/src/StasToken.js +111 -0
  9. package/dist/cjs/src/StasToken.js.map +1 -0
  10. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  11. package/dist/esm/mod.js +3 -0
  12. package/dist/esm/mod.js.map +1 -1
  13. package/dist/esm/src/Bsv21Token.js +191 -0
  14. package/dist/esm/src/Bsv21Token.js.map +1 -0
  15. package/dist/esm/src/DstasToken.js +62 -0
  16. package/dist/esm/src/DstasToken.js.map +1 -0
  17. package/dist/esm/src/StasToken.js +106 -0
  18. package/dist/esm/src/StasToken.js.map +1 -0
  19. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  20. package/dist/types/mod.d.ts +6 -0
  21. package/dist/types/mod.d.ts.map +1 -1
  22. package/dist/types/src/Bsv21Token.d.ts +26 -0
  23. package/dist/types/src/Bsv21Token.d.ts.map +1 -0
  24. package/dist/types/src/DstasToken.d.ts +46 -0
  25. package/dist/types/src/DstasToken.d.ts.map +1 -0
  26. package/dist/types/src/StasToken.d.ts +40 -0
  27. package/dist/types/src/StasToken.d.ts.map +1 -0
  28. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  29. package/mod.ts +6 -0
  30. package/package.json +1 -1
  31. package/src/Bsv21Token.ts +197 -0
  32. package/src/DstasToken.ts +105 -0
  33. package/src/StasToken.ts +136 -0
  34. package/src/__tests/Bsv21Token.test.ts +56 -0
  35. package/src/__tests/DstasToken.test.ts +33 -0
  36. package/src/__tests/StasToken.test.ts +44 -0
  37. package/src/__tests/dstas-fixtures.ts +6 -0
@@ -0,0 +1,197 @@
1
+ import { LockingScript, Utils } from '@bsv/sdk'
2
+
3
+ /**
4
+ * Bsv21Token — decoder for BSV-21 (1Sat ordinals-style fungible token)
5
+ * locking scripts. The token is an ord-inscription envelope carrying a
6
+ * BSV-20 JSON payload, followed by a standard P2PKH owner lock:
7
+ *
8
+ * OP_FALSE OP_IF "ord" OP_1 "application/bsv-20" OP_0 <json> OP_ENDIF
9
+ * OP_DUP OP_HASH160 <owner_pkh:20> OP_EQUALVERIFY OP_CHECKSIG
10
+ *
11
+ * JSON: {"p":"bsv-20","op":"transfer"|"deploy+mint","id":"<txid_vout>","amt":"<int>",...}
12
+ *
13
+ * BSV-21 amounts are divisible bigints carried as strings; ownership is plain
14
+ * P2PKH. Decode-only — building transfers is the wallet's job (see the 1sat
15
+ * inscription builder). Mirrors the wallet's `parseBsv21LockingScript`.
16
+ */
17
+
18
+ const ORD_TAG_HEX = '6f7264' // "ord"
19
+ const CONTENT_TYPE = 'application/bsv-20'
20
+ const OP_FALSE_HEX = '00'
21
+ const OP_IF_HEX = '63'
22
+ const OP_ENDIF_HEX = '68'
23
+ const OP_DUP_HEX = '76'
24
+ const OP_HASH160_HEX = 'a9'
25
+ const OP_EQUALVERIFY_HEX = '88'
26
+ const OP_CHECKSIG_HEX = 'ac'
27
+ const PKH_PUSH_LEN_HEX = '14'
28
+
29
+ export interface Bsv21TokenDecoded {
30
+ /** Token id `<txid>_<vout>` of the deploy+mint (empty for the mint output itself). */
31
+ id: string
32
+ /** Raw token amount as a stringified bigint. */
33
+ amt: string
34
+ /** Decimals (deploy+mint only). */
35
+ dec?: number
36
+ /** Symbol / ticker. */
37
+ sym?: string
38
+ /** Icon outpoint / URL. */
39
+ icon?: string
40
+ /** True when this is the deploy+mint output (no id in payload). */
41
+ isMint: boolean
42
+ /** Trailing P2PKH owner hash160 (hex). */
43
+ ownerHash160: string
44
+ }
45
+
46
+ class HexReader {
47
+ pos = 0
48
+ constructor (public readonly hex: string) {}
49
+ readByteHex (): string | null {
50
+ if (this.pos + 2 > this.hex.length) return null
51
+ const b = this.hex.substring(this.pos, this.pos + 2)
52
+ this.pos += 2
53
+ return b
54
+ }
55
+
56
+ readBytesHex (n: number): string | null {
57
+ if (this.pos + n * 2 > this.hex.length) return null
58
+ const out = this.hex.substring(this.pos, this.pos + n * 2)
59
+ this.pos += n * 2
60
+ return out
61
+ }
62
+
63
+ readPushHex (): string | null {
64
+ const op = this.readByteHex()
65
+ if (op === null) return null
66
+ const code = Number.parseInt(op, 16)
67
+ if (code === 0) return ''
68
+ if (code >= 0x01 && code <= 0x4b) return this.readBytesHex(code)
69
+ if (code === 0x4c) {
70
+ const lenHex = this.readByteHex()
71
+ if (lenHex === null) return null
72
+ return this.readBytesHex(Number.parseInt(lenHex, 16))
73
+ }
74
+ if (code === 0x4d) {
75
+ const b1 = this.readByteHex(); const b2 = this.readByteHex()
76
+ if (b1 === null || b2 === null) return null
77
+ return this.readBytesHex(Number.parseInt(b2 + b1, 16))
78
+ }
79
+ if (code === 0x4e) {
80
+ const b1 = this.readByteHex(); const b2 = this.readByteHex()
81
+ const b3 = this.readByteHex(); const b4 = this.readByteHex()
82
+ if (b1 === null || b2 === null || b3 === null || b4 === null) return null
83
+ return this.readBytesHex(Number.parseInt(b4 + b3 + b2 + b1, 16))
84
+ }
85
+ return null
86
+ }
87
+ }
88
+
89
+ function hexToUtf8 (hex: string): string {
90
+ if (hex === '') return ''
91
+ try {
92
+ return Utils.toUTF8(Utils.toArray(hex, 'hex'))
93
+ } catch {
94
+ return ''
95
+ }
96
+ }
97
+
98
+ /** Reads the ord-inscription envelope up to and including OP_ENDIF, returning its JSON payload. */
99
+ function readOrdEnvelope (r: HexReader): any {
100
+ if (r.readPushHex() !== ORD_TAG_HEX) throw new Error('not a BSV-21 script: missing "ord" tag')
101
+
102
+ // Content-type field id: accept canonical OP_1 (0x51) or non-minimal push-of-0x01.
103
+ const peek = r.hex.substring(r.pos, r.pos + 2)
104
+ if (peek === '51') {
105
+ r.pos += 2
106
+ } else if (r.readPushHex() !== '01') {
107
+ throw new Error('not a BSV-21 script: bad content-type field id')
108
+ }
109
+
110
+ const ctHex = r.readPushHex()
111
+ if (ctHex === null || hexToUtf8(ctHex) !== CONTENT_TYPE) throw new Error('not a BSV-21 script: wrong content-type')
112
+
113
+ if (r.readByteHex() !== '00') throw new Error('not a BSV-21 script: missing OP_0 separator')
114
+
115
+ const contentHex = r.readPushHex()
116
+ if (contentHex === null) throw new Error('not a BSV-21 script: missing JSON payload')
117
+ let payload: any
118
+ try {
119
+ payload = JSON.parse(hexToUtf8(contentHex))
120
+ } catch {
121
+ throw new Error('not a BSV-21 script: invalid JSON payload')
122
+ }
123
+ if (payload?.p !== 'bsv-20') throw new Error('not a BSV-21 script: not bsv-20')
124
+
125
+ if (r.readByteHex() !== OP_ENDIF_HEX) throw new Error('not a BSV-21 script: missing OP_ENDIF')
126
+ return payload
127
+ }
128
+
129
+ /** Reads the trailing standard P2PKH owner lock, returning the owner hash160 (hex). */
130
+ function readP2pkhOwner (r: HexReader): string {
131
+ const dup = r.readByteHex()
132
+ const hash160Op = r.readByteHex()
133
+ const pushLen = r.readByteHex()
134
+ if (dup !== OP_DUP_HEX || hash160Op !== OP_HASH160_HEX || pushLen !== PKH_PUSH_LEN_HEX) {
135
+ throw new Error('not a BSV-21 script: bad P2PKH owner lock')
136
+ }
137
+ const ownerHash160 = r.readBytesHex(20)
138
+ if (ownerHash160 === null) throw new Error('not a BSV-21 script: truncated owner hash')
139
+ if (r.readByteHex() !== OP_EQUALVERIFY_HEX || r.readByteHex() !== OP_CHECKSIG_HEX) {
140
+ throw new Error('not a BSV-21 script: bad P2PKH tail')
141
+ }
142
+ return ownerHash160
143
+ }
144
+
145
+ /** Parses the optional `dec` field, accepted as a number or a digit string in [0, 18]. */
146
+ function parseDecimals (payload: any): number | undefined {
147
+ if (typeof payload.dec === 'number' && Number.isFinite(payload.dec)) return payload.dec
148
+ if (typeof payload.dec === 'string' && /^\d+$/.test(payload.dec)) {
149
+ const n = Number.parseInt(payload.dec, 10)
150
+ if (n >= 0 && n <= 18) return n
151
+ }
152
+ return undefined
153
+ }
154
+
155
+ export class Bsv21Token {
156
+ static isBsv21 (script: LockingScript): boolean {
157
+ try {
158
+ Bsv21Token.decode(script)
159
+ return true
160
+ } catch {
161
+ return false
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Decodes a BSV-21 locking script.
167
+ * @throws if the script is not a recognisable BSV-21 output.
168
+ */
169
+ static decode (script: LockingScript): Bsv21TokenDecoded {
170
+ const lower = script.toHex().toLowerCase()
171
+ if (lower.length < 60) throw new Error('not a BSV-21 script: too short')
172
+ if (!lower.startsWith(OP_FALSE_HEX + OP_IF_HEX)) throw new Error('not a BSV-21 script: missing OP_FALSE OP_IF')
173
+
174
+ const r = new HexReader(lower)
175
+ r.pos = 4 // past OP_FALSE OP_IF
176
+
177
+ const payload = readOrdEnvelope(r)
178
+ const ownerHash160 = readP2pkhOwner(r)
179
+
180
+ const amt: string | undefined = payload.amt
181
+ if (typeof amt !== 'string' || !/^\d+$/.test(amt)) throw new Error('not a BSV-21 script: bad amount')
182
+
183
+ const isMint = payload.op === 'deploy+mint'
184
+ const dec = parseDecimals(payload)
185
+ const id = !isMint && typeof payload.id === 'string' ? payload.id : ''
186
+
187
+ return {
188
+ id,
189
+ amt,
190
+ dec,
191
+ sym: typeof payload.sym === 'string' ? payload.sym : undefined,
192
+ icon: typeof payload.icon === 'string' ? payload.icon : undefined,
193
+ isMint,
194
+ ownerHash160
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,105 @@
1
+ import { LockingScript } from '@bsv/sdk'
2
+
3
+ /**
4
+ * DstasToken — decoder for DSTAS (Divisible STAS / STAS 3.0) locking scripts.
5
+ *
6
+ * DSTAS uses the dxs-bsv-token-sdk template:
7
+ *
8
+ * <owner pkh:20> <action data> [ENGINE ~2.9KB] OP_RETURN
9
+ * <redemption/protoID pkh:20 = tokenId> <flags> <service field per flag> <optional data...>
10
+ *
11
+ * Unlike the dxs SDK's full `LockingScriptReader` (which template-matches the
12
+ * whole body), this is a minimal *structural* recogniser sufficient for an
13
+ * overlay indexer: it extracts the owner, the tokenId (redemption pkh), the
14
+ * flags, and the frozen marker. DSTAS is satoshi-denominated, so the token
15
+ * amount is the containing output's satoshi value (read by the caller).
16
+ *
17
+ * Recognition signals (validated against real dxs SDK output):
18
+ * - the script opens with a 20-byte push (the owner) — `14 <20 bytes>`;
19
+ * - the body is large (the ~2.9KB engine);
20
+ * - `6a 14 <20 bytes>` (OP_RETURN + redemption push) appears once, near the
21
+ * end — the engine body contains no `6a14`.
22
+ *
23
+ * Decode-only; building DSTAS scripts is the dxs SDK's job.
24
+ */
25
+
26
+ export interface DstasTokenDecoded {
27
+ /** Token identity for indexing + conservation — the redemption/protoID pkh. */
28
+ assetId: string
29
+ /** Same value as assetId; the redemption (protoID) pkh, hex. */
30
+ tokenId: string
31
+ /** Owner public-key hash (20-byte hex). */
32
+ ownerHash160: string
33
+ /** Flags byte(s), hex (bit 0x01 = freezable, 0x02 = confiscatable). */
34
+ flagsHex: string
35
+ freezeEnabled: boolean
36
+ confiscationEnabled: boolean
37
+ /** True when the action-data marker indicates a frozen UTXO. */
38
+ frozen: boolean
39
+ }
40
+
41
+ const OWNER_PUSH_OP = '14' // push 20 bytes
42
+ // DSTAS scripts carry the ~2.9KB engine; anything smaller is not DSTAS.
43
+ const MIN_HEX_LEN = 4000
44
+
45
+ function isSinglePush (op: string): boolean {
46
+ const code = Number.parseInt(op, 16)
47
+ return code >= 0x01 && code <= 0x4b
48
+ }
49
+
50
+ export class DstasToken {
51
+ static isDstas (script: LockingScript): boolean {
52
+ try {
53
+ DstasToken.decode(script)
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Decodes a DSTAS locking script's identity fields.
62
+ * @throws if the script is not a recognisable DSTAS script.
63
+ */
64
+ static decode (script: LockingScript): DstasTokenDecoded {
65
+ const hex = script.toHex().toLowerCase()
66
+ if (hex.length < MIN_HEX_LEN) throw new Error('not a DSTAS script: too short')
67
+ if (!hex.startsWith(OWNER_PUSH_OP)) throw new Error('not a DSTAS script: missing 20-byte owner push')
68
+
69
+ const ownerHash160 = hex.substring(2, 42)
70
+
71
+ // OP_RETURN (0x6a) + redemption push (0x14) — unique in a DSTAS body.
72
+ const ri = hex.lastIndexOf('6a14')
73
+ if (ri < 0) throw new Error('not a DSTAS script: missing OP_RETURN + redemption')
74
+ const tokenId = hex.substring(ri + 4, ri + 44)
75
+ if (tokenId.length !== 40) throw new Error('not a DSTAS script: truncated redemption')
76
+
77
+ // Flags push immediately follows the redemption push.
78
+ let flagsHex = ''
79
+ const flagsLenOp = hex.substring(ri + 44, ri + 46)
80
+ if (isSinglePush(flagsLenOp)) {
81
+ const len = Number.parseInt(flagsLenOp, 16)
82
+ flagsHex = hex.substring(ri + 46, ri + 46 + len * 2)
83
+ }
84
+ const flagsByte = flagsHex.length >= 2 ? Number.parseInt(flagsHex.substring(0, 2), 16) : 0
85
+ const freezeEnabled = (flagsByte & 0x01) !== 0
86
+ const confiscationEnabled = (flagsByte & 0x02) !== 0
87
+
88
+ // Action-data marker sits right after the owner push:
89
+ // OP_0 (00) = neutral; OP_2 (52) = frozen; push prefixed 0x02 = frozen.
90
+ const actionOp = hex.substring(42, 44)
91
+ const frozen =
92
+ actionOp === '52' ||
93
+ (isSinglePush(actionOp) && hex.substring(44, 46) === '02')
94
+
95
+ return {
96
+ assetId: tokenId,
97
+ tokenId,
98
+ ownerHash160,
99
+ flagsHex,
100
+ freezeEnabled,
101
+ confiscationEnabled,
102
+ frozen
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,136 @@
1
+ import { LockingScript, Utils } from '@bsv/sdk'
2
+
3
+ /**
4
+ * StasToken — decoder for **classic STAS** (legacy P2STAS / STAS 1.0) locking
5
+ * scripts. Unlike {@link MandalaToken}, classic STAS is satoshi-denominated:
6
+ * the token amount IS the output's satoshi value, so this template only
7
+ * recovers the on-chain *identity* fields (owner PKH + symbol). The amount is
8
+ * read from the containing output by the caller.
9
+ *
10
+ * Script shape produced by stas-js CreateContract:
11
+ *
12
+ * 76a914 <owner_pkh:20> 88ac69 <engine ~2.9KB> 6a <flags> <symbol> <data...>
13
+ *
14
+ * The owner hash160 sits at the well-known P2PKH-like prefix. The engine body
15
+ * is large and opaque (and may contain incidental `6a` bytes), so the OP_RETURN
16
+ * trailer is located by scanning from the end, matching the wallet's
17
+ * `parseClassicStasMetadata` source of truth.
18
+ *
19
+ * Building/unlocking classic STAS scripts is the stas-js engine's job; this
20
+ * template is decode-only.
21
+ */
22
+
23
+ export interface StasTokenDecoded {
24
+ /** Token identity used for indexing + conservation grouping (the symbol). */
25
+ assetId: string
26
+ /** Token symbol parsed from the OP_RETURN trailer, or null if absent. */
27
+ symbol: string | null
28
+ /** Owner public-key hash (20-byte hex) from the P2PKH-like prefix. */
29
+ ownerHash160: string
30
+ /** Flags byte (hex) from the OP_RETURN trailer, or null if absent. */
31
+ flagsHex: string | null
32
+ }
33
+
34
+ const P2PKH_PREFIX = '76a914'
35
+ const STAS_MARKER = '88ac69'
36
+
37
+ interface PushLength { len: number, dataStart: number }
38
+
39
+ /** Resolves a push opcode's payload length + data offset, or null for a non-push opcode. */
40
+ function pushDataLength (scriptHex: string, opcode: number, pos: number): PushLength | null {
41
+ if (opcode >= 0x01 && opcode <= 0x4b) return { len: opcode, dataStart: pos }
42
+ if (opcode === 0x4c) {
43
+ if (pos + 2 > scriptHex.length) return null
44
+ return { len: Number.parseInt(scriptHex.substring(pos, pos + 2), 16), dataStart: pos + 2 }
45
+ }
46
+ if (opcode === 0x4d) {
47
+ if (pos + 4 > scriptHex.length) return null
48
+ const b1 = scriptHex.substring(pos, pos + 2)
49
+ const b2 = scriptHex.substring(pos + 2, pos + 4)
50
+ return { len: Number.parseInt(b2 + b1, 16), dataStart: pos + 4 }
51
+ }
52
+ if (opcode === 0x4e) {
53
+ if (pos + 8 > scriptHex.length) return null
54
+ const b1 = scriptHex.substring(pos, pos + 2)
55
+ const b2 = scriptHex.substring(pos + 2, pos + 4)
56
+ const b3 = scriptHex.substring(pos + 4, pos + 6)
57
+ const b4 = scriptHex.substring(pos + 6, pos + 8)
58
+ return { len: Number.parseInt(b4 + b3 + b2 + b1, 16), dataStart: pos + 8 }
59
+ }
60
+ return null
61
+ }
62
+
63
+ /** Reads push-data slots starting at a hex offset (after OP_RETURN). */
64
+ function readPushes (scriptHex: string, startPos: number, max = 8): string[] {
65
+ const pushes: string[] = []
66
+ let pos = startPos
67
+ while (pos < scriptHex.length && pushes.length < max) {
68
+ if (pos + 2 > scriptHex.length) break
69
+ const opcode = Number.parseInt(scriptHex.substring(pos, pos + 2), 16)
70
+ if (Number.isNaN(opcode)) break
71
+ pos += 2
72
+ if (opcode === 0) {
73
+ pushes.push('')
74
+ continue
75
+ }
76
+ const push = pushDataLength(scriptHex, opcode, pos)
77
+ if (push === null) break // non-push opcode (or truncated length) after OP_RETURN — stop
78
+ pushes.push(scriptHex.substring(push.dataStart, push.dataStart + push.len * 2))
79
+ pos = push.dataStart + push.len * 2
80
+ }
81
+ return pushes
82
+ }
83
+
84
+ function hexToUtf8 (hex: string): string {
85
+ if (hex === '') return ''
86
+ try {
87
+ return Utils.toUTF8(Utils.toArray(hex, 'hex'))
88
+ } catch {
89
+ return ''
90
+ }
91
+ }
92
+
93
+ export class StasToken {
94
+ /** True if the script carries the classic STAS prefix + marker. */
95
+ static isStas (script: LockingScript): boolean {
96
+ const hex = script.toHex()
97
+ return hex.startsWith(P2PKH_PREFIX) && hex.substring(46, 52) === STAS_MARKER
98
+ }
99
+
100
+ /**
101
+ * Decodes a classic STAS locking script into its identity fields.
102
+ * @throws if the script is not a classic STAS script.
103
+ */
104
+ static decode (script: LockingScript): StasTokenDecoded {
105
+ const hex = script.toHex()
106
+ if (hex.length < 56) throw new Error('not a STAS script: too short')
107
+ if (!hex.startsWith(P2PKH_PREFIX)) throw new Error('not a STAS script: missing P2PKH prefix')
108
+ if (hex.substring(46, 52) !== STAS_MARKER) throw new Error('not a STAS script: missing STAS marker')
109
+
110
+ const ownerHash160 = hex.substring(6, 46)
111
+
112
+ // OP_RETURN (0x6a) is placed by CreateContract as the last opcode before
113
+ // the data region. The engine body may contain incidental 0x6a bytes, so
114
+ // scan from the back.
115
+ const opReturnIdx = hex.lastIndexOf('6a')
116
+ let symbol: string | null = null
117
+ let flagsHex: string | null = null
118
+ if (opReturnIdx >= 0) {
119
+ const pushes = readPushes(hex, opReturnIdx + 2)
120
+ // Layout after OP_RETURN: [flagsByte, symbol, data, ...].
121
+ flagsHex = pushes[0]?.length === 2 ? pushes[0] : null
122
+ const symbolHex = pushes[1] ?? null
123
+ symbol = (symbolHex != null && symbolHex !== '')
124
+ ? (hexToUtf8(symbolHex).replace(/[\x00- ]/g, '').trim() || null)
125
+ : null
126
+ }
127
+
128
+ // assetId groups inputs/outputs of the same token for conservation. The
129
+ // symbol is the only identity carried in a classic STAS script; tokens
130
+ // with no symbol fall back to the owner-agnostic script tail hash so the
131
+ // grouping is still stable within a single transfer.
132
+ const assetId = symbol ?? `stas:${hex.substring(52, 68)}`
133
+
134
+ return { assetId, symbol, ownerHash160, flagsHex }
135
+ }
136
+ }
@@ -0,0 +1,56 @@
1
+ import { Bsv21Token } from '../Bsv21Token'
2
+ import { LockingScript, Utils } from '@bsv/sdk'
3
+
4
+ const OWNER = 'ab'.repeat(20)
5
+
6
+ function utf8ToHex (s: string): string {
7
+ return Utils.toArray(s, 'utf8').map(b => b.toString(16).padStart(2, '0')).join('')
8
+ }
9
+ function push (bytesHex: string): string {
10
+ const len = bytesHex.length / 2
11
+ if (len === 0) return '00'
12
+ if (len <= 0x4b) return len.toString(16).padStart(2, '0') + bytesHex
13
+ if (len <= 0xff) return '4c' + len.toString(16).padStart(2, '0') + bytesHex
14
+ const lo = len & 0xff; const hi = (len >> 8) & 0xff
15
+ return '4d' + lo.toString(16).padStart(2, '0') + hi.toString(16).padStart(2, '0') + bytesHex
16
+ }
17
+
18
+ // Build a BSV-21 envelope with the given JSON payload + P2PKH owner tail.
19
+ function bsv21Script (payload: Record<string, string>, owner = OWNER): LockingScript {
20
+ const json = utf8ToHex(JSON.stringify(payload))
21
+ const envelope =
22
+ '00' + '63' + // OP_FALSE OP_IF
23
+ push(utf8ToHex('ord')) +
24
+ '51' + // OP_1 content-type tag
25
+ push(utf8ToHex('application/bsv-20')) +
26
+ '00' + // OP_0 separator
27
+ push(json) +
28
+ '68' // OP_ENDIF
29
+ const p2pkh = '76a914' + owner + '88ac'
30
+ return LockingScript.fromHex(envelope + p2pkh)
31
+ }
32
+
33
+ describe('Bsv21Token.decode', () => {
34
+ it('decodes a transfer output', () => {
35
+ const id = `${'cd'.repeat(32)}_0`
36
+ const d = Bsv21Token.decode(bsv21Script({ p: 'bsv-20', op: 'transfer', id, amt: '500' }))
37
+ expect(d).toMatchObject({ id, amt: '500', isMint: false, ownerHash160: OWNER })
38
+ })
39
+
40
+ it('decodes a deploy+mint output (no id in payload)', () => {
41
+ const d = Bsv21Token.decode(bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '21000000', dec: '8', sym: 'TIK' }))
42
+ expect(d).toMatchObject({ id: '', amt: '21000000', dec: 8, sym: 'TIK', isMint: true })
43
+ })
44
+
45
+ it('isBsv21 is false for plain P2PKH', () => {
46
+ expect(Bsv21Token.isBsv21(LockingScript.fromHex(`76a914${OWNER}88ac`))).toBe(false)
47
+ })
48
+
49
+ it('throws on a non-bsv-20 inscription protocol', () => {
50
+ expect(() => Bsv21Token.decode(bsv21Script({ p: 'bsv-21', op: 'transfer', id: 'x', amt: '1' }))).toThrow(/bsv-20/)
51
+ })
52
+
53
+ it('throws on a missing/invalid amount', () => {
54
+ expect(() => Bsv21Token.decode(bsv21Script({ p: 'bsv-20', op: 'transfer', id: 'x' } as any))).toThrow(/amount/)
55
+ })
56
+ })
@@ -0,0 +1,33 @@
1
+ import { DstasToken } from '../DstasToken'
2
+ import { LockingScript } from '@bsv/sdk'
3
+ import { DSTAS_PLAIN_HEX, DSTAS_FROZEN_HEX, DSTAS_OWNER, DSTAS_TOKEN_ID } from './dstas-fixtures'
4
+
5
+ describe('DstasToken.decode (against real dxs-bsv-token-sdk output)', () => {
6
+ it('recovers owner, tokenId, flags from a real DSTAS script', () => {
7
+ const d = DstasToken.decode(LockingScript.fromHex(DSTAS_PLAIN_HEX))
8
+ expect(d.ownerHash160).toBe(DSTAS_OWNER)
9
+ expect(d.tokenId).toBe(DSTAS_TOKEN_ID)
10
+ expect(d.assetId).toBe(DSTAS_TOKEN_ID)
11
+ expect(d.flagsHex).toBe('03')
12
+ expect(d.freezeEnabled).toBe(true)
13
+ expect(d.confiscationEnabled).toBe(true)
14
+ expect(d.frozen).toBe(false)
15
+ })
16
+
17
+ it('detects the frozen marker (OP_2 action data)', () => {
18
+ const d = DstasToken.decode(LockingScript.fromHex(DSTAS_FROZEN_HEX))
19
+ expect(d.frozen).toBe(true)
20
+ expect(d.tokenId).toBe(DSTAS_TOKEN_ID)
21
+ })
22
+
23
+ it('isDstas is true for DSTAS, false for plain P2PKH and classic STAS', () => {
24
+ expect(DstasToken.isDstas(LockingScript.fromHex(DSTAS_PLAIN_HEX))).toBe(true)
25
+ expect(DstasToken.isDstas(LockingScript.fromHex(`76a914${DSTAS_OWNER}88ac`))).toBe(false)
26
+ // classic STAS prefix is 76a914… not a 20-byte owner push, and short.
27
+ expect(DstasToken.isDstas(LockingScript.fromHex(`76a914${DSTAS_OWNER}88ac69` + 'ac'.repeat(8)))).toBe(false)
28
+ })
29
+
30
+ it('throws on a short / non-DSTAS script', () => {
31
+ expect(() => DstasToken.decode(LockingScript.fromHex(`14${DSTAS_OWNER}00`))).toThrow(/DSTAS/)
32
+ })
33
+ })
@@ -0,0 +1,44 @@
1
+ import { StasToken } from '../StasToken'
2
+ import { LockingScript } from '@bsv/sdk'
3
+
4
+ // Build a synthetic classic STAS script matching stas-js CreateContract shape:
5
+ // 76a914 <owner_pkh:20> 88ac69 <engine> 6a <flags push> <symbol push> <data>
6
+ const ownerHash160 = 'ab'.repeat(20)
7
+ const engine = 'ac'.repeat(8) // opaque filler, deliberately free of 0x6a bytes
8
+ const flagsPush = '0100' // push 1 byte: flags = 0x00
9
+ const symbolPush = '04' + '54455354' // push 4 bytes: "TEST"
10
+ const stasHex = `76a914${ownerHash160}88ac69${engine}6a${flagsPush}${symbolPush}`
11
+
12
+ describe('StasToken.decode', () => {
13
+ it('recovers owner, symbol, flags, and assetId from a classic STAS script', () => {
14
+ const decoded = StasToken.decode(LockingScript.fromHex(stasHex))
15
+ expect(decoded.ownerHash160).toBe(ownerHash160)
16
+ expect(decoded.symbol).toBe('TEST')
17
+ expect(decoded.assetId).toBe('TEST')
18
+ expect(decoded.flagsHex).toBe('00')
19
+ })
20
+
21
+ it('isStas is true for a STAS script and false for plain P2PKH', () => {
22
+ const p2pkh = `76a914${ownerHash160}88ac`
23
+ expect(StasToken.isStas(LockingScript.fromHex(stasHex))).toBe(true)
24
+ expect(StasToken.isStas(LockingScript.fromHex(p2pkh))).toBe(false)
25
+ })
26
+
27
+ it('throws when the STAS marker is absent (long P2PKH-like script)', () => {
28
+ // ≥56 hex chars, starts with the P2PKH prefix but lacks the 88ac69 marker.
29
+ const notStas = `76a914${ownerHash160}88accccccccccc`
30
+ expect(() => StasToken.decode(LockingScript.fromHex(notStas))).toThrow(/STAS marker/)
31
+ })
32
+
33
+ it('throws when the P2PKH prefix is absent', () => {
34
+ expect(() => StasToken.decode(LockingScript.fromHex('6a0048656c6c6f'))).toThrow(/STAS/)
35
+ })
36
+
37
+ it('falls back to a script-derived assetId when no symbol is present', () => {
38
+ // OP_RETURN with only a flags push, no symbol slot.
39
+ const noSymbol = `76a914${ownerHash160}88ac69${engine}6a${flagsPush}`
40
+ const decoded = StasToken.decode(LockingScript.fromHex(noSymbol))
41
+ expect(decoded.symbol).toBeNull()
42
+ expect(decoded.assetId).toMatch(/^stas:/)
43
+ })
44
+ })
@@ -0,0 +1,6 @@
1
+ // Real DSTAS locking scripts generated by dxs-bsv-token-sdk (buildDstasLockingScript).
2
+ // owner=2f2ec98d… redemption(tokenId)=b4ab0fff… flags=0x03 (freeze+confiscation).
3
+ export const DSTAS_PLAIN_HEX = '142f2ec98dfa6429a028536a6c9451f702daa3a333006d82736301218763007b7b517c6e5667766b517f786b517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68766c936c7c5493686751687652937a76aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e011f7f7d7e01007e8111414136d08c5ed2bf3ba048afe6dcaebafe01005f80837e01007e7652967b537a7601ff877c0100879b7d648b6752799368537a7d9776547aa06394677768263044022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802207c607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e7c6421038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b92186721023635954789a02e39fb7e54440b6f528d53efd65635ddad7f3c4085f97fdbdc4868ad547f7701207f01207f7701247f517f7801007e02fd00a063546752687f7801007e817f727e7b517f7c01147d887f517f7c01007e817601619f6976014ea063517c7b6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f007b7b687602540b7f7701147f7c5579876b826475020100686b587a5893766b7a765155a569005379736382013ca07c517f7c51877b9a6352795487637101007c7e717101207f01147f7577776775785387646c766b8b8b7951886868677568686c6c7c6b517f7c817f788273638c7f776775010068518463517f7c01147d887f547952876372777c717c767663517f756852875779766352790152879a689b63517f77567a7567527c7681014f0161a5587a9a63015094687e68746c766b5c9388748c76795879888c8c7978886777717c567a5679538764780152879a787663517f756852879b745394768b797663517f756852877c6c766b5c936ea0637c8c768b797663517f75685287726b9b7c6c686ea0637c5394768b797663517f75685287726b9b7c6c686ea063755494797663517f756852879b676d689b63006968677568687c717167567a7568788273638c7f776775010068528463517f7c01147d887f547953876372777c677768686d6c75787653877c52879b636c75006b687c518763755279685879a9886b6b6b6b6b6b6b827763af686c6c6c6c6c6c6c547a577a7664577a577a587a597a786354807e7e676d68aa8800677b7c7651876375577a7c587a67007c68765258a569765187645294597a53795b7a7e7e78637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e68687276647572677772755168537a76aa5a7a7d54807e597a5b7a5c7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa5a7a7d877663516752687c72879b69537a6491687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75517f7c01147d887f517f7c01007e817601619f6976014ea0637c6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f68557964577988756d67716881687863567a677b68587f7c8153796353795287637b6b537a6b717c6b6b537a6b676b577a6b597a6b587a6b577a6b7c68677b93687c547f7701207f75748c7a7669765880044676a914780114748c7a76727b748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685c795c79636c766b7363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e0a888201218763ac67517f07517f73637c7f6876767e767e7e02ae687e7e7c557a00740111a063005a79646b7c748c7a76697d937b7b58807e6c91677c748c7a7d58807e6c6c6c557a680114748c7a748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685479635f79676c766b0115797363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7c637e677c6b7c6b7c6b7e7c6b68685979636c6c766b786b7363517f7c51876301347f77547f547f75786352797b01007e81957c01007e81965379a169676d68677568685c797363517f7c51876301347f77547f547f75786354797b01007e81957c01007e819678a169676d68677568687568740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68597a636c6c6c6d6c6c6d6c9d687c587a9d7d7e5c79635d795880041976a9145e797e0288ac7e7e6700687d7e5c7a766302006a7c7e827602fc00a06301fd7c7e536751687f757c7e0058807c7e687d7eaa6b7e7e7e7e7e7eaa78877c6c877c6c9a9b726d726d77776a14b4ab0fffa02223a8a40d9e7f7823e61b3862538201031400112233445566778899aabbccddeeff00112233148899aabbccddeeff00112233445566778899aabb'
4
+ export const DSTAS_FROZEN_HEX = '142f2ec98dfa6429a028536a6c9451f702daa3a333526d82736301218763007b7b517c6e5667766b517f786b517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68766c936c7c5493686751687652937a76aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e011f7f7d7e01007e8111414136d08c5ed2bf3ba048afe6dcaebafe01005f80837e01007e7652967b537a7601ff877c0100879b7d648b6752799368537a7d9776547aa06394677768263044022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802207c607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e7c6421038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b92186721023635954789a02e39fb7e54440b6f528d53efd65635ddad7f3c4085f97fdbdc4868ad547f7701207f01207f7701247f517f7801007e02fd00a063546752687f7801007e817f727e7b517f7c01147d887f517f7c01007e817601619f6976014ea063517c7b6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f007b7b687602540b7f7701147f7c5579876b826475020100686b587a5893766b7a765155a569005379736382013ca07c517f7c51877b9a6352795487637101007c7e717101207f01147f7577776775785387646c766b8b8b7951886868677568686c6c7c6b517f7c817f788273638c7f776775010068518463517f7c01147d887f547952876372777c717c767663517f756852875779766352790152879a689b63517f77567a7567527c7681014f0161a5587a9a63015094687e68746c766b5c9388748c76795879888c8c7978886777717c567a5679538764780152879a787663517f756852879b745394768b797663517f756852877c6c766b5c936ea0637c8c768b797663517f75685287726b9b7c6c686ea0637c5394768b797663517f75685287726b9b7c6c686ea063755494797663517f756852879b676d689b63006968677568687c717167567a7568788273638c7f776775010068528463517f7c01147d887f547953876372777c677768686d6c75787653877c52879b636c75006b687c518763755279685879a9886b6b6b6b6b6b6b827763af686c6c6c6c6c6c6c547a577a7664577a577a587a597a786354807e7e676d68aa8800677b7c7651876375577a7c587a67007c68765258a569765187645294597a53795b7a7e7e78637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e68687276647572677772755168537a76aa5a7a7d54807e597a5b7a5c7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa5a7a7d877663516752687c72879b69537a6491687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75517f7c01147d887f517f7c01007e817601619f6976014ea0637c6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f68557964577988756d67716881687863567a677b68587f7c8153796353795287637b6b537a6b717c6b6b537a6b676b577a6b597a6b587a6b577a6b7c68677b93687c547f7701207f75748c7a7669765880044676a914780114748c7a76727b748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685c795c79636c766b7363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e0a888201218763ac67517f07517f73637c7f6876767e767e7e02ae687e7e7c557a00740111a063005a79646b7c748c7a76697d937b7b58807e6c91677c748c7a7d58807e6c6c6c557a680114748c7a748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685479635f79676c766b0115797363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7c637e677c6b7c6b7c6b7e7c6b68685979636c6c766b786b7363517f7c51876301347f77547f547f75786352797b01007e81957c01007e81965379a169676d68677568685c797363517f7c51876301347f77547f547f75786354797b01007e81957c01007e819678a169676d68677568687568740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68597a636c6c6c6d6c6c6d6c9d687c587a9d7d7e5c79635d795880041976a9145e797e0288ac7e7e6700687d7e5c7a766302006a7c7e827602fc00a06301fd7c7e536751687f757c7e0058807c7e687d7eaa6b7e7e7e7e7e7eaa78877c6c877c6c9a9b726d726d77776a14b4ab0fffa02223a8a40d9e7f7823e61b3862538201031400112233445566778899aabbccddeeff00112233148899aabbccddeeff00112233445566778899aabb'
5
+ export const DSTAS_OWNER = '2f2ec98dfa6429a028536a6c9451f702daa3a333'
6
+ export const DSTAS_TOKEN_ID = 'b4ab0fffa02223a8a40d9e7f7823e61b38625382'