diff --git a/packages/helpers/ts-templates/mod.ts b/packages/helpers/ts-templates/mod.ts index 58a489bb4..de220e10b 100644 --- a/packages/helpers/ts-templates/mod.ts +++ b/packages/helpers/ts-templates/mod.ts @@ -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' diff --git a/packages/helpers/ts-templates/src/Bsv21Token.ts b/packages/helpers/ts-templates/src/Bsv21Token.ts new file mode 100644 index 000000000..257b122dc --- /dev/null +++ b/packages/helpers/ts-templates/src/Bsv21Token.ts @@ -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 OP_ENDIF + * OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG + * + * JSON: {"p":"bsv-20","op":"transfer"|"deploy+mint","id":"","amt":"",...} + * + * 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 `_` 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 + } + } +} diff --git a/packages/helpers/ts-templates/src/DstasToken.ts b/packages/helpers/ts-templates/src/DstasToken.ts new file mode 100644 index 000000000..9fa4afebc --- /dev/null +++ b/packages/helpers/ts-templates/src/DstasToken.ts @@ -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: + * + * [ENGINE ~2.9KB] OP_RETURN + * + * + * 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 + } + } +} diff --git a/packages/helpers/ts-templates/src/StasToken.ts b/packages/helpers/ts-templates/src/StasToken.ts new file mode 100644 index 000000000..2788e0417 --- /dev/null +++ b/packages/helpers/ts-templates/src/StasToken.ts @@ -0,0 +1,136 @@ +import { LockingScript, Utils } from '@bsv/sdk' + +/** + * StasToken — decoder for **classic STAS** (legacy P2STAS / STAS 1.0) locking + * scripts. Unlike {@link MandalaToken}, classic STAS is satoshi-denominated: + * the token amount IS the output's satoshi value, so this template only + * recovers the on-chain *identity* fields (owner PKH + symbol). The amount is + * read from the containing output by the caller. + * + * Script shape produced by stas-js CreateContract: + * + * 76a914 88ac69 6a + * + * The owner hash160 sits at the well-known P2PKH-like prefix. The engine body + * is large and opaque (and may contain incidental `6a` bytes), so the OP_RETURN + * trailer is located by scanning from the end, matching the wallet's + * `parseClassicStasMetadata` source of truth. + * + * Building/unlocking classic STAS scripts is the stas-js engine's job; this + * template is decode-only. + */ + +export interface StasTokenDecoded { + /** Token identity used for indexing + conservation grouping (the symbol). */ + assetId: string + /** Token symbol parsed from the OP_RETURN trailer, or null if absent. */ + symbol: string | null + /** Owner public-key hash (20-byte hex) from the P2PKH-like prefix. */ + ownerHash160: string + /** Flags byte (hex) from the OP_RETURN trailer, or null if absent. */ + flagsHex: string | null +} + +const P2PKH_PREFIX = '76a914' +const STAS_MARKER = '88ac69' + +interface PushLength { len: number, dataStart: number } + +/** Resolves a push opcode's payload length + data offset, or null for a non-push opcode. */ +function pushDataLength (scriptHex: string, opcode: number, pos: number): PushLength | null { + if (opcode >= 0x01 && opcode <= 0x4b) return { len: opcode, dataStart: pos } + if (opcode === 0x4c) { + if (pos + 2 > scriptHex.length) return null + return { len: Number.parseInt(scriptHex.substring(pos, pos + 2), 16), dataStart: pos + 2 } + } + if (opcode === 0x4d) { + if (pos + 4 > scriptHex.length) return null + const b1 = scriptHex.substring(pos, pos + 2) + const b2 = scriptHex.substring(pos + 2, pos + 4) + return { len: Number.parseInt(b2 + b1, 16), dataStart: pos + 4 } + } + if (opcode === 0x4e) { + if (pos + 8 > scriptHex.length) return null + const b1 = scriptHex.substring(pos, pos + 2) + const b2 = scriptHex.substring(pos + 2, pos + 4) + const b3 = scriptHex.substring(pos + 4, pos + 6) + const b4 = scriptHex.substring(pos + 6, pos + 8) + return { len: Number.parseInt(b4 + b3 + b2 + b1, 16), dataStart: pos + 8 } + } + return null +} + +/** Reads push-data slots starting at a hex offset (after OP_RETURN). */ +function readPushes (scriptHex: string, startPos: number, max = 8): string[] { + const pushes: string[] = [] + let pos = startPos + while (pos < scriptHex.length && pushes.length < max) { + if (pos + 2 > scriptHex.length) break + const opcode = Number.parseInt(scriptHex.substring(pos, pos + 2), 16) + if (Number.isNaN(opcode)) break + pos += 2 + if (opcode === 0) { + pushes.push('') + continue + } + const push = pushDataLength(scriptHex, opcode, pos) + if (push === null) break // non-push opcode (or truncated length) after OP_RETURN — stop + pushes.push(scriptHex.substring(push.dataStart, push.dataStart + push.len * 2)) + pos = push.dataStart + push.len * 2 + } + return pushes +} + +function hexToUtf8 (hex: string): string { + if (hex === '') return '' + try { + return Utils.toUTF8(Utils.toArray(hex, 'hex')) + } catch { + return '' + } +} + +export class StasToken { + /** True if the script carries the classic STAS prefix + marker. */ + static isStas (script: LockingScript): boolean { + const hex = script.toHex() + return hex.startsWith(P2PKH_PREFIX) && hex.substring(46, 52) === STAS_MARKER + } + + /** + * Decodes a classic STAS locking script into its identity fields. + * @throws if the script is not a classic STAS script. + */ + static decode (script: LockingScript): StasTokenDecoded { + const hex = script.toHex() + if (hex.length < 56) throw new Error('not a STAS script: too short') + if (!hex.startsWith(P2PKH_PREFIX)) throw new Error('not a STAS script: missing P2PKH prefix') + if (hex.substring(46, 52) !== STAS_MARKER) throw new Error('not a STAS script: missing STAS marker') + + const ownerHash160 = hex.substring(6, 46) + + // OP_RETURN (0x6a) is placed by CreateContract as the last opcode before + // the data region. The engine body may contain incidental 0x6a bytes, so + // scan from the back. + const opReturnIdx = hex.lastIndexOf('6a') + let symbol: string | null = null + let flagsHex: string | null = null + if (opReturnIdx >= 0) { + const pushes = readPushes(hex, opReturnIdx + 2) + // Layout after OP_RETURN: [flagsByte, symbol, data, ...]. + flagsHex = pushes[0]?.length === 2 ? pushes[0] : null + const symbolHex = pushes[1] ?? null + symbol = (symbolHex != null && symbolHex !== '') + ? (hexToUtf8(symbolHex).replace(/[\x00- ]/g, '').trim() || null) + : null + } + + // assetId groups inputs/outputs of the same token for conservation. The + // symbol is the only identity carried in a classic STAS script; tokens + // with no symbol fall back to the owner-agnostic script tail hash so the + // grouping is still stable within a single transfer. + const assetId = symbol ?? `stas:${hex.substring(52, 68)}` + + return { assetId, symbol, ownerHash160, flagsHex } + } +} diff --git a/packages/helpers/ts-templates/src/__tests/Bsv21Token.test.ts b/packages/helpers/ts-templates/src/__tests/Bsv21Token.test.ts new file mode 100644 index 000000000..018c961ac --- /dev/null +++ b/packages/helpers/ts-templates/src/__tests/Bsv21Token.test.ts @@ -0,0 +1,56 @@ +import { Bsv21Token } from '../Bsv21Token' +import { LockingScript, Utils } from '@bsv/sdk' + +const OWNER = 'ab'.repeat(20) + +function utf8ToHex (s: string): string { + return Utils.toArray(s, 'utf8').map(b => b.toString(16).padStart(2, '0')).join('') +} +function push (bytesHex: string): string { + const len = bytesHex.length / 2 + if (len === 0) return '00' + if (len <= 0x4b) return len.toString(16).padStart(2, '0') + bytesHex + if (len <= 0xff) return '4c' + len.toString(16).padStart(2, '0') + bytesHex + const lo = len & 0xff; const hi = (len >> 8) & 0xff + return '4d' + lo.toString(16).padStart(2, '0') + hi.toString(16).padStart(2, '0') + bytesHex +} + +// Build a BSV-21 envelope with the given JSON payload + P2PKH owner tail. +function bsv21Script (payload: Record, owner = OWNER): LockingScript { + const json = utf8ToHex(JSON.stringify(payload)) + const envelope = + '00' + '63' + // OP_FALSE OP_IF + push(utf8ToHex('ord')) + + '51' + // OP_1 content-type tag + push(utf8ToHex('application/bsv-20')) + + '00' + // OP_0 separator + push(json) + + '68' // OP_ENDIF + const p2pkh = '76a914' + owner + '88ac' + return LockingScript.fromHex(envelope + p2pkh) +} + +describe('Bsv21Token.decode', () => { + it('decodes a transfer output', () => { + const id = `${'cd'.repeat(32)}_0` + const d = Bsv21Token.decode(bsv21Script({ p: 'bsv-20', op: 'transfer', id, amt: '500' })) + expect(d).toMatchObject({ id, amt: '500', isMint: false, ownerHash160: OWNER }) + }) + + it('decodes a deploy+mint output (no id in payload)', () => { + const d = Bsv21Token.decode(bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '21000000', dec: '8', sym: 'TIK' })) + expect(d).toMatchObject({ id: '', amt: '21000000', dec: 8, sym: 'TIK', isMint: true }) + }) + + it('isBsv21 is false for plain P2PKH', () => { + expect(Bsv21Token.isBsv21(LockingScript.fromHex(`76a914${OWNER}88ac`))).toBe(false) + }) + + it('throws on a non-bsv-20 inscription protocol', () => { + expect(() => Bsv21Token.decode(bsv21Script({ p: 'bsv-21', op: 'transfer', id: 'x', amt: '1' }))).toThrow(/bsv-20/) + }) + + it('throws on a missing/invalid amount', () => { + expect(() => Bsv21Token.decode(bsv21Script({ p: 'bsv-20', op: 'transfer', id: 'x' } as any))).toThrow(/amount/) + }) +}) diff --git a/packages/helpers/ts-templates/src/__tests/DstasToken.test.ts b/packages/helpers/ts-templates/src/__tests/DstasToken.test.ts new file mode 100644 index 000000000..80f28121c --- /dev/null +++ b/packages/helpers/ts-templates/src/__tests/DstasToken.test.ts @@ -0,0 +1,33 @@ +import { DstasToken } from '../DstasToken' +import { LockingScript } from '@bsv/sdk' +import { DSTAS_PLAIN_HEX, DSTAS_FROZEN_HEX, DSTAS_OWNER, DSTAS_TOKEN_ID } from './dstas-fixtures' + +describe('DstasToken.decode (against real dxs-bsv-token-sdk output)', () => { + it('recovers owner, tokenId, flags from a real DSTAS script', () => { + const d = DstasToken.decode(LockingScript.fromHex(DSTAS_PLAIN_HEX)) + expect(d.ownerHash160).toBe(DSTAS_OWNER) + expect(d.tokenId).toBe(DSTAS_TOKEN_ID) + expect(d.assetId).toBe(DSTAS_TOKEN_ID) + expect(d.flagsHex).toBe('03') + expect(d.freezeEnabled).toBe(true) + expect(d.confiscationEnabled).toBe(true) + expect(d.frozen).toBe(false) + }) + + it('detects the frozen marker (OP_2 action data)', () => { + const d = DstasToken.decode(LockingScript.fromHex(DSTAS_FROZEN_HEX)) + expect(d.frozen).toBe(true) + expect(d.tokenId).toBe(DSTAS_TOKEN_ID) + }) + + it('isDstas is true for DSTAS, false for plain P2PKH and classic STAS', () => { + expect(DstasToken.isDstas(LockingScript.fromHex(DSTAS_PLAIN_HEX))).toBe(true) + expect(DstasToken.isDstas(LockingScript.fromHex(`76a914${DSTAS_OWNER}88ac`))).toBe(false) + // classic STAS prefix is 76a914… not a 20-byte owner push, and short. + expect(DstasToken.isDstas(LockingScript.fromHex(`76a914${DSTAS_OWNER}88ac69` + 'ac'.repeat(8)))).toBe(false) + }) + + it('throws on a short / non-DSTAS script', () => { + expect(() => DstasToken.decode(LockingScript.fromHex(`14${DSTAS_OWNER}00`))).toThrow(/DSTAS/) + }) +}) diff --git a/packages/helpers/ts-templates/src/__tests/StasToken.test.ts b/packages/helpers/ts-templates/src/__tests/StasToken.test.ts new file mode 100644 index 000000000..8cba90ffc --- /dev/null +++ b/packages/helpers/ts-templates/src/__tests/StasToken.test.ts @@ -0,0 +1,44 @@ +import { StasToken } from '../StasToken' +import { LockingScript } from '@bsv/sdk' + +// Build a synthetic classic STAS script matching stas-js CreateContract shape: +// 76a914 88ac69 6a +const ownerHash160 = 'ab'.repeat(20) +const engine = 'ac'.repeat(8) // opaque filler, deliberately free of 0x6a bytes +const flagsPush = '0100' // push 1 byte: flags = 0x00 +const symbolPush = '04' + '54455354' // push 4 bytes: "TEST" +const stasHex = `76a914${ownerHash160}88ac69${engine}6a${flagsPush}${symbolPush}` + +describe('StasToken.decode', () => { + it('recovers owner, symbol, flags, and assetId from a classic STAS script', () => { + const decoded = StasToken.decode(LockingScript.fromHex(stasHex)) + expect(decoded.ownerHash160).toBe(ownerHash160) + expect(decoded.symbol).toBe('TEST') + expect(decoded.assetId).toBe('TEST') + expect(decoded.flagsHex).toBe('00') + }) + + it('isStas is true for a STAS script and false for plain P2PKH', () => { + const p2pkh = `76a914${ownerHash160}88ac` + expect(StasToken.isStas(LockingScript.fromHex(stasHex))).toBe(true) + expect(StasToken.isStas(LockingScript.fromHex(p2pkh))).toBe(false) + }) + + it('throws when the STAS marker is absent (long P2PKH-like script)', () => { + // ≥56 hex chars, starts with the P2PKH prefix but lacks the 88ac69 marker. + const notStas = `76a914${ownerHash160}88accccccccccc` + expect(() => StasToken.decode(LockingScript.fromHex(notStas))).toThrow(/STAS marker/) + }) + + it('throws when the P2PKH prefix is absent', () => { + expect(() => StasToken.decode(LockingScript.fromHex('6a0048656c6c6f'))).toThrow(/STAS/) + }) + + it('falls back to a script-derived assetId when no symbol is present', () => { + // OP_RETURN with only a flags push, no symbol slot. + const noSymbol = `76a914${ownerHash160}88ac69${engine}6a${flagsPush}` + const decoded = StasToken.decode(LockingScript.fromHex(noSymbol)) + expect(decoded.symbol).toBeNull() + expect(decoded.assetId).toMatch(/^stas:/) + }) +}) diff --git a/packages/helpers/ts-templates/src/__tests/dstas-fixtures.ts b/packages/helpers/ts-templates/src/__tests/dstas-fixtures.ts new file mode 100644 index 000000000..82b805ffb --- /dev/null +++ b/packages/helpers/ts-templates/src/__tests/dstas-fixtures.ts @@ -0,0 +1,6 @@ +// Real DSTAS locking scripts generated by dxs-bsv-token-sdk (buildDstasLockingScript). +// owner=2f2ec98d… redemption(tokenId)=b4ab0fff… flags=0x03 (freeze+confiscation). +export const DSTAS_PLAIN_HEX = '142f2ec98dfa6429a028536a6c9451f702daa3a333006d82736301218763007b7b517c6e5667766b517f786b517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68766c936c7c5493686751687652937a76aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e011f7f7d7e01007e8111414136d08c5ed2bf3ba048afe6dcaebafe01005f80837e01007e7652967b537a7601ff877c0100879b7d648b6752799368537a7d9776547aa06394677768263044022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802207c607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e7c6421038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b92186721023635954789a02e39fb7e54440b6f528d53efd65635ddad7f3c4085f97fdbdc4868ad547f7701207f01207f7701247f517f7801007e02fd00a063546752687f7801007e817f727e7b517f7c01147d887f517f7c01007e817601619f6976014ea063517c7b6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f007b7b687602540b7f7701147f7c5579876b826475020100686b587a5893766b7a765155a569005379736382013ca07c517f7c51877b9a6352795487637101007c7e717101207f01147f7577776775785387646c766b8b8b7951886868677568686c6c7c6b517f7c817f788273638c7f776775010068518463517f7c01147d887f547952876372777c717c767663517f756852875779766352790152879a689b63517f77567a7567527c7681014f0161a5587a9a63015094687e68746c766b5c9388748c76795879888c8c7978886777717c567a5679538764780152879a787663517f756852879b745394768b797663517f756852877c6c766b5c936ea0637c8c768b797663517f75685287726b9b7c6c686ea0637c5394768b797663517f75685287726b9b7c6c686ea063755494797663517f756852879b676d689b63006968677568687c717167567a7568788273638c7f776775010068528463517f7c01147d887f547953876372777c677768686d6c75787653877c52879b636c75006b687c518763755279685879a9886b6b6b6b6b6b6b827763af686c6c6c6c6c6c6c547a577a7664577a577a587a597a786354807e7e676d68aa8800677b7c7651876375577a7c587a67007c68765258a569765187645294597a53795b7a7e7e78637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e68687276647572677772755168537a76aa5a7a7d54807e597a5b7a5c7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa5a7a7d877663516752687c72879b69537a6491687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75517f7c01147d887f517f7c01007e817601619f6976014ea0637c6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f68557964577988756d67716881687863567a677b68587f7c8153796353795287637b6b537a6b717c6b6b537a6b676b577a6b597a6b587a6b577a6b7c68677b93687c547f7701207f75748c7a7669765880044676a914780114748c7a76727b748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685c795c79636c766b7363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e0a888201218763ac67517f07517f73637c7f6876767e767e7e02ae687e7e7c557a00740111a063005a79646b7c748c7a76697d937b7b58807e6c91677c748c7a7d58807e6c6c6c557a680114748c7a748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685479635f79676c766b0115797363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7c637e677c6b7c6b7c6b7e7c6b68685979636c6c766b786b7363517f7c51876301347f77547f547f75786352797b01007e81957c01007e81965379a169676d68677568685c797363517f7c51876301347f77547f547f75786354797b01007e81957c01007e819678a169676d68677568687568740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68597a636c6c6c6d6c6c6d6c9d687c587a9d7d7e5c79635d795880041976a9145e797e0288ac7e7e6700687d7e5c7a766302006a7c7e827602fc00a06301fd7c7e536751687f757c7e0058807c7e687d7eaa6b7e7e7e7e7e7eaa78877c6c877c6c9a9b726d726d77776a14b4ab0fffa02223a8a40d9e7f7823e61b3862538201031400112233445566778899aabbccddeeff00112233148899aabbccddeeff00112233445566778899aabb' +export const DSTAS_FROZEN_HEX = '142f2ec98dfa6429a028536a6c9451f702daa3a333526d82736301218763007b7b517c6e5667766b517f786b517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68766c936c7c5493686751687652937a76aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e011f7f7d7e01007e8111414136d08c5ed2bf3ba048afe6dcaebafe01005f80837e01007e7652967b537a7601ff877c0100879b7d648b6752799368537a7d9776547aa06394677768263044022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802207c607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e7c6421038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b92186721023635954789a02e39fb7e54440b6f528d53efd65635ddad7f3c4085f97fdbdc4868ad547f7701207f01207f7701247f517f7801007e02fd00a063546752687f7801007e817f727e7b517f7c01147d887f517f7c01007e817601619f6976014ea063517c7b6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f007b7b687602540b7f7701147f7c5579876b826475020100686b587a5893766b7a765155a569005379736382013ca07c517f7c51877b9a6352795487637101007c7e717101207f01147f7577776775785387646c766b8b8b7951886868677568686c6c7c6b517f7c817f788273638c7f776775010068518463517f7c01147d887f547952876372777c717c767663517f756852875779766352790152879a689b63517f77567a7567527c7681014f0161a5587a9a63015094687e68746c766b5c9388748c76795879888c8c7978886777717c567a5679538764780152879a787663517f756852879b745394768b797663517f756852877c6c766b5c936ea0637c8c768b797663517f75685287726b9b7c6c686ea0637c5394768b797663517f75685287726b9b7c6c686ea063755494797663517f756852879b676d689b63006968677568687c717167567a7568788273638c7f776775010068528463517f7c01147d887f547953876372777c677768686d6c75787653877c52879b636c75006b687c518763755279685879a9886b6b6b6b6b6b6b827763af686c6c6c6c6c6c6c547a577a7664577a577a587a597a786354807e7e676d68aa8800677b7c7651876375577a7c587a67007c68765258a569765187645294597a53795b7a7e7e78637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e68687276647572677772755168537a76aa5a7a7d54807e597a5b7a5c7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa5a7a7d877663516752687c72879b69537a6491687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75517f7c01147d887f517f7c01007e817601619f6976014ea0637c6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f68557964577988756d67716881687863567a677b68587f7c8153796353795287637b6b537a6b717c6b6b537a6b676b577a6b597a6b587a6b577a6b7c68677b93687c547f7701207f75748c7a7669765880044676a914780114748c7a76727b748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685c795c79636c766b7363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e0a888201218763ac67517f07517f73637c7f6876767e767e7e02ae687e7e7c557a00740111a063005a79646b7c748c7a76697d937b7b58807e6c91677c748c7a7d58807e6c6c6c557a680114748c7a748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685479635f79676c766b0115797363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7c637e677c6b7c6b7c6b7e7c6b68685979636c6c766b786b7363517f7c51876301347f77547f547f75786352797b01007e81957c01007e81965379a169676d68677568685c797363517f7c51876301347f77547f547f75786354797b01007e81957c01007e819678a169676d68677568687568740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68597a636c6c6c6d6c6c6d6c9d687c587a9d7d7e5c79635d795880041976a9145e797e0288ac7e7e6700687d7e5c7a766302006a7c7e827602fc00a06301fd7c7e536751687f757c7e0058807c7e687d7eaa6b7e7e7e7e7e7eaa78877c6c877c6c9a9b726d726d77776a14b4ab0fffa02223a8a40d9e7f7823e61b3862538201031400112233445566778899aabbccddeeff00112233148899aabbccddeeff00112233445566778899aabb' +export const DSTAS_OWNER = '2f2ec98dfa6429a028536a6c9451f702daa3a333' +export const DSTAS_TOKEN_ID = 'b4ab0fffa02223a8a40d9e7f7823e61b38625382' diff --git a/packages/messaging/message-box-client/mod.ts b/packages/messaging/message-box-client/mod.ts index 5df29cab5..6dc399e3a 100644 --- a/packages/messaging/message-box-client/mod.ts +++ b/packages/messaging/message-box-client/mod.ts @@ -1,4 +1,6 @@ export * from './src/MessageBoxClient.js' export * from './src/PeerPayClient.js' +export * from './src/PeerTokenClient.js' +export * from './src/TokenSettlementAdapter.js' export * from './src/types.js' export * from './src/RemittanceAdapter.js' diff --git a/packages/messaging/message-box-client/package.json b/packages/messaging/message-box-client/package.json index 5f73a4d78..4dfbd714e 100644 --- a/packages/messaging/message-box-client/package.json +++ b/packages/messaging/message-box-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/message-box-client", - "version": "2.2.0", + "version": "2.2.1", "publishConfig": { "access": "public" }, diff --git a/packages/messaging/message-box-client/src/PeerTokenClient.ts b/packages/messaging/message-box-client/src/PeerTokenClient.ts new file mode 100644 index 000000000..2a66260f7 --- /dev/null +++ b/packages/messaging/message-box-client/src/PeerTokenClient.ts @@ -0,0 +1,392 @@ +/** + * PeerTokenClient + * + * Extends `MessageBoxClient` to move BSV **tokens** (classic STAS, DSTAS, + * BSV-21) peer-to-peer over MessageBox — the token analog of PeerPayClient. + * + * The client is standard-agnostic: it delegates the actual transfer building + * and acceptance to a per-standard {@link TokenSettlementAdapter}, selected by + * the `protocol` field. Message transport, request/response flows, and HMAC + * proofs reuse the same machinery PeerPayClient uses for satoshi payments. + */ +import { MessageBoxClient } from './MessageBoxClient.js' +import { + PeerMessage, TokenToken, IncomingToken, TokenRequestMessage, TokenRequestResponse, + IncomingTokenRequest +} from './types.js' +import { + TokenSettlementAdapter, TokenSourceRef, TokenAdapterContext +} from './TokenSettlementAdapter.js' +import { WalletInterface, OriginatorDomainNameStringUnder250Bytes, createNonce } from '@bsv/sdk' + +import * as Logger from './Utils/logger.js' + +function hexToBytes (hex: string): number[] { + const matches = hex.match(/.{1,2}/g) + return (matches ?? []).map(byte => Number.parseInt(byte, 16)) +} + +function safeParse (input: any): T | undefined { + try { + return typeof input === 'string' ? JSON.parse(input) : input + } catch (parseError) { + Logger.error('[PT CLIENT] Failed to parse input in safeParse:', input, parseError) + return undefined + } +} + +export const STANDARD_TOKEN_MESSAGEBOX = 'token_inbox' +export const TOKEN_REQUESTS_MESSAGEBOX = 'token_requests' +export const TOKEN_REQUEST_RESPONSES_MESSAGEBOX = 'token_request_responses' + +export interface PeerTokenClientConfig { + messageBoxHost?: string + messageBox?: string + walletClient: WalletInterface + /** One adapter per token standard, keyed by its `protocol` discriminator. */ + adapters: TokenSettlementAdapter[] + enableLogging?: boolean + originator?: OriginatorDomainNameStringUnder250Bytes +} + +/** Parameters to send a token transfer. */ +export interface SendTokenParams { + recipient: string + /** Standard discriminator (e.g. 'stas', 'dstas', 'bsv-21'); selects the adapter. */ + protocol: string + /** The token UTXO the sender controls and wishes to transfer. */ + source: TokenSourceRef + /** Token units to send, as a string. */ + amount: string +} + +export class PeerTokenClient extends MessageBoxClient { + private readonly peerTokenWalletClient: WalletInterface + private readonly messageBox: string + private readonly adapters: Map + /** + * The configured MessageBox host, threaded explicitly through every token + * transport call. On mainnet the `ls_messagebox` overlay (SLAP) has no + * advertised hosts, so overlay-resolving calls fail; passing the host + * directly (and using listMessagesLite for reads) bypasses that lookup. + */ + private readonly tokenHost?: string + + constructor (config: PeerTokenClientConfig) { + const { messageBoxHost = 'https://message-box-us-1.bsvb.tech', walletClient, enableLogging = false, originator } = config + super({ host: messageBoxHost, walletClient, enableLogging, originator }) + + this.messageBox = config.messageBox ?? STANDARD_TOKEN_MESSAGEBOX + this.tokenHost = messageBoxHost + this.peerTokenWalletClient = walletClient + this.originator = originator + this.adapters = new Map(config.adapters.map(a => [a.protocol, a])) + } + + private adapterFor (protocol: string): TokenSettlementAdapter { + const adapter = this.adapters.get(protocol) + if (adapter == null) { + throw new Error(`No token settlement adapter registered for protocol '${protocol}'`) + } + return adapter + } + + private adapterContext (dryRun = false): TokenAdapterContext { + return { wallet: this.peerTokenWalletClient, originator: this.originator, logger: Logger, dryRun } + } + + /** + * Builds a transferable token artifact for a recipient by delegating to the + * adapter for the requested protocol. With `dryRun`, the adapter derives and + * validates only — no signing, no broadcast (mainnet rehearsal). + */ + async createTokenToken (params: SendTokenParams, dryRun = false): Promise { + const adapter = this.adapterFor(params.protocol) + const result = await adapter.buildTokenSettlement( + { recipient: params.recipient, source: params.source, amount: params.amount }, + this.adapterContext(dryRun) + ) + if (result.action === 'terminate') { + throw new Error(result.termination.message) + } + const { artifact } = result + return { + protocol: artifact.protocol, + assetId: artifact.assetId, + amount: artifact.amount, + customInstructions: artifact.customInstructions, + transaction: artifact.transaction, + outputIndex: artifact.outputIndex, + txid: artifact.txid + } + } + + /** Sends a token to a recipient over HTTP. Returns the sent token (incl. txid). */ + async sendToken (params: SendTokenParams, hostOverride?: string): Promise { + if (params.recipient == null || params.recipient.trim() === '') { + throw new Error('Invalid token transfer: recipient is required') + } + const token = await this.createTokenToken(params) + await this.sendMessage({ + recipient: params.recipient, + messageBox: this.messageBox, + body: JSON.stringify(token) + }, hostOverride ?? this.tokenHost) + return token + } + + /** Sends a token over WebSocket, falling back to HTTP if the socket fails. Returns the sent token. */ + async sendLiveToken (params: SendTokenParams, overrideHost?: string): Promise { + const token = await this.createTokenToken(params) + const host = overrideHost ?? this.tokenHost + try { + await this.sendLiveMessage({ + recipient: params.recipient, + messageBox: this.messageBox, + body: JSON.stringify(token) + }, host) + } catch (err) { + Logger.warn('[PT CLIENT] sendLiveMessage failed, falling back to HTTP:', err) + await this.sendMessage({ + recipient: params.recipient, + messageBox: this.messageBox, + body: JSON.stringify(token) + }, host) + } + return token + } + + /** Listens for incoming tokens in real time over WebSocket. */ + async listenForLiveTokens ({ + onToken, + overrideHost + }: { + onToken: (token: IncomingToken) => void + overrideHost?: string + }): Promise { + await this.listenForLiveMessages({ + messageBox: this.messageBox, + overrideHost: overrideHost ?? this.tokenHost, + onMessage: (message: PeerMessage) => { + const token = safeParse(message.body) + if (token == null) return + onToken({ messageId: message.messageId, sender: message.sender, token }) + } + }) + } + + /** + * Accepts an incoming token by delegating to the adapter for its protocol, + * then acknowledges the transport message. + */ + async acceptToken (incoming: IncomingToken): Promise { + try { + const adapter = this.adapterFor(incoming.token.protocol) + const result = await adapter.acceptTokenSettlement( + { + sender: incoming.sender, + settlement: { + customInstructions: incoming.token.customInstructions, + transaction: incoming.token.transaction, + protocol: incoming.token.protocol, + assetId: incoming.token.assetId, + amount: incoming.token.amount, + outputIndex: incoming.token.outputIndex ?? 0 + } + }, + this.adapterContext() + ) + if (result.action === 'terminate') { + throw new Error(result.termination.message) + } + await this.acknowledgeMessage({ messageIds: [incoming.messageId], host: this.tokenHost }) + return { incoming, receiptData: result.receiptData } + } catch (error) { + Logger.error(`[PT CLIENT] Error accepting token: ${String(error)}`) + return 'Unable to receive token!' + } + } + + /** Lists pending incoming tokens from the token message box. */ + async listIncomingTokens (overrideHost?: string): Promise { + // listMessagesLite talks to the host directly and skips overlay (SLAP) + // resolution, which has no advertised ls_messagebox hosts on mainnet. + const messages = await this.listMessagesLite({ messageBox: this.messageBox, host: overrideHost ?? this.tokenHost }) + return messages.map((msg: any) => { + const token = safeParse(msg.body) + if (token == null) return null + return { messageId: msg.messageId, sender: msg.sender, token } + }).filter((t): t is IncomingToken => t != null) + } + + // ── Token request flow (mirrors PeerPayClient's payment requests) ────────── + + /** + * Requests a token transfer from a payer. Generates a unique requestId and an + * HMAC proof tying the request to the sender, then posts it to the requests box. + */ + async requestToken ( + params: { recipient: string, protocol: string, assetId: string, amount: string, description: string, expiresAt: number }, + hostOverride?: string + ): Promise<{ requestId: string, requestProof: string }> { + const requestId = await createNonce(this.peerTokenWalletClient, 'self', this.originator) + const senderIdentityKey = await this.getIdentityKey() + + const proofData = Array.from(new TextEncoder().encode(requestId + params.recipient)) + const { hmac } = await this.peerTokenWalletClient.createHmac({ + data: proofData, + protocolID: [2, 'token request auth'], + keyID: requestId, + counterparty: params.recipient + }, this.originator) + const requestProof = Array.from(hmac).map((b: number) => b.toString(16).padStart(2, '0')).join('') + + const body: TokenRequestMessage = { + requestId, + protocol: params.protocol, + assetId: params.assetId, + amount: params.amount, + description: params.description, + expiresAt: params.expiresAt, + senderIdentityKey, + requestProof + } + + await this.sendMessage({ + recipient: params.recipient, + messageBox: TOKEN_REQUESTS_MESSAGEBOX, + body: JSON.stringify(body) + }, hostOverride ?? this.tokenHost) + + return { requestId, requestProof } + } + + /** Listens for incoming token requests in real time over WebSocket. */ + async listenForLiveTokenRequests ({ + onRequest, + overrideHost + }: { + onRequest: (request: IncomingTokenRequest) => void + overrideHost?: string + }): Promise { + await this.listenForLiveMessages({ + messageBox: TOKEN_REQUESTS_MESSAGEBOX, + overrideHost, + onMessage: (message: PeerMessage) => { + const body = safeParse(message.body) + if (body == null || body.cancelled === true) return + onRequest({ + messageId: message.messageId, + sender: message.sender, + requestId: body.requestId, + protocol: body.protocol, + assetId: body.assetId, + amount: body.amount, + description: body.description, + expiresAt: body.expiresAt + }) + } + }) + } + + /** + * Fulfills an incoming token request by sending the requested token and + * notifying the requester with a 'sent' response. Acknowledges the request. + */ + async fulfillTokenRequest ( + params: { request: IncomingTokenRequest, source: TokenSourceRef, note?: string }, + hostOverride?: string + ): Promise { + const { request, source, note } = params + + await this.sendToken({ + recipient: request.sender, + protocol: request.protocol, + source, + amount: request.amount + }, hostOverride ?? this.tokenHost) + + const response: TokenRequestResponse = { + requestId: request.requestId, + status: 'sent', + protocol: request.protocol, + assetId: request.assetId, + amountSent: request.amount, + ...(note != null && { note }) + } + + await this.sendMessage({ + recipient: request.sender, + messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, + body: JSON.stringify(response) + }, hostOverride ?? this.tokenHost) + + await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride ?? this.tokenHost }) + } + + /** Declines an incoming token request and acknowledges it. */ + async declineTokenRequest ( + params: { request: IncomingTokenRequest, note?: string }, + hostOverride?: string + ): Promise { + const { request, note } = params + const response: TokenRequestResponse = { + requestId: request.requestId, + status: 'declined', + ...(note != null && { note }) + } + await this.sendMessage({ + recipient: request.sender, + messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, + body: JSON.stringify(response) + }, hostOverride ?? this.tokenHost) + await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride ?? this.tokenHost }) + } + + /** Cancels a previously sent token request. */ + async cancelTokenRequest ( + params: { recipient: string, requestId: string, requestProof: string }, + hostOverride?: string + ): Promise { + const senderIdentityKey = await this.getIdentityKey() + const body: TokenRequestMessage = { + requestId: params.requestId, + senderIdentityKey, + requestProof: params.requestProof, + cancelled: true + } + await this.sendMessage({ + recipient: params.recipient, + messageBox: TOKEN_REQUESTS_MESSAGEBOX, + body: JSON.stringify(body) + }, hostOverride ?? this.tokenHost) + } + + /** Lists responses to token requests this client has sent. */ + async listTokenRequestResponses (hostOverride?: string): Promise { + const messages = await this.listMessagesLite({ messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, host: hostOverride ?? this.tokenHost }) + return messages.map((msg: any) => safeParse(msg.body)) + .filter((r): r is TokenRequestResponse => r != null) + } + + /** + * Verifies the HMAC proof on an incoming token request, confirming it came + * from the claimed sender. Mirrors PeerPayClient's request-proof check. + */ + async verifyTokenRequestProof (request: { requestId: string, sender: string, requestProof: string }): Promise { + const myIdentityKey = await this.getIdentityKey() + try { + const proofData = Array.from(new TextEncoder().encode(request.requestId + myIdentityKey)) + await this.peerTokenWalletClient.verifyHmac({ + data: proofData, + hmac: hexToBytes(request.requestProof), + protocolID: [2, 'token request auth'], + keyID: request.requestId, + counterparty: request.sender + }, this.originator) + return true + } catch { + return false + } + } +} diff --git a/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts b/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts new file mode 100644 index 000000000..85644db3e --- /dev/null +++ b/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts @@ -0,0 +1,83 @@ +/** + * TokenSettlementAdapter — the pluggable seam that lets PeerTokenClient move + * tokens over MessageBox without knowing any token standard's wire format. + * + * It mirrors the shape of `Brc29RemittanceModule` (buildSettlement / + * acceptSettlement) that PeerPayClient uses for satoshi payments, but the + * artifact additionally carries the token `protocol`, `assetId`, and `amount` + * so the receiving side can route to the right adapter. One adapter exists per + * standard (classic STAS, DSTAS, BSV-21). + */ +import { AtomicBEEF, Base64String, PubKeyHex, WalletInterface } from '@bsv/sdk' + +/** A token UTXO the sender controls and wishes to transfer. */ +export interface TokenSourceRef { + txid: string + outputIndex: number + lockingScriptHex: string + satoshis: number + protocol: string + assetId: string + /** BRC-42 receive-key id under which the sender holds this UTXO. */ + brc42KeyId?: string + /** Standard-specific extras (e.g. BSV-21 tokenId/dec/sym) tolerated by adapters. */ + [key: string]: unknown +} + +/** The transferable result produced by an adapter; travels in the message body. */ +export interface TokenSettlementArtifact { + customInstructions: { derivationPrefix: Base64String, derivationSuffix: Base64String } + transaction: AtomicBEEF + protocol: string + assetId: string + /** Token units as a string (bigint-safe); for classic STAS this equals satoshis. */ + amount: string + outputIndex: number + /** Broadcast txid of the transfer (set by adapters after a live send). */ + txid?: string +} + +export interface AdapterLogger { + log: (...args: any[]) => void + warn: (...args: any[]) => void + error: (...args: any[]) => void +} + +export interface TokenAdapterContext { + wallet: WalletInterface + originator?: string + logger?: AdapterLogger + /** + * When true, the adapter derives + validates the settlement only and does NOT + * touch the chain (no signing, no broadcast) — for rehearsal / mainnet safety. + */ + dryRun?: boolean +} + +export interface BuildTokenSettlementArgs { + recipient: PubKeyHex + source: TokenSourceRef + amount: string +} + +export interface AcceptTokenSettlementArgs { + sender: PubKeyHex + settlement: TokenSettlementArtifact +} + +export interface Termination { code: string, message: string } + +export type TokenBuildResult = + | { action: 'settle', artifact: TokenSettlementArtifact } + | { action: 'terminate', termination: Termination } + +export type TokenAcceptResult = + | { action: 'accept', receiptData?: { internalizeResult?: unknown } } + | { action: 'terminate', termination: Termination } + +export interface TokenSettlementAdapter { + /** Discriminator matched against TokenSourceRef.protocol / TokenToken.protocol. */ + readonly protocol: string + buildTokenSettlement: (args: BuildTokenSettlementArgs, ctx: TokenAdapterContext) => Promise + acceptTokenSettlement: (args: AcceptTokenSettlementArgs, ctx: TokenAdapterContext) => Promise +} diff --git a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts new file mode 100644 index 000000000..f930d0ebf --- /dev/null +++ b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts @@ -0,0 +1,141 @@ +/* eslint-env jest */ +import { PeerTokenClient, STANDARD_TOKEN_MESSAGEBOX } from '../PeerTokenClient.js' +import { TokenSettlementAdapter } from '../TokenSettlementAdapter.js' +import { PrivateKey, type WalletInterface } from '@bsv/sdk' +import { jest } from '@jest/globals' + +const createMockWalletClient = (): jest.Mocked => ({ + getPublicKey: jest.fn(), + createAction: jest.fn(), + internalizeAction: jest.fn(), + createHmac: jest.fn<() => Promise<{ hmac: number[] }>>().mockResolvedValue({ hmac: [1, 2, 3] }), + verifyHmac: jest.fn<() => Promise<{ valid: true }>>().mockResolvedValue({ valid: true as const }) +} as unknown as jest.Mocked) + +const ARTIFACT = { + customInstructions: { derivationPrefix: 'cHJl', derivationSuffix: 'c3Vm' }, + transaction: [1, 2, 3], + protocol: 'stas', + assetId: 'TEST', + amount: '1000', + outputIndex: 0 +} + +const makeStubAdapter = (): TokenSettlementAdapter => ({ + protocol: 'stas', + buildTokenSettlement: jest.fn() + .mockResolvedValue({ action: 'settle', artifact: ARTIFACT }), + acceptTokenSettlement: jest.fn() + .mockResolvedValue({ action: 'accept', receiptData: { internalizeResult: 'ok' } }) +}) + +const SOURCE = { + txid: 'aa'.repeat(32), + outputIndex: 0, + lockingScriptHex: '76a914' + 'ab'.repeat(20) + '88ac69ac', + satoshis: 1000, + protocol: 'stas', + assetId: 'TEST', + brc42KeyId: 'recv 1' +} + +describe('PeerTokenClient Unit Tests', () => { + let client: PeerTokenClient + let adapter: TokenSettlementAdapter + let recipient: string + + beforeEach(() => { + jest.clearAllMocks() + adapter = makeStubAdapter() + recipient = PrivateKey.fromRandom().toPublicKey().toString() + client = new PeerTokenClient({ walletClient: createMockWalletClient(), adapters: [adapter] }) + }) + + describe('createTokenToken', () => { + it('delegates to the adapter and returns the artifact fields', async () => { + const token = await client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + expect(token).toMatchObject({ protocol: 'stas', assetId: 'TEST', amount: '1000', outputIndex: 0 }) + expect(token.transaction).toEqual([1, 2, 3]) + expect(adapter.buildTokenSettlement).toHaveBeenCalledTimes(1) + }) + + it('throws when no adapter is registered for the protocol', async () => { + await expect( + client.createTokenToken({ recipient, protocol: 'dstas', source: SOURCE, amount: '1' }) + ).rejects.toThrow(/No token settlement adapter/) + }) + + it('throws when the adapter terminates', async () => { + ;(adapter.buildTokenSettlement as jest.Mock).mockResolvedValue({ + action: 'terminate', termination: { code: 'stas.frozen', message: 'frozen UTXO' } + } as never) + await expect( + client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + ).rejects.toThrow(/frozen UTXO/) + }) + + it('surfaces the broadcast txid from the artifact', async () => { + ;(adapter.buildTokenSettlement as jest.Mock).mockResolvedValue({ + action: 'settle', artifact: { ...ARTIFACT, txid: 'cd'.repeat(32) } + } as never) + const token = await client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + expect(token.txid).toBe('cd'.repeat(32)) + }) + + it('passes dryRun through to the adapter context', async () => { + await client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }, true) + const ctx = (adapter.buildTokenSettlement as jest.Mock).mock.calls[0][1] as { dryRun?: boolean } + expect(ctx.dryRun).toBe(true) + }) + }) + + describe('sendToken', () => { + it('sends the serialized token to the token message box', async () => { + const sendSpy = jest.spyOn(client, 'sendMessage' as any).mockResolvedValue(undefined as never) + await client.sendToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + expect(sendSpy).toHaveBeenCalledTimes(1) + const arg = (sendSpy.mock.calls[0] as any)[0] + expect(arg.messageBox).toBe(STANDARD_TOKEN_MESSAGEBOX) + expect(arg.recipient).toBe(recipient) + expect(JSON.parse(arg.body)).toMatchObject({ protocol: 'stas', assetId: 'TEST', amount: '1000' }) + }) + + it('returns the sent token', async () => { + jest.spyOn(client, 'sendMessage' as any).mockResolvedValue(undefined as never) + const token = await client.sendToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + expect(token).toMatchObject({ protocol: 'stas', assetId: 'TEST', amount: '1000' }) + }) + + it('throws on a missing recipient', async () => { + await expect( + client.sendToken({ recipient: ' ', protocol: 'stas', source: SOURCE, amount: '1000' }) + ).rejects.toThrow(/recipient is required/) + }) + }) + + describe('acceptToken', () => { + it('delegates to the adapter and acknowledges the message', async () => { + const ackSpy = jest.spyOn(client, 'acknowledgeMessage' as any).mockResolvedValue(undefined as never) + const incoming = { + messageId: 'msg-1', + sender: PrivateKey.fromRandom().toPublicKey().toString(), + token: { ...ARTIFACT } + } + const result = await client.acceptToken(incoming) + expect(adapter.acceptTokenSettlement).toHaveBeenCalledTimes(1) + expect(ackSpy).toHaveBeenCalledWith(expect.objectContaining({ messageIds: ['msg-1'] })) + expect(result).toMatchObject({ receiptData: { internalizeResult: 'ok' } }) + }) + }) + + describe('listIncomingTokens', () => { + it('reads via listMessagesLite (mainnet bypass) with the configured host', async () => { + const liteSpy = jest.spyOn(client, 'listMessagesLite' as any).mockResolvedValue([] as never) + await client.listIncomingTokens() + expect(liteSpy).toHaveBeenCalledWith(expect.objectContaining({ + messageBox: STANDARD_TOKEN_MESSAGEBOX, + host: 'https://message-box-us-1.bsvb.tech' + })) + }) + }) +}) diff --git a/packages/messaging/message-box-client/src/types.ts b/packages/messaging/message-box-client/src/types.ts index 7db463f6e..361897b1b 100644 --- a/packages/messaging/message-box-client/src/types.ts +++ b/packages/messaging/message-box-client/src/types.ts @@ -267,6 +267,70 @@ export interface IncomingPaymentRequest { expiresAt: number } +/** + * A token transfer carried in a token message box (the token analog of + * PaymentToken). The derivation values let the recipient's wallet re-derive + * the owner key and take custody; protocol/assetId/amount route it to the + * right TokenSettlementAdapter on the receiving side. + */ +export interface TokenToken { + protocol: string + assetId: string + /** Token units as a string (bigint-safe). */ + amount: string + customInstructions: { derivationPrefix: Base64String, derivationSuffix: Base64String } + transaction: AtomicBEEF + outputIndex?: number + /** Broadcast txid of the transfer, surfaced by the adapter (explorer links). */ + txid?: string +} + +/** An incoming token received via MessageBox (token analog of IncomingPayment). */ +export interface IncomingToken { + messageId: string + sender: string + token: TokenToken +} + +/** A new token-transfer request (token analog of PaymentRequestNew). */ +export interface TokenRequestNew extends PaymentRequestBase { + protocol: string + assetId: string + /** Token units requested, as a string. */ + amount: string + description: string + expiresAt: number + requestProof: string + cancelled?: false +} + +export interface TokenRequestCancellation extends PaymentRequestBase { + cancelled: true + requestProof: string +} + +export type TokenRequestMessage = TokenRequestNew | TokenRequestCancellation + +export interface TokenRequestResponse { + requestId: string + status: 'sent' | 'declined' + protocol?: string + assetId?: string + amountSent?: string + note?: string +} + +export interface IncomingTokenRequest { + messageId: string + sender: string + requestId: string + protocol: string + assetId: string + amount: string + description: string + expiresAt: number +} + /** Default minimum satoshis for payment request filtering. */ export const DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT = 1000 /** Default maximum satoshis for payment request filtering. */ diff --git a/packages/overlays/topics/package.json b/packages/overlays/topics/package.json index cc5cec233..d2d4e96a1 100644 --- a/packages/overlays/topics/package.json +++ b/packages/overlays/topics/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-topics", - "version": "1.4.0", + "version": "1.4.1", "description": "Canonical BSV overlay topic managers and lookup services", "type": "module", "main": "./dist/index.js", diff --git a/packages/overlays/topics/src/admission/ADMISSION.md b/packages/overlays/topics/src/admission/ADMISSION.md new file mode 100644 index 000000000..da5cc9d65 --- /dev/null +++ b/packages/overlays/topics/src/admission/ADMISSION.md @@ -0,0 +1,71 @@ +# Token overlay admission — trust model + +The token topic managers (`tm_stas`, `tm_bsv21`, `tm_dstas`) decide which outputs +to index. This note states precisely what they verify, what they delegate to +Bitcoin Script, and what is left to the overlay operator. It applies to all +three; DSTAS is the one that matters most, because it has no third-party indexer +and so the overlay is its only discovery surface. + +## What Bitcoin Script already guarantees (not re-checked) + +The overlay only ever sees **SPV-valid** transactions — every admitted output +comes from a transaction with a merkle proof, i.e. one that miners accepted +under consensus. The STAS/DSTAS covenant and the BSV-21 rules are enforced *in +Script* at spend time (STAS protocol study §6: "transfer correctness is verified +by miners running the standard BSV consensus rules, not by an off-chain +indexer"). So the overlay does **not** re-verify, because it can never observe a +violation: + +- **Owner authorisation** — a transfer is signed by the owner key (or MPKH). +- **Transfer conservation** — `tokens in == tokens out` per asset. +- **Freeze rule** — a frozen UTXO cannot be spent under a normal owner transfer + (§3, §6). An illegitimate frozen-spend never confirms, so it never reaches the + overlay. +- **Confiscation / redemption rules** — likewise enforced by the covenant. + +## What the overlay verifies structurally + +- **Template validity** — each output must decode against the token template; + non-token outputs are ignored. +- **Anti-inflation** — a transaction whose outputs exceed its inputs for a + tokenId *that has inputs* is rejected in full. This is conservation applied to + what the overlay can see; consensus enforces the authoritative version. + +## What Script does NOT constrain — and the overlay's controls + +**Issuance is permissionless.** Minting is just creating an output that claims a +`tokenId` / `protoID`; Script does not bind that claim to any identity. Token +*authority* is established **off-chain**, via the issuer's published +`TokenScheme` (§4: "indexers reconstruct a token's identity by combining the +on-chain locking script with the off-chain TokenScheme"). An overlay cannot +derive authority from the chain alone. + +The control is `TokenIssuerPolicy` (`./issuerPolicy.ts`), passed to a topic +manager's constructor. Its `allowIssuance(tokenId)` hook is consulted **only for +issuance outputs** — a tokenId appearing in a transaction's outputs with no input +of the same tokenId (a mint, not a transfer). Transfers are never gated; they are +governed by conservation, which Script already enforces. + +```ts +import { DstasTopicManager, allowlistIssuerPolicy } from '@bsv/overlay-topics' + +// Permissionless (default): index every issuance. +new DstasTopicManager() + +// Restricted: index only issuances of known protoIDs. +new DstasTopicManager(allowlistIssuerPolicy([protoIdA, protoIdB])) + +// Custom: any predicate (e.g. look up a registry / TokenScheme cache). +new DstasTopicManager({ allowIssuance: (tokenId) => registry.isKnown(tokenId) }) +``` + +For BSV-21 a mint's tokenId is its own outpoint (`_`), so the value +passed to `allowIssuance` is that outpoint, not a stable protoID. + +## Compliance state is indexed, not enforced + +Frozen DSTAS UTXOs are real on-chain state and remain **discoverable** — the +lookup service stores the `frozen` flag and `ls_dstas` accepts a `frozen` filter +(`{ ownerHash160, frozen: false }` for spendable holdings only, `true` for frozen +ones). The overlay surfaces freeze state for consumers; it does not re-enforce +the freeze rule, because Script already does (above). diff --git a/packages/overlays/topics/src/admission/issuerPolicy.ts b/packages/overlays/topics/src/admission/issuerPolicy.ts new file mode 100644 index 000000000..d97dd6141 --- /dev/null +++ b/packages/overlays/topics/src/admission/issuerPolicy.ts @@ -0,0 +1,39 @@ +/** + * Issuer-authority policy shared by the token topic managers (STAS, BSV-21, + * DSTAS). + * + * Minting a token is permissionless in Bitcoin Script: anyone can create an + * output that claims any tokenId / protoID, and the script engine will admit + * it. Token *authority* — "is this protoID really issued by who it claims?" — + * is therefore established off-chain, via the issuer's published TokenScheme + * (see the STAS protocol study §4: "indexers reconstruct a token's identity by + * combining the on-chain locking script with the off-chain TokenScheme"). + * + * An overlay cannot derive that authority from the chain alone. What it CAN do + * is let its operator decide which issuances to index. `allowIssuance` is the + * hook for that decision: it is consulted only for *issuance* outputs — a + * tokenId that appears in a transaction's outputs with no admitted input of the + * same tokenId (a mint, not a transfer). Transfers are always governed by value + * conservation, which the script engine already enforces on-chain. + * + * Omitting the policy (or `allowIssuance`) keeps the permissionless default: + * every issuance is admitted. Supplying an allowlist-backed predicate restricts + * the index to known issuers without changing transfer behaviour. + */ +export interface TokenIssuerPolicy { + /** + * Decide whether an issuance of `tokenId` may be admitted (indexed). + * Return true to admit, false to reject. Consulted once per issuance tokenId. + * Omitted ⇒ permissionless (admit all issuances). + */ + allowIssuance?: (tokenId: string) => boolean +} + +/** + * Build an `allowIssuance` predicate from a fixed allowlist of tokenIds + * (protoIDs). A convenience for the common "only these known issuers" case. + */ +export function allowlistIssuerPolicy (tokenIds: Iterable): TokenIssuerPolicy { + const allowed = new Set(tokenIds) + return { allowIssuance: (tokenId: string) => allowed.has(tokenId) } +} diff --git a/packages/overlays/topics/src/bsv21/Bsv21LookupDocs.md.ts b/packages/overlays/topics/src/bsv21/Bsv21LookupDocs.md.ts new file mode 100644 index 000000000..ff2747e7b --- /dev/null +++ b/packages/overlays/topics/src/bsv21/Bsv21LookupDocs.md.ts @@ -0,0 +1,6 @@ +export default `# BSV-21 Token Lookup Service (ls_bsv21) + +Indexes admitted BSV-21 token UTXOs by tokenId, owner hash160, and outpoint. +Supports lookup by tokenId, by owner, or by outpoint. Retains the raw bigint +amount (as a string) for explorer use. No identity linkage. +` diff --git a/packages/overlays/topics/src/bsv21/Bsv21LookupService.ts b/packages/overlays/topics/src/bsv21/Bsv21LookupService.ts new file mode 100644 index 000000000..798b02eb9 --- /dev/null +++ b/packages/overlays/topics/src/bsv21/Bsv21LookupService.ts @@ -0,0 +1,79 @@ +import { + LookupService, LookupQuestion, LookupFormula, + AdmissionMode, SpendNotificationMode, OutputAdmittedByTopic, OutputSpent +} from '@bsv/overlay' +import { Db } from 'mongodb' +import { Bsv21Token } from '@bsv/templates' +import { Bsv21StorageManager } from './Bsv21StorageManager.js' +import { Bsv21Query } from './types.js' +import { lookupByOwnerOrOutpoint } from '../shared/tokenLookupTail.js' +import docs from './Bsv21LookupDocs.md.js' + +export interface Bsv21LookupDeps { + storage: Bsv21StorageManager +} + +export class Bsv21LookupService implements LookupService { + readonly admissionMode: AdmissionMode = 'locking-script' + readonly spendNotificationMode: SpendNotificationMode = 'script' + + constructor (private readonly deps: Bsv21LookupDeps) {} + + async outputAdmittedByTopic (payload: OutputAdmittedByTopic): Promise { + if (payload.mode !== 'locking-script') return + if (payload.topic !== 'tm_bsv21') return + let decoded + try { + decoded = Bsv21Token.decode(payload.lockingScript) + } catch { + return // not a BSV-21 output + } + // A mint output's tokenId is its own outpoint; a transfer names it in the JSON. + const tokenId = decoded.isMint || decoded.id === '' + ? `${payload.txid}_${payload.outputIndex}` + : decoded.id + await this.deps.storage.storeToken({ + txid: payload.txid, + outputIndex: payload.outputIndex, + tokenId, + amount: decoded.amt, + sym: decoded.sym, + ownerHash160: decoded.ownerHash160, + createdAt: new Date() + }) + } + + async outputSpent (payload: OutputSpent): Promise { + if (payload.topic !== 'tm_bsv21') return + await this.deps.storage.deleteToken(payload.txid, payload.outputIndex) + } + + async outputEvicted (txid: string, outputIndex: number): Promise { + await this.deps.storage.deleteToken(txid, outputIndex) + } + + async lookup (question: LookupQuestion): Promise { + const query = ((question as any).query ?? {}) as Bsv21Query + if (typeof query.tokenId === 'string') { + return await this.deps.storage.findByTokenId(query.tokenId) + } + return await lookupByOwnerOrOutpoint(this.deps.storage, query) + } + + async getDocumentation (): Promise { + return docs + } + + async getMetaData (): Promise<{ name: string, shortDescription: string }> { + return { + name: 'ls_bsv21', + shortDescription: 'BSV-21 token index by tokenId/owner/outpoint.' + } + } +} + +export function createBsv21LookupService () { + return (db: Db): Bsv21LookupService => new Bsv21LookupService({ + storage: new Bsv21StorageManager(db) + }) +} diff --git a/packages/overlays/topics/src/bsv21/Bsv21StorageManager.ts b/packages/overlays/topics/src/bsv21/Bsv21StorageManager.ts new file mode 100644 index 000000000..77e1a30bd --- /dev/null +++ b/packages/overlays/topics/src/bsv21/Bsv21StorageManager.ts @@ -0,0 +1,55 @@ +import { Collection, Db } from 'mongodb' +import { Bsv21TokenRecord, UTXOReference } from './types.js' + +/** + * Mongo-backed index of admitted BSV-21 token UTXOs, by outpoint, tokenId, and + * owner. The raw bigint amount is retained (as a string) for explorer use. + */ +export class Bsv21StorageManager { + private readonly tokens: Collection + private indexInit?: Promise + + constructor (private readonly db: Db) { + this.tokens = db.collection('bsv21Tokens') + } + + private async ensureIndexes (): Promise { + this.indexInit ??= (async () => { + await Promise.all([ + this.tokens.createIndex({ txid: 1, outputIndex: 1 }, { unique: true }), + this.tokens.createIndex({ tokenId: 1 }), + this.tokens.createIndex({ ownerHash160: 1 }) + ]) + })() + return await this.indexInit + } + + /** Project a UTXO-reference cursor for a mongo filter (DRY for the finders). */ + private async query (filter: Record): Promise { + await this.ensureIndexes() + return await this.tokens.find(filter) + .project({ txid: 1, outputIndex: 1, _id: 0 }).toArray() + } + + async storeToken (record: Bsv21TokenRecord): Promise { + await this.ensureIndexes() + await this.tokens.insertOne(record) + } + + async deleteToken (txid: string, outputIndex: number): Promise { + await this.ensureIndexes() + await this.tokens.deleteOne({ txid, outputIndex }) + } + + async findByTokenId (tokenId: string): Promise { + return await this.query({ tokenId }) + } + + async findByOwner (ownerHash160: string): Promise { + return await this.query({ ownerHash160 }) + } + + async findByOutpoint (txid: string, outputIndex: number): Promise { + return await this.query({ txid, outputIndex }) + } +} diff --git a/packages/overlays/topics/src/bsv21/Bsv21TopicDocs.md.ts b/packages/overlays/topics/src/bsv21/Bsv21TopicDocs.md.ts new file mode 100644 index 000000000..af17cbb7c --- /dev/null +++ b/packages/overlays/topics/src/bsv21/Bsv21TopicDocs.md.ts @@ -0,0 +1,14 @@ +export default `# BSV-21 Token Topic Manager (tm_bsv21) + +Admits BSV-21 (1Sat ordinals-style) fungible-token outputs by structural +validation: each output is decoded against the ord-inscription envelope +(\`OP_FALSE OP_IF "ord" OP_1 "application/bsv-20" OP_0 OP_ENDIF\` + +P2PKH owner) and the transaction is admitted when per-tokenId value +conservation holds on the divisible bigint amounts. + +A \`deploy+mint\` output (whose tokenId is its own outpoint, with no prior +input of that tokenId) is treated as issuance and admitted. A transaction that +would inflate a token (outputs exceed inputs for a tokenId that has inputs) is +rejected in full. Ownership is plain P2PKH — there is no key-linkage or +sanctions layer. +` diff --git a/packages/overlays/topics/src/bsv21/Bsv21TopicManager.ts b/packages/overlays/topics/src/bsv21/Bsv21TopicManager.ts new file mode 100644 index 000000000..14b6be920 --- /dev/null +++ b/packages/overlays/topics/src/bsv21/Bsv21TopicManager.ts @@ -0,0 +1,71 @@ +import { Transaction } from '@bsv/sdk' +import { Bsv21Token } from '@bsv/templates' +import { BaseTokenTopicManager, DecodedTokenOutput } from '../shared/BaseTokenTopicManager.js' +import docs from './Bsv21TopicDocs.md.js' + +/** + * Topic manager for BSV-21 (1Sat ordinals-style) fungible tokens. + * + * Admissibility is structural: each output is decoded against the BSV-21 + * ord-envelope shape, and the transaction is admitted only when per-tokenId + * value conservation holds on the divisible bigint amounts. A transaction that + * would inflate a token is rejected in full. + * + * tokenId resolution: a transfer output names its id in the JSON; a mint + * output's id IS its own outpoint (`_`). A mint is therefore an + * issuance (its tokenId never appears as an input); the optional + * TokenIssuerPolicy gates which mints are indexed — note the gated value is + * the mint's outpoint-based tokenId. Omitted, all mints are admitted. + */ +export class Bsv21TopicManager extends BaseTokenTopicManager { + protected readonly zero = 0n + protected readonly logLabel = 'Bsv21TopicManager' + + private outputTokenId (decoded: { id: string, isMint: boolean }, txid: string, index: number): string { + return decoded.isMint || decoded.id === '' ? `${txid}_${index}` : decoded.id + } + + protected decodeOutputs (tx: Transaction): Array> { + const txid = tx.id('hex') + const outputs: Array> = [] + for (let i = 0; i < tx.outputs.length; i++) { + try { + const d = Bsv21Token.decode(tx.outputs[i].lockingScript) + outputs.push({ index: i, key: this.outputTokenId(d, txid, i), amount: BigInt(d.amt) }) + } catch { + // not a BSV-21 output — ignore + } + } + return outputs + } + + protected inputTotals (tx: Transaction, previousCoins: number[]): Map { + const inTotals = new Map() + for (const ci of previousCoins) { + const input = tx.inputs[ci] + const src = input?.sourceTransaction?.outputs[input.sourceOutputIndex] + if (src == null) continue + try { + const d = Bsv21Token.decode(src.lockingScript) + const srcTxid = input.sourceTXID ?? input.sourceTransaction?.id('hex') ?? '' + const tokenId = this.outputTokenId(d, srcTxid, input.sourceOutputIndex) + inTotals.set(tokenId, (inTotals.get(tokenId) ?? 0n) + BigInt(d.amt)) + } catch { /* non-BSV-21 previous coin */ } + } + return inTotals + } + + protected add (a: bigint, b: bigint): bigint { return a + b } + protected gt (a: bigint, b: bigint): boolean { return a > b } + + async getDocumentation (): Promise { + return docs + } + + async getMetaData (): Promise<{ name: string, shortDescription: string }> { + return { + name: 'tm_bsv21', + shortDescription: 'BSV-21 (1Sat) fungible-token transfers admitted by inscription validity and value conservation.' + } + } +} diff --git a/packages/overlays/topics/src/bsv21/__tests/Bsv21TopicManager.test.ts b/packages/overlays/topics/src/bsv21/__tests/Bsv21TopicManager.test.ts new file mode 100644 index 000000000..9bc307f15 --- /dev/null +++ b/packages/overlays/topics/src/bsv21/__tests/Bsv21TopicManager.test.ts @@ -0,0 +1,85 @@ +import { Bsv21TopicManager } from '../Bsv21TopicManager' +import { LockingScript, Transaction, UnlockingScript, Utils } from '@bsv/sdk' +import { allowlistIssuerPolicy } from '../../admission/issuerPolicy' + +const OWNER_A = 'ab'.repeat(20) +const OWNER_B = 'cd'.repeat(20) + +function utf8ToHex (s: string): string { + return Utils.toArray(s, 'utf8').map(b => b.toString(16).padStart(2, '0')).join('') +} +function push (bytesHex: string): string { + const len = bytesHex.length / 2 + if (len <= 0x4b) return len.toString(16).padStart(2, '0') + bytesHex + return '4c' + len.toString(16).padStart(2, '0') + bytesHex +} +function bsv21Script (payload: Record, owner = OWNER_A): LockingScript { + const envelope = + '0063' + push(utf8ToHex('ord')) + '51' + push(utf8ToHex('application/bsv-20')) + + '00' + push(utf8ToHex(JSON.stringify(payload))) + '68' + return LockingScript.fromHex(envelope + '76a914' + owner + '88ac') +} + +describe('Bsv21TopicManager', () => { + const manager = new Bsv21TopicManager() + + it('admits a deploy+mint output (issuance)', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '1000', dec: '0' }), satoshis: 1 }) + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) + + it('admits a conserving transfer (out amt == in amt for the tokenId)', async () => { + const mint = new Transaction() + mint.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '1000' }), satoshis: 1 }) + const tokenId = `${mint.id('hex')}_0` + + const tx = new Transaction() + tx.addInput({ sourceTransaction: mint, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'transfer', id: tokenId, amt: '600' }, OWNER_B), satoshis: 1 }) + tx.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'transfer', id: tokenId, amt: '400' }, OWNER_A), satoshis: 1 }) + + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([0, 1]) + }) + + it('rejects inflation (out amt > in amt for a tokenId with inputs)', async () => { + const mint = new Transaction() + mint.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '1000' }), satoshis: 1 }) + const tokenId = `${mint.id('hex')}_0` + + const tx = new Transaction() + tx.addInput({ sourceTransaction: mint, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'transfer', id: tokenId, amt: '1500' }, OWNER_B), satoshis: 1 }) + + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + it('ignores non-BSV-21 outputs', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: LockingScript.fromHex(`76a914${OWNER_A}88ac`), satoshis: 1 }) + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + describe('issuer policy', () => { + it('rejects a mint whose tokenId is not in the allowlist', async () => { + const gated = new Bsv21TopicManager(allowlistIssuerPolicy([])) + const tx = new Transaction() + tx.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '1000', dec: '0' }), satoshis: 1 }) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + it('admits a mint whose tokenId (outpoint) is in the allowlist', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: bsv21Script({ p: 'bsv-20', op: 'deploy+mint', amt: '1000', dec: '0' }), satoshis: 1 }) + const tokenId = `${tx.id('hex')}_0` + const gated = new Bsv21TopicManager(allowlistIssuerPolicy([tokenId])) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) + }) +}) diff --git a/packages/overlays/topics/src/bsv21/types.ts b/packages/overlays/topics/src/bsv21/types.ts new file mode 100644 index 000000000..2eb84a9c8 --- /dev/null +++ b/packages/overlays/topics/src/bsv21/types.ts @@ -0,0 +1,30 @@ +import { PubKeyHex } from '@bsv/sdk' + +export type { Bsv21TokenDecoded } from '@bsv/templates' + +/** + * An indexed BSV-21 token UTXO. `amount` is the raw bigint token amount stored + * as a string (BSV-21 is divisible, unlike satoshi-denominated classic STAS). + */ +export interface Bsv21TokenRecord { + txid: string + outputIndex: number + /** Token id `_` of the deploy+mint. */ + tokenId: string + amount: string + sym?: string + ownerHash160: string + createdAt: Date +} + +export interface UTXOReference { + txid: string + outputIndex: number +} + +export interface Bsv21Query { + tokenId?: string + txid?: string + outputIndex?: number + ownerHash160?: PubKeyHex +} diff --git a/packages/overlays/topics/src/dstas/DstasLookupDocs.md.ts b/packages/overlays/topics/src/dstas/DstasLookupDocs.md.ts new file mode 100644 index 000000000..278bf9823 --- /dev/null +++ b/packages/overlays/topics/src/dstas/DstasLookupDocs.md.ts @@ -0,0 +1,7 @@ +export default `# DSTAS Token Lookup Service (ls_dstas) + +Indexes admitted DSTAS token UTXOs by tokenId (redemption pkh), owner hash160, +and outpoint, and records the frozen marker. Supports lookup by tokenId, by +owner, or by outpoint. This is the primary public index for DSTAS, which has no +third-party indexer. +` diff --git a/packages/overlays/topics/src/dstas/DstasLookupService.ts b/packages/overlays/topics/src/dstas/DstasLookupService.ts new file mode 100644 index 000000000..939258fe0 --- /dev/null +++ b/packages/overlays/topics/src/dstas/DstasLookupService.ts @@ -0,0 +1,80 @@ +import { + LookupService, LookupQuestion, LookupFormula, + AdmissionMode, SpendNotificationMode, OutputAdmittedByTopic, OutputSpent +} from '@bsv/overlay' +import { Db } from 'mongodb' +import { DstasToken } from '@bsv/templates' +import { DstasStorageManager } from './DstasStorageManager.js' +import { DstasQuery } from './types.js' +import docs from './DstasLookupDocs.md.js' + +export interface DstasLookupDeps { + storage: DstasStorageManager +} + +export class DstasLookupService implements LookupService { + readonly admissionMode: AdmissionMode = 'locking-script' + readonly spendNotificationMode: SpendNotificationMode = 'script' + + constructor (private readonly deps: DstasLookupDeps) {} + + async outputAdmittedByTopic (payload: OutputAdmittedByTopic): Promise { + if (payload.mode !== 'locking-script') return + if (payload.topic !== 'tm_dstas') return + let decoded + try { + decoded = DstasToken.decode(payload.lockingScript) + } catch { + return // not a DSTAS output + } + await this.deps.storage.storeToken({ + txid: payload.txid, + outputIndex: payload.outputIndex, + tokenId: decoded.tokenId, + ownerHash160: decoded.ownerHash160, + frozen: decoded.frozen, + createdAt: new Date() + }) + } + + async outputSpent (payload: OutputSpent): Promise { + if (payload.topic !== 'tm_dstas') return + await this.deps.storage.deleteToken(payload.txid, payload.outputIndex) + } + + async outputEvicted (txid: string, outputIndex: number): Promise { + await this.deps.storage.deleteToken(txid, outputIndex) + } + + async lookup (question: LookupQuestion): Promise { + const query = ((question as any).query ?? {}) as DstasQuery + const frozen = typeof query.frozen === 'boolean' ? query.frozen : undefined + if (typeof query.tokenId === 'string') { + return await this.deps.storage.findByTokenId(query.tokenId, frozen) + } + if (typeof query.ownerHash160 === 'string') { + return await this.deps.storage.findByOwner(query.ownerHash160, frozen) + } + if (typeof query.txid === 'string' && typeof query.outputIndex === 'number') { + return await this.deps.storage.findByOutpoint(query.txid, query.outputIndex) + } + throw new Error('Unsupported query') + } + + async getDocumentation (): Promise { + return docs + } + + async getMetaData (): Promise<{ name: string, shortDescription: string }> { + return { + name: 'ls_dstas', + shortDescription: 'DSTAS token index by tokenId/owner/outpoint.' + } + } +} + +export function createDstasLookupService () { + return (db: Db): DstasLookupService => new DstasLookupService({ + storage: new DstasStorageManager(db) + }) +} diff --git a/packages/overlays/topics/src/dstas/DstasStorageManager.ts b/packages/overlays/topics/src/dstas/DstasStorageManager.ts new file mode 100644 index 000000000..be2739814 --- /dev/null +++ b/packages/overlays/topics/src/dstas/DstasStorageManager.ts @@ -0,0 +1,57 @@ +import { Collection, Db } from 'mongodb' +import { DstasTokenRecord, UTXOReference } from './types.js' + +/** + * Mongo-backed index of admitted DSTAS token UTXOs, by outpoint, tokenId, and + * owner. This is the only public index for DSTAS — classic STAS has Bitails and + * BSV-21 has the 1Sat overlay, but DSTAS has no third-party indexer, so this + * overlay is the discovery surface. + */ +export class DstasStorageManager { + private readonly tokens: Collection + private indexInit?: Promise + + constructor (private readonly db: Db) { + this.tokens = db.collection('dstasTokens') + } + + private async ensureIndexes (): Promise { + this.indexInit ??= (async () => { + await Promise.all([ + this.tokens.createIndex({ txid: 1, outputIndex: 1 }, { unique: true }), + this.tokens.createIndex({ tokenId: 1 }), + this.tokens.createIndex({ ownerHash160: 1 }) + ]) + })() + return await this.indexInit + } + + /** Project a UTXO-reference cursor for a mongo filter (DRY for the finders). */ + private async query (filter: Record): Promise { + await this.ensureIndexes() + return await this.tokens.find(filter) + .project({ txid: 1, outputIndex: 1, _id: 0 }).toArray() + } + + async storeToken (record: DstasTokenRecord): Promise { + await this.ensureIndexes() + await this.tokens.insertOne(record) + } + + async deleteToken (txid: string, outputIndex: number): Promise { + await this.ensureIndexes() + await this.tokens.deleteOne({ txid, outputIndex }) + } + + async findByTokenId (tokenId: string, frozen?: boolean): Promise { + return await this.query({ tokenId, ...(frozen === undefined ? {} : { frozen }) }) + } + + async findByOwner (ownerHash160: string, frozen?: boolean): Promise { + return await this.query({ ownerHash160, ...(frozen === undefined ? {} : { frozen }) }) + } + + async findByOutpoint (txid: string, outputIndex: number): Promise { + return await this.query({ txid, outputIndex }) + } +} diff --git a/packages/overlays/topics/src/dstas/DstasTopicDocs.md.ts b/packages/overlays/topics/src/dstas/DstasTopicDocs.md.ts new file mode 100644 index 000000000..18fdcaecc --- /dev/null +++ b/packages/overlays/topics/src/dstas/DstasTopicDocs.md.ts @@ -0,0 +1,15 @@ +export default `# DSTAS Token Topic Manager (tm_dstas) + +Admits DSTAS (Divisible STAS / STAS 3.0) token transfer outputs by structural +validation. Each output is decoded against the dxs-bsv-token-sdk DSTAS template +(\` [engine] OP_RETURN …\`) +and the transaction is admitted when per-tokenId value conservation holds — +DSTAS is satoshi-denominated, so the token amount IS the output's satoshi value. + +DSTAS has no public third-party indexer (unlike classic STAS via Bitails or +BSV-21 via the 1Sat overlay), so this overlay is the discovery surface for +DSTAS holdings. A transaction with no DSTAS inputs for a tokenId is treated as +issuance and admitted; a transaction that would inflate a token is rejected in +full. Freeze/confiscation authority is not verified at admission in this +version (the frozen marker is surfaced for indexing only). +` diff --git a/packages/overlays/topics/src/dstas/DstasTopicManager.ts b/packages/overlays/topics/src/dstas/DstasTopicManager.ts new file mode 100644 index 000000000..718d70e43 --- /dev/null +++ b/packages/overlays/topics/src/dstas/DstasTopicManager.ts @@ -0,0 +1,69 @@ +import { Transaction } from '@bsv/sdk' +import { DstasToken } from '@bsv/templates' +import { BaseTokenTopicManager, DecodedTokenOutput } from '../shared/BaseTokenTopicManager.js' +import docs from './DstasTopicDocs.md.js' + +/** + * Topic manager for DSTAS (Divisible STAS / STAS 3.0) token transfers. + * + * DSTAS has no public third-party indexer, so this overlay is its discovery + * surface. Admissibility is structural: each output is decoded against the + * DSTAS template, and the transaction is admitted only when per-tokenId value + * conservation holds. DSTAS is satoshi-denominated, so the token amount is the + * output's satoshi value. + * + * Trust model. Transfer correctness (owner signature, conservation, and the + * freeze rule that a frozen input cannot be spent under a normal transfer) is + * enforced by Bitcoin Script and verified by miners — the overlay only ever + * sees SPV-valid transactions, so it does not re-enforce it (protocol study §6). + * Frozen UTXOs are real on-chain state and stay indexed (discoverable); the + * lookup service surfaces the frozen flag. The one thing Script does NOT + * constrain is issuance: minting is permissionless, so any output can claim any + * protoID. The optional TokenIssuerPolicy gates which issuances are indexed; + * omitted, the overlay stays permissionless (admits all issuances). + */ +export class DstasTopicManager extends BaseTokenTopicManager { + protected readonly zero = 0 + protected readonly logLabel = 'DstasTopicManager' + + protected decodeOutputs (tx: Transaction): Array> { + const outputs: Array> = [] + for (let i = 0; i < tx.outputs.length; i++) { + try { + const { tokenId } = DstasToken.decode(tx.outputs[i].lockingScript) + outputs.push({ index: i, key: tokenId, amount: tx.outputs[i].satoshis ?? 0 }) + } catch { + // not a DSTAS output — ignore + } + } + return outputs + } + + protected inputTotals (tx: Transaction, previousCoins: number[]): Map { + const inTotals = new Map() + for (const ci of previousCoins) { + const input = tx.inputs[ci] + const src = input?.sourceTransaction?.outputs[input.sourceOutputIndex] + if (src == null) continue + try { + const { tokenId } = DstasToken.decode(src.lockingScript) + inTotals.set(tokenId, (inTotals.get(tokenId) ?? 0) + (src.satoshis ?? 0)) + } catch { /* non-DSTAS previous coin */ } + } + return inTotals + } + + protected add (a: number, b: number): number { return a + b } + protected gt (a: number, b: number): boolean { return a > b } + + async getDocumentation (): Promise { + return docs + } + + async getMetaData (): Promise<{ name: string, shortDescription: string }> { + return { + name: 'tm_dstas', + shortDescription: 'DSTAS (Divisible STAS / STAS 3.0) token transfers admitted by script validity and value conservation.' + } + } +} diff --git a/packages/overlays/topics/src/dstas/__tests/DstasLookupService.test.ts b/packages/overlays/topics/src/dstas/__tests/DstasLookupService.test.ts new file mode 100644 index 000000000..608d15f6a --- /dev/null +++ b/packages/overlays/topics/src/dstas/__tests/DstasLookupService.test.ts @@ -0,0 +1,39 @@ +import { DstasLookupService } from '../DstasLookupService' +import type { DstasStorageManager } from '../DstasStorageManager' +import type { UTXOReference } from '../types' + +// A stub storage that records the arguments each finder was called with, so we +// can assert the lookup service threads the optional `frozen` filter through. +function stubStorage () { + const calls: Array<{ method: string, args: unknown[] }> = [] + const ref: UTXOReference[] = [{ txid: 'aa'.repeat(32), outputIndex: 0 }] + const storage = { + async findByTokenId (tokenId: string, frozen?: boolean) { calls.push({ method: 'findByTokenId', args: [tokenId, frozen] }); return ref }, + async findByOwner (ownerHash160: string, frozen?: boolean) { calls.push({ method: 'findByOwner', args: [ownerHash160, frozen] }); return ref }, + async findByOutpoint (txid: string, outputIndex: number) { calls.push({ method: 'findByOutpoint', args: [txid, outputIndex] }); return ref } + } as unknown as DstasStorageManager + return { storage, calls } +} + +describe('DstasLookupService frozen filter', () => { + it('passes frozen=false through an owner query', async () => { + const { storage, calls } = stubStorage() + const svc = new DstasLookupService({ storage }) + await svc.lookup({ service: 'ls_dstas', query: { ownerHash160: 'ab'.repeat(20), frozen: false } } as any) + expect(calls).toEqual([{ method: 'findByOwner', args: ['ab'.repeat(20), false] }]) + }) + + it('passes frozen=true through a tokenId query', async () => { + const { storage, calls } = stubStorage() + const svc = new DstasLookupService({ storage }) + await svc.lookup({ service: 'ls_dstas', query: { tokenId: 'cd'.repeat(20), frozen: true } } as any) + expect(calls).toEqual([{ method: 'findByTokenId', args: ['cd'.repeat(20), true] }]) + }) + + it('omits the filter (undefined) when frozen is not supplied', async () => { + const { storage, calls } = stubStorage() + const svc = new DstasLookupService({ storage }) + await svc.lookup({ service: 'ls_dstas', query: { ownerHash160: 'ab'.repeat(20) } } as any) + expect(calls).toEqual([{ method: 'findByOwner', args: ['ab'.repeat(20), undefined] }]) + }) +}) diff --git a/packages/overlays/topics/src/dstas/__tests/DstasTopicManager.test.ts b/packages/overlays/topics/src/dstas/__tests/DstasTopicManager.test.ts new file mode 100644 index 000000000..7fe8c20a0 --- /dev/null +++ b/packages/overlays/topics/src/dstas/__tests/DstasTopicManager.test.ts @@ -0,0 +1,82 @@ +import { DstasTopicManager } from '../DstasTopicManager' +import { LockingScript, Transaction, UnlockingScript } from '@bsv/sdk' +import { DstasToken } from '@bsv/templates' +import { allowlistIssuerPolicy } from '../../admission/issuerPolicy' +import { DSTAS_PLAIN_HEX } from './dstas-fixture' + +// A real DSTAS script (from dxs-bsv-token-sdk). The tokenId is the redemption +// pkh baked into the script, so two outputs of the same script share a tokenId. +const dstasScript = (): LockingScript => LockingScript.fromHex(DSTAS_PLAIN_HEX) +const FIXTURE_TOKEN_ID = DstasToken.decode(dstasScript()).tokenId + +describe('DstasTopicManager', () => { + const manager = new DstasTopicManager() + + it('admits an issuance output (no DSTAS inputs)', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) + + it('admits a conserving transfer (out sats == in sats for the tokenId)', async () => { + const sourceTx = new Transaction() + sourceTx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + + const tx = new Transaction() + tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([0]) + expect(admitted.coinsToRetain).toEqual([0]) + }) + + it('rejects inflation (out sats > in sats for a tokenId with inputs)', async () => { + const sourceTx = new Transaction() + sourceTx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + + const tx = new Transaction() + tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: dstasScript(), satoshis: 2000 }) + + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + it('ignores non-DSTAS outputs', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: LockingScript.fromHex('76a914' + 'ab'.repeat(20) + '88ac'), satoshis: 1000 }) + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + describe('issuer policy', () => { + it('rejects an issuance whose tokenId is not in the allowlist', async () => { + const gated = new DstasTopicManager(allowlistIssuerPolicy([])) // empty allowlist + const tx = new Transaction() + tx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + it('admits an issuance whose tokenId is in the allowlist', async () => { + const gated = new DstasTopicManager(allowlistIssuerPolicy([FIXTURE_TOKEN_ID])) + const tx = new Transaction() + tx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) + + it('does not gate transfers — a conserving spend is admitted even under an empty allowlist', async () => { + const gated = new DstasTopicManager(allowlistIssuerPolicy([])) // would reject issuance + const sourceTx = new Transaction() + sourceTx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + const tx = new Transaction() + tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: dstasScript(), satoshis: 1000 }) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([0]) // a transfer, not an issuance — not gated + }) + }) +}) diff --git a/packages/overlays/topics/src/dstas/__tests/dstas-fixture.ts b/packages/overlays/topics/src/dstas/__tests/dstas-fixture.ts new file mode 100644 index 000000000..8f3eb1e5d --- /dev/null +++ b/packages/overlays/topics/src/dstas/__tests/dstas-fixture.ts @@ -0,0 +1,2 @@ +// Real DSTAS locking script from dxs-bsv-token-sdk (buildDstasLockingScript). +export const DSTAS_PLAIN_HEX = '142f2ec98dfa6429a028536a6c9451f702daa3a333006d82736301218763007b7b517c6e5667766b517f786b517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68517f73637c7f68766c936c7c5493686751687652937a76aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e011f7f7d7e01007e8111414136d08c5ed2bf3ba048afe6dcaebafe01005f80837e01007e7652967b537a7601ff877c0100879b7d648b6752799368537a7d9776547aa06394677768263044022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802207c607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e7c6421038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b92186721023635954789a02e39fb7e54440b6f528d53efd65635ddad7f3c4085f97fdbdc4868ad547f7701207f01207f7701247f517f7801007e02fd00a063546752687f7801007e817f727e7b517f7c01147d887f517f7c01007e817601619f6976014ea063517c7b6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f007b7b687602540b7f7701147f7c5579876b826475020100686b587a5893766b7a765155a569005379736382013ca07c517f7c51877b9a6352795487637101007c7e717101207f01147f7577776775785387646c766b8b8b7951886868677568686c6c7c6b517f7c817f788273638c7f776775010068518463517f7c01147d887f547952876372777c717c767663517f756852875779766352790152879a689b63517f77567a7567527c7681014f0161a5587a9a63015094687e68746c766b5c9388748c76795879888c8c7978886777717c567a5679538764780152879a787663517f756852879b745394768b797663517f756852877c6c766b5c936ea0637c8c768b797663517f75685287726b9b7c6c686ea0637c5394768b797663517f75685287726b9b7c6c686ea063755494797663517f756852879b676d689b63006968677568687c717167567a7568788273638c7f776775010068528463517f7c01147d887f547953876372777c677768686d6c75787653877c52879b636c75006b687c518763755279685879a9886b6b6b6b6b6b6b827763af686c6c6c6c6c6c6c547a577a7664577a577a587a597a786354807e7e676d68aa8800677b7c7651876375577a7c587a67007c68765258a569765187645294597a53795b7a7e7e78637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e6878637c8c7c53797e5a7a7e68687276647572677772755168537a76aa5a7a7d54807e597a5b7a5c7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa5a7a7d877663516752687c72879b69537a6491687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75517f7c01147d887f517f7c01007e817601619f6976014ea0637c6776014ba06376014da063755467014d9c6352675168687f7c01007e81687f68557964577988756d67716881687863567a677b68587f7c8153796353795287637b6b537a6b717c6b6b537a6b676b577a6b597a6b587a6b577a6b7c68677b93687c547f7701207f75748c7a7669765880044676a914780114748c7a76727b748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685c795c79636c766b7363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e0a888201218763ac67517f07517f73637c7f6876767e767e7e02ae687e7e7c557a00740111a063005a79646b7c748c7a76697d937b7b58807e6c91677c748c7a7d58807e6c6c6c557a680114748c7a748c7a768291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068685479635f79676c766b0115797363517f7c51876301207f7c5279a8877c011c7f5579877c01147f755679879a9a6967756868687e777e7e827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7c637e677c6b7c6b7c6b7e7c6b68685979636c6c766b786b7363517f7c51876301347f77547f547f75786352797b01007e81957c01007e81965379a169676d68677568685c797363517f7c51876301347f77547f547f75786354797b01007e81957c01007e819678a169676d68677568687568740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68740111a063748c7a76697d58807e00005c79635e79768263517f756851876c6c766b7c6b768263517f756851877b6e9b63789c6375745294797b78877b7b877d9b69637c917c689167745294797c638777637c917c91686777876391677c917c686868676d6d68687863537a6c936c6c6c567a567a54795479587a676b72937b7b5c795e796c68748c7a748c7a7b636e717b7b877b7b879a6967726d6801147b7e7c8291788251877c764f877c81510111a59b9a9b648276014ba1647602ff00a16351014c677603ffff00a16352014d6754014e68687b7b7f757e687c7e67736301509367010068687e7c636c766b7e726b6b726b6b675b797e68827602fc00a0637603ffff00a06301fe7c82546701fd7c8252687da0637f756780687e67517f75687c7e7e68597a636c6c6c6d6c6c6d6c9d687c587a9d7d7e5c79635d795880041976a9145e797e0288ac7e7e6700687d7e5c7a766302006a7c7e827602fc00a06301fd7c7e536751687f757c7e0058807c7e687d7eaa6b7e7e7e7e7e7eaa78877c6c877c6c9a9b726d726d77776a14b4ab0fffa02223a8a40d9e7f7823e61b3862538201031400112233445566778899aabbccddeeff00112233148899aabbccddeeff00112233445566778899aabb' diff --git a/packages/overlays/topics/src/dstas/types.ts b/packages/overlays/topics/src/dstas/types.ts new file mode 100644 index 000000000..cdf902bf0 --- /dev/null +++ b/packages/overlays/topics/src/dstas/types.ts @@ -0,0 +1,38 @@ +import { PubKeyHex } from '@bsv/sdk' + +export type { DstasTokenDecoded } from '@bsv/templates' + +/** + * An indexed DSTAS token UTXO. DSTAS is satoshi-denominated, so the amount is + * the output's satoshi value and is not stored here (the admission payload + * carries no satoshis; conservation is enforced upstream). + */ +export interface DstasTokenRecord { + txid: string + outputIndex: number + /** Redemption / protoID pkh — the token id. */ + tokenId: string + ownerHash160: string + frozen: boolean + createdAt: Date +} + +export interface UTXOReference { + txid: string + outputIndex: number +} + +export interface DstasQuery { + tokenId?: string + txid?: string + outputIndex?: number + ownerHash160?: PubKeyHex + /** + * Optional compliance filter, combinable with tokenId/owner queries. DSTAS + * freeze is enforced on-chain by Bitcoin Script (a frozen UTXO cannot be + * transferred by its owner); the overlay still indexes frozen outputs as real + * state. Set `frozen: false` to discover only spendable holdings, `true` to + * find frozen ones. Omitted ⇒ both. + */ + frozen?: boolean +} diff --git a/packages/overlays/topics/src/index.ts b/packages/overlays/topics/src/index.ts index 214179525..e1aa53a53 100644 --- a/packages/overlays/topics/src/index.ts +++ b/packages/overlays/topics/src/index.ts @@ -103,6 +103,28 @@ export type { WalletConfigRegistration, WalletConfigRecord, WalletConfigQuery } export { default as WalletConfigTopicManager } from './walletconfig/WalletConfigTopicManager.js' export { default as createWalletConfigLookupService } from './walletconfig/WalletConfigLookupService.js' +// token issuer-authority policy (shared by stas / bsv21 / dstas topic managers) +export type { TokenIssuerPolicy } from './admission/issuerPolicy.js' +export { allowlistIssuerPolicy } from './admission/issuerPolicy.js' + +// stas (classic STAS / P2STAS) +export { StasTopicManager } from './stas/StasTopicManager.js' +export { StasLookupService, createStasLookupService } from './stas/StasLookupService.js' +export { StasStorageManager } from './stas/StasStorageManager.js' +export type { StasTokenRecord, StasQuery, UTXOReference as StasUTXOReference } from './stas/types.js' + +// bsv21 (1Sat fungible tokens) +export { Bsv21TopicManager } from './bsv21/Bsv21TopicManager.js' +export { Bsv21LookupService, createBsv21LookupService } from './bsv21/Bsv21LookupService.js' +export { Bsv21StorageManager } from './bsv21/Bsv21StorageManager.js' +export type { Bsv21TokenRecord, Bsv21Query, UTXOReference as Bsv21UTXOReference } from './bsv21/types.js' + +// dstas (Divisible STAS / STAS 3.0) +export { DstasTopicManager } from './dstas/DstasTopicManager.js' +export { DstasLookupService, createDstasLookupService } from './dstas/DstasLookupService.js' +export { DstasStorageManager } from './dstas/DstasStorageManager.js' +export type { DstasTokenRecord, DstasQuery, UTXOReference as DstasUTXOReference } from './dstas/types.js' + // mandala export { MandalaTopicManager } from './mandala/MandalaTopicManager.js' export { MandalaLookupService, createMandalaLookupService } from './mandala/MandalaLookupService.js' diff --git a/packages/overlays/topics/src/shared/BaseTokenTopicManager.ts b/packages/overlays/topics/src/shared/BaseTokenTopicManager.ts new file mode 100644 index 000000000..f9d7dd843 --- /dev/null +++ b/packages/overlays/topics/src/shared/BaseTokenTopicManager.ts @@ -0,0 +1,81 @@ +import { TopicManager } from '@bsv/overlay' +import { AdmittanceInstructions, Transaction } from '@bsv/sdk' +import { TokenIssuerPolicy } from '../admission/issuerPolicy.js' + +export interface DecodedTokenOutput { + index: number + key: string + amount: N +} + +/** + * Shared admission machinery for structural, value-conservation-gated token + * topic managers (STAS / BSV-21 / DSTAS). The three token types differ only in + * how an output is decoded and how amounts are added/compared (number vs. + * bigint); conservation checking, issuer-policy gating, and the top-level + * try/catch are identical, so they live here once. + */ +export abstract class BaseTokenTopicManager implements TopicManager { + constructor (protected readonly issuerPolicy: TokenIssuerPolicy = {}) {} + + protected abstract readonly zero: N + protected abstract readonly logLabel: string + + protected abstract decodeOutputs (tx: Transaction): Array> + protected abstract inputTotals (tx: Transaction, previousCoins: number[]): Map + protected abstract add (a: N, b: N): N + protected abstract gt (a: N, b: N): boolean + + /** Rejects only on inflation: outputs exceeding inputs for a key with inputs. */ + private conservationHolds (outputs: Array>, inTotals: Map): boolean { + const outTotals = new Map() + for (const o of outputs) outTotals.set(o.key, this.add(outTotals.get(o.key) ?? this.zero, o.amount)) + for (const [key, outAmt] of outTotals) { + const inAmt = inTotals.get(key) ?? this.zero + if (this.gt(inAmt, this.zero) && this.gt(outAmt, inAmt)) return false + } + return true + } + + /** + * Drop issuance outputs the issuer policy rejects. An output is an issuance + * when no input carries its key; transfers are untouched. With no policy, + * every issuance passes (permissionless default). + */ + private applyIssuerPolicy (outputs: Array>, inTotals: Map): Array> { + const allow = this.issuerPolicy.allowIssuance + if (allow === undefined) return outputs + return outputs.filter(o => { + const isIssuance = (inTotals.get(o.key) ?? this.zero) === this.zero + return !isIssuance || allow(o.key) + }) + } + + async identifyAdmissibleOutputs ( + beef: number[], + previousCoins: number[] + ): Promise { + try { + const tx = Transaction.fromBEEF(beef) + const outputs = this.decodeOutputs(tx) + if (outputs.length === 0) return { outputsToAdmit: [], coinsToRetain: [] } + + const inTotals = this.inputTotals(tx, previousCoins) + if (!this.conservationHolds(outputs, inTotals)) { + return { outputsToAdmit: [], coinsToRetain: [] } + } + + const admissible = this.applyIssuerPolicy(outputs, inTotals) + return { + outputsToAdmit: admissible.map(o => o.index).sort((a, b) => a - b), + coinsToRetain: previousCoins + } + } catch (error) { + console.warn(`[${this.logLabel}] identifyAdmissibleOutputs failed: ${String(error)}`) + return { outputsToAdmit: [], coinsToRetain: [] } + } + } + + abstract getDocumentation (): Promise + abstract getMetaData (): Promise<{ name: string, shortDescription: string }> +} diff --git a/packages/overlays/topics/src/shared/tokenLookupTail.ts b/packages/overlays/topics/src/shared/tokenLookupTail.ts new file mode 100644 index 000000000..eb4e2bfdc --- /dev/null +++ b/packages/overlays/topics/src/shared/tokenLookupTail.ts @@ -0,0 +1,24 @@ +import { LookupFormula } from '@bsv/overlay' + +export interface OwnerOutpointStorage { + findByOwner: (ownerHash160: string) => Promise + findByOutpoint: (txid: string, outputIndex: number) => Promise +} + +/** + * Shared tail of the STAS/BSV-21 `lookup()` implementations: once the + * token-specific primary key (assetId/tokenId) has been checked and missed, + * every token type falls back to the same owner/outpoint queries. + */ +export async function lookupByOwnerOrOutpoint ( + storage: OwnerOutpointStorage, + query: { ownerHash160?: unknown, txid?: unknown, outputIndex?: unknown } +): Promise { + if (typeof query.ownerHash160 === 'string') { + return await storage.findByOwner(query.ownerHash160) + } + if (typeof query.txid === 'string' && typeof query.outputIndex === 'number') { + return await storage.findByOutpoint(query.txid, query.outputIndex) + } + throw new Error('Unsupported query') +} diff --git a/packages/overlays/topics/src/stas/StasLookupDocs.md.ts b/packages/overlays/topics/src/stas/StasLookupDocs.md.ts new file mode 100644 index 000000000..5e7e03282 --- /dev/null +++ b/packages/overlays/topics/src/stas/StasLookupDocs.md.ts @@ -0,0 +1,7 @@ +export default `# Classic STAS Token Lookup Service (ls_stas) + +Indexes admitted classic-STAS token UTXOs by assetId (symbol), owner hash160, +and outpoint. Supports lookup by assetId, by owner, or by outpoint. There is no +key-linkage retention and no identity-balance query — classic STAS carries no +identity linkage on-chain. +` diff --git a/packages/overlays/topics/src/stas/StasLookupService.ts b/packages/overlays/topics/src/stas/StasLookupService.ts new file mode 100644 index 000000000..d93de8535 --- /dev/null +++ b/packages/overlays/topics/src/stas/StasLookupService.ts @@ -0,0 +1,73 @@ +import { + LookupService, LookupQuestion, LookupFormula, + AdmissionMode, SpendNotificationMode, OutputAdmittedByTopic, OutputSpent +} from '@bsv/overlay' +import { Db } from 'mongodb' +import { StasToken } from '@bsv/templates' +import { StasStorageManager } from './StasStorageManager.js' +import { StasQuery } from './types.js' +import { lookupByOwnerOrOutpoint } from '../shared/tokenLookupTail.js' +import docs from './StasLookupDocs.md.js' + +export interface StasLookupDeps { + storage: StasStorageManager +} + +export class StasLookupService implements LookupService { + readonly admissionMode: AdmissionMode = 'locking-script' + readonly spendNotificationMode: SpendNotificationMode = 'script' + + constructor (private readonly deps: StasLookupDeps) {} + + async outputAdmittedByTopic (payload: OutputAdmittedByTopic): Promise { + if (payload.mode !== 'locking-script') return + if (payload.topic !== 'tm_stas') return + let decoded + try { + decoded = StasToken.decode(payload.lockingScript) + } catch { + return // not a STAS output + } + await this.deps.storage.storeToken({ + txid: payload.txid, + outputIndex: payload.outputIndex, + assetId: decoded.assetId, + ownerHash160: decoded.ownerHash160, + createdAt: new Date() + }) + } + + async outputSpent (payload: OutputSpent): Promise { + if (payload.topic !== 'tm_stas') return + await this.deps.storage.deleteToken(payload.txid, payload.outputIndex) + } + + async outputEvicted (txid: string, outputIndex: number): Promise { + await this.deps.storage.deleteToken(txid, outputIndex) + } + + async lookup (question: LookupQuestion): Promise { + const query = ((question as any).query ?? {}) as StasQuery + if (typeof query.assetId === 'string') { + return await this.deps.storage.findByAssetId(query.assetId) + } + return await lookupByOwnerOrOutpoint(this.deps.storage, query) + } + + async getDocumentation (): Promise { + return docs + } + + async getMetaData (): Promise<{ name: string, shortDescription: string }> { + return { + name: 'ls_stas', + shortDescription: 'Classic STAS token index by assetId/owner/outpoint.' + } + } +} + +export function createStasLookupService () { + return (db: Db): StasLookupService => new StasLookupService({ + storage: new StasStorageManager(db) + }) +} diff --git a/packages/overlays/topics/src/stas/StasStorageManager.ts b/packages/overlays/topics/src/stas/StasStorageManager.ts new file mode 100644 index 000000000..23c69d940 --- /dev/null +++ b/packages/overlays/topics/src/stas/StasStorageManager.ts @@ -0,0 +1,62 @@ +import { Collection, Db } from 'mongodb' +import { StasTokenRecord, UTXOReference } from './types.js' + +/** + * Mongo-backed index of admitted classic-STAS token UTXOs. Indexes by + * outpoint, assetId, and owner. Unlike the Mandala storage manager there is no + * key-linkage retention and no per-identity balance collection — classic STAS + * carries no identity linkage on-chain. + */ +export class StasStorageManager { + private readonly tokens: Collection + private indexInit?: Promise + + constructor (private readonly db: Db) { + this.tokens = db.collection('stasTokens') + } + + private async ensureIndexes (): Promise { + this.indexInit ??= (async () => { + await Promise.all([ + this.tokens.createIndex({ txid: 1, outputIndex: 1 }, { unique: true }), + this.tokens.createIndex({ assetId: 1 }), + this.tokens.createIndex({ ownerHash160: 1 }) + ]) + })() + return await this.indexInit + } + + /** Project a UTXO-reference cursor for a mongo filter (DRY for the finders). */ + private async query (filter: Record): Promise { + await this.ensureIndexes() + return await this.tokens.find(filter) + .project({ txid: 1, outputIndex: 1, _id: 0 }).toArray() + } + + async storeToken (record: StasTokenRecord): Promise { + await this.ensureIndexes() + await this.tokens.insertOne(record) + } + + async deleteToken (txid: string, outputIndex: number): Promise { + await this.ensureIndexes() + await this.tokens.deleteOne({ txid, outputIndex }) + } + + async findByAssetId (assetId: string): Promise { + return await this.query({ assetId }) + } + + async findByOwner (ownerHash160: string): Promise { + return await this.query({ ownerHash160 }) + } + + async findByOutpoint (txid: string, outputIndex: number): Promise { + return await this.query({ txid, outputIndex }) + } + + async getTokenRow (txid: string, outputIndex: number): Promise { + await this.ensureIndexes() + return await this.tokens.findOne({ txid, outputIndex }) + } +} diff --git a/packages/overlays/topics/src/stas/StasTopicDocs.md.ts b/packages/overlays/topics/src/stas/StasTopicDocs.md.ts new file mode 100644 index 000000000..7eccecb09 --- /dev/null +++ b/packages/overlays/topics/src/stas/StasTopicDocs.md.ts @@ -0,0 +1,15 @@ +export default `# Classic STAS Token Topic Manager (tm_stas) + +Admits classic STAS (legacy P2STAS) token transfer outputs by structural +validation only: an output is decoded against the STAS locking-script shape +(\`76a914 88ac69 OP_RETURN …\`) and the +transaction is admitted when per-asset value conservation holds — classic STAS +is satoshi-denominated, so the token amount IS the output's satoshi value. + +Unlike \`tm_mandala\`, there is no key-linkage verification and no sanctions +screening: classic STAS carries no identity linkage on-chain. A transaction +with no STAS inputs for an asset is treated as issuance and admitted; mint +authority is not verified on-chain in this version. A transaction that would +inflate a token (STAS outputs exceed STAS inputs for an asset that has inputs) +is rejected in full. +` diff --git a/packages/overlays/topics/src/stas/StasTopicManager.ts b/packages/overlays/topics/src/stas/StasTopicManager.ts new file mode 100644 index 000000000..119a96f83 --- /dev/null +++ b/packages/overlays/topics/src/stas/StasTopicManager.ts @@ -0,0 +1,68 @@ +import { Transaction } from '@bsv/sdk' +import { StasToken } from '@bsv/templates' +import { BaseTokenTopicManager, DecodedTokenOutput } from '../shared/BaseTokenTopicManager.js' +import docs from './StasTopicDocs.md.js' + +/** + * Topic manager for classic STAS (legacy P2STAS) token transfers. + * + * Admissibility is purely structural: each output is decoded against the STAS + * locking-script shape, and the transaction is admitted only when per-asset + * value conservation holds. Classic STAS is satoshi-denominated, so the token + * amount is the output's satoshi value. + * + * Deliberate simplifications vs. tm_mandala (no off-chain dependency): + * - no key-linkage verification, no sanctions screening; + * - a tx that would inflate a token (outputs > inputs for an asset that has + * inputs) is rejected in full. + * + * Mint authority is not verifiable on-chain (minting is permissionless). An + * output with no input of its assetId is an issuance; the optional + * TokenIssuerPolicy gates which issuances are indexed. Omitted, the overlay + * stays permissionless (admits all issuances) — the prior behaviour. + */ +export class StasTopicManager extends BaseTokenTopicManager { + protected readonly zero = 0 + protected readonly logLabel = 'StasTopicManager' + + protected decodeOutputs (tx: Transaction): Array> { + const outputs: Array> = [] + for (let i = 0; i < tx.outputs.length; i++) { + try { + const { assetId } = StasToken.decode(tx.outputs[i].lockingScript) + outputs.push({ index: i, key: assetId, amount: tx.outputs[i].satoshis ?? 0 }) + } catch { + // not a STAS output — ignore + } + } + return outputs + } + + protected inputTotals (tx: Transaction, previousCoins: number[]): Map { + const inTotals = new Map() + for (const ci of previousCoins) { + const input = tx.inputs[ci] + const src = input?.sourceTransaction?.outputs[input.sourceOutputIndex] + if (src == null) continue + try { + const { assetId } = StasToken.decode(src.lockingScript) + inTotals.set(assetId, (inTotals.get(assetId) ?? 0) + (src.satoshis ?? 0)) + } catch { /* non-STAS previous coin */ } + } + return inTotals + } + + protected add (a: number, b: number): number { return a + b } + protected gt (a: number, b: number): boolean { return a > b } + + async getDocumentation (): Promise { + return docs + } + + async getMetaData (): Promise<{ name: string, shortDescription: string }> { + return { + name: 'tm_stas', + shortDescription: 'Classic STAS (P2STAS) token transfers admitted by script validity and value conservation.' + } + } +} diff --git a/packages/overlays/topics/src/stas/__tests/StasTopicManager.test.ts b/packages/overlays/topics/src/stas/__tests/StasTopicManager.test.ts new file mode 100644 index 000000000..9af8ce5fb --- /dev/null +++ b/packages/overlays/topics/src/stas/__tests/StasTopicManager.test.ts @@ -0,0 +1,78 @@ +import { StasTopicManager } from '../StasTopicManager' +import { LockingScript, Transaction, UnlockingScript } from '@bsv/sdk' +import { StasToken } from '@bsv/templates' +import { allowlistIssuerPolicy } from '../../admission/issuerPolicy' + +// Synthetic classic STAS locking script: 76a914 88ac69 6a +function stasScript (ownerHex: string, symbol = 'TEST'): LockingScript { + const engine = 'ac'.repeat(8) // opaque filler, free of 0x6a + const symBytes = Buffer.from(symbol, 'utf8').toString('hex') + const symPush = (symBytes.length / 2).toString(16).padStart(2, '0') + symBytes + return LockingScript.fromHex(`76a914${ownerHex}88ac69${engine}6a0100${symPush}`) +} + +const OWNER_A = 'ab'.repeat(20) +const OWNER_B = 'cd'.repeat(20) + +describe('StasTopicManager', () => { + const manager = new StasTopicManager() + + it('admits an issuance output (no STAS inputs)', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: stasScript(OWNER_A), satoshis: 1000 }) + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) + + it('admits a conserving transfer (out sats == in sats for the asset)', async () => { + const sourceTx = new Transaction() + sourceTx.addOutput({ lockingScript: stasScript(OWNER_A), satoshis: 1000 }) + + const tx = new Transaction() + tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: stasScript(OWNER_B), satoshis: 1000 }) + + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([0]) + expect(admitted.coinsToRetain).toEqual([0]) + }) + + it('rejects inflation (out sats > in sats for an asset with inputs)', async () => { + const sourceTx = new Transaction() + sourceTx.addOutput({ lockingScript: stasScript(OWNER_A), satoshis: 1000 }) + + const tx = new Transaction() + tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, unlockingScript: new UnlockingScript([]) }) + tx.addOutput({ lockingScript: stasScript(OWNER_B), satoshis: 2000 }) // inflated + + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0]) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + it('ignores non-STAS outputs', async () => { + const tx = new Transaction() + tx.addOutput({ lockingScript: LockingScript.fromHex(`76a914${OWNER_A}88ac`), satoshis: 1000 }) // plain P2PKH + const admitted = await manager.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + describe('issuer policy', () => { + const ASSET_ID = StasToken.decode(stasScript(OWNER_A)).assetId + + it('rejects an issuance whose assetId is not in the allowlist', async () => { + const gated = new StasTopicManager(allowlistIssuerPolicy([])) + const tx = new Transaction() + tx.addOutput({ lockingScript: stasScript(OWNER_A), satoshis: 1000 }) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([]) + }) + + it('admits an issuance whose assetId is in the allowlist', async () => { + const gated = new StasTopicManager(allowlistIssuerPolicy([ASSET_ID])) + const tx = new Transaction() + tx.addOutput({ lockingScript: stasScript(OWNER_A), satoshis: 1000 }) + const admitted = await gated.identifyAdmissibleOutputs(tx.toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) + }) +}) diff --git a/packages/overlays/topics/src/stas/types.ts b/packages/overlays/topics/src/stas/types.ts new file mode 100644 index 000000000..cf9c10a21 --- /dev/null +++ b/packages/overlays/topics/src/stas/types.ts @@ -0,0 +1,29 @@ +import { PubKeyHex } from '@bsv/sdk' + +export type { StasTokenDecoded } from '@bsv/templates' + +/** + * An indexed classic-STAS token UTXO. Classic STAS is satoshi-denominated (the + * token amount IS the output's satoshi value); the amount is not stored here + * because the overlay admission payload does not carry satoshis — conservation + * is enforced upstream in the topic manager, and queries return outpoints. + */ +export interface StasTokenRecord { + txid: string + outputIndex: number + assetId: string + ownerHash160: string + createdAt: Date +} + +export interface UTXOReference { + txid: string + outputIndex: number +} + +export interface StasQuery { + assetId?: string + txid?: string + outputIndex?: number + ownerHash160?: PubKeyHex +}