Skip to content

Commit f282f4e

Browse files
committed
implement StampParserService: Classic Stamps + SRC-721 via OLGA P2WSH
New parser for the Bitcoin Stamps ecosystem beyond SRC-20. Detects: - Classic Stamps (NFT images: PNG/GIF/SVG/WebP/HTML via P2WSH) - SRC-721 (composable layered NFTs, JSON via P2WSH) - SRC-101 (domain names, JSON via P2WSH) -- type ready, needs test tx OLGA P2WSH format (block 833,000+): data encoded in v0_p2wsh output scriptpubkeys. Each output = 0020 + 32 raw bytes. Concatenate all (skip idx 0), strip trailing zeros, 2-byte BE length prefix, raw data. Two flavors found on mainnet: - Counterparty-issued (Classic Stamps): no stamp: prefix, raw file bytes - Direct Bitcoin (SRC-20/101 OLGA): stamp: prefix before content Both handled by extractOlgaData(). MIME detection via magic bytes (PNG/GIF/WebP/JPEG/BMP) and text pattern matching (SVG/HTML/JSON). Based on stampchain indexer source (enhanced_mime_detection.py). Wired into: DigitalArtifactsParserService, DigitalArtifactAnalyserService, flags (ordpool_stamp, ordpool_src721, ordpool_src101), index.ts exports. Existing SRC-20 parser unchanged -- still handles ARC4 multisig path.
1 parent 3ad33dc commit f282f4e

16 files changed

Lines changed: 485 additions & 1 deletion

src/digital-artifact-analyser.service.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,18 @@ export class DigitalArtifactAnalyserService {
438438
flags |= OrdpoolTransactionFlags.ordpool_counterparty;
439439
break;
440440

441+
case DigitalArtifactType.Stamp:
442+
flags |= OrdpoolTransactionFlags.ordpool_stamp;
443+
break;
444+
445+
case DigitalArtifactType.Src721:
446+
flags |= OrdpoolTransactionFlags.ordpool_src721;
447+
break;
448+
449+
case DigitalArtifactType.Src101:
450+
flags |= OrdpoolTransactionFlags.ordpool_src101;
451+
break;
452+
441453
case DigitalArtifactType.Inscription:
442454
const inscription = artifact as ParsedInscription;
443455
flags |= OrdpoolTransactionFlags.ordpool_inscription;
@@ -551,7 +563,10 @@ export class DigitalArtifactAnalyserService {
551563
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_counterparty) ||
552564
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_rune) ||
553565
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_brc20) ||
554-
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_src20);
566+
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_src20) ||
567+
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_stamp) ||
568+
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_src721) ||
569+
isFlagSetOnTransaction(tx, OrdpoolTransactionFlags.ordpool_src101);
555570
}
556571

557572

src/digital-artifacts-parser.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { InscriptionParserService } from './inscription/inscription-parser.servi
55
import { LabitbuParserService } from './labitbu/labitbu-parser.service';
66
import { RuneParserService } from './rune/rune-parser.service';
77
import { Src20ParserService } from './src20/src20-parser.service';
8+
import { StampParserService } from './stamp/stamp-parser.service';
89
import { DigitalArtifact } from './types/digital-artifact';
910
import { OnParseError } from './types/parser-options';
1011
import { TransactionSimple } from './types/transaction-simple';
@@ -62,6 +63,11 @@ export class DigitalArtifactsParserService {
6263
artifacts.push(parsedSrc20);
6364
}
6465

66+
const parsedStamp = StampParserService.parse(transaction, onError);
67+
if (parsedStamp) {
68+
artifacts.push(parsedStamp);
69+
}
70+
6571
return artifacts;
6672
}
6773
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export * from './rune/rune-parser.service.helper';
3030
export * from './rune/rune-parser.service';
3131
export * from './src20/src20-parser.service.helper';
3232
export * from './src20/src20-parser.service';
33+
export * from './stamp/stamp-parser.service.helper';
34+
export * from './stamp/stamp-parser.service';
3335
export * from './types/digital-artifact';
3436
export * from './types/mempool';
3537
export * from './types/ordpool-stats';
@@ -42,6 +44,9 @@ export * from './types/parsed-labitbu';
4244
export * from './types/parsed-inscription';
4345
export * from './types/parsed-runestone';
4446
export * from './types/parsed-src20';
47+
export * from './types/parsed-stamp';
48+
export * from './types/parsed-src721';
49+
export * from './types/parsed-src101';
4550
export * from './types/parser-options';
4651
export * from './types/bitcoin-rpc';
4752
export * from './types/transaction-simple';
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { bigEndianBytesToNumber, hexToBytes, concatUint8Arrays } from '../lib/conversions';
2+
3+
/**
4+
* Extracts raw file data from OLGA P2WSH outputs.
5+
*
6+
* OLGA (Octet Linked Graphics & Artifacts) encodes data directly in P2WSH
7+
* output scriptpubkeys. Each output is 34 bytes: 0x00 0x20 + 32 bytes of data.
8+
* The 32-byte "hash" is NOT a real hash -- it's raw file data.
9+
*
10+
* Format:
11+
* 1. Collect all v0_p2wsh scriptpubkeys (skip index 0 -- reserved for destination)
12+
* 2. Strip the 0x0020 prefix from each, giving 32 raw bytes per output
13+
* 3. Concatenate, strip trailing zeros
14+
* 4. First 2 bytes = big-endian file length
15+
* 5. Remaining bytes = raw file data
16+
*
17+
* Two flavors exist on mainnet:
18+
* a) Counterparty-issued stamps (Classic Stamps, SRC-721 via CP):
19+
* Raw file data directly (PNG, GIF, SVG, JSON). No stamp: prefix.
20+
* b) Direct Bitcoin stamps (SRC-20 OLGA, SRC-101 OLGA):
21+
* Data starts with "stamp:" prefix, followed by the file content.
22+
*
23+
* Source: stampchain-io/btc_stamps indexer/src/index_core/transaction_utils.py
24+
* Active since block 833,000 (CP_P2WSH_FEAT_BLOCK_START)
25+
*/
26+
export function extractOlgaData(
27+
vout: { scriptpubkey: string, scriptpubkey_type: string }[]
28+
): Uint8Array | null {
29+
30+
// Collect P2WSH outputs at index > 0 (index 0 is reserved for destination/value)
31+
// P2WSH scriptpubkey format: 0020 + 32 bytes = 68 hex chars total
32+
const chunks: Uint8Array[] = [];
33+
for (let i = 1; i < vout.length; i++) {
34+
const output = vout[i];
35+
if (output.scriptpubkey_type !== 'v0_p2wsh') {
36+
continue;
37+
}
38+
// Must be exactly 68 hex chars (34 bytes: 0x00 0x20 + 32 data bytes)
39+
if (output.scriptpubkey.length !== 68) {
40+
continue;
41+
}
42+
// Skip the 0020 prefix (4 hex chars), extract 32 raw bytes
43+
chunks.push(hexToBytes(output.scriptpubkey.substring(4)));
44+
}
45+
46+
if (chunks.length === 0) {
47+
return null;
48+
}
49+
50+
const allBytes = concatUint8Arrays(chunks);
51+
52+
// Strip trailing zeros (padding)
53+
let end = allBytes.length;
54+
while (end > 0 && allBytes[end - 1] === 0) {
55+
end--;
56+
}
57+
if (end < 2) {
58+
return null;
59+
}
60+
const trimmed = allBytes.subarray(0, end);
61+
62+
// First 2 bytes = big-endian file length
63+
const fileLength = bigEndianBytesToNumber(trimmed.subarray(0, 2));
64+
if (fileLength === 0 || fileLength > trimmed.length - 2) {
65+
return null;
66+
}
67+
68+
// Extract the file data (skip 2-byte length prefix)
69+
const fileData = trimmed.subarray(2, 2 + fileLength);
70+
71+
// Check for "stamp:" prefix (0x7374616d703a) -- used by SRC-20/SRC-101 OLGA
72+
// Counterparty-issued OLGA stamps do NOT have this prefix.
73+
const STAMP_PREFIX = new Uint8Array([0x73, 0x74, 0x61, 0x6d, 0x70, 0x3a]); // "stamp:"
74+
if (fileData.length > STAMP_PREFIX.length &&
75+
fileData[0] === 0x73 && fileData[1] === 0x74 && fileData[2] === 0x61 &&
76+
fileData[3] === 0x6d && fileData[4] === 0x70 && fileData[5] === 0x3a) {
77+
// Strip the stamp: prefix
78+
return fileData.subarray(STAMP_PREFIX.length);
79+
}
80+
81+
// No stamp: prefix -- return raw file data (Counterparty-issued OLGA)
82+
return fileData;
83+
}
84+
85+
/**
86+
* Detects the MIME type from raw file bytes by checking magic numbers.
87+
*
88+
* This is the same approach used by the stampchain indexer
89+
* (enhanced_mime_detection.py) but simplified for our use case.
90+
*/
91+
export function detectMimeType(data: Uint8Array): string | null {
92+
if (data.length < 4) {
93+
return null;
94+
}
95+
96+
// PNG: 89 50 4e 47 0d 0a 1a 0a
97+
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
98+
return 'image/png';
99+
}
100+
101+
// GIF87a or GIF89a: 47 49 46 38 (37|39) 61
102+
if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38) {
103+
return 'image/gif';
104+
}
105+
106+
// WebP: 52 49 46 46 ... 57 45 42 50 (RIFF....WEBP)
107+
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
108+
data.length >= 12 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
109+
return 'image/webp';
110+
}
111+
112+
// JPEG: ff d8 ff
113+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
114+
return 'image/jpeg';
115+
}
116+
117+
// BMP: 42 4d (BM)
118+
if (data[0] === 0x42 && data[1] === 0x4d) {
119+
return 'image/bmp';
120+
}
121+
122+
// Text-based formats: check as UTF-8
123+
try {
124+
const head = new TextDecoder().decode(data.subarray(0, Math.min(data.length, 256)));
125+
const trimmed = head.trimStart();
126+
127+
// SVG: starts with '<svg' or '<?xml'
128+
if (trimmed.startsWith('<svg') || trimmed.startsWith('<?xml')) {
129+
return 'image/svg+xml';
130+
}
131+
132+
// HTML: starts with '<!doctype html', '<html', '<head', '<body'
133+
const lower = trimmed.toLowerCase();
134+
if (lower.startsWith('<!doctype html') || lower.startsWith('<html') ||
135+
lower.startsWith('<head') || lower.startsWith('<body')) {
136+
return 'text/html';
137+
}
138+
139+
// JSON: starts with '{' or '['
140+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
141+
return 'application/json';
142+
}
143+
} catch {
144+
// not valid UTF-8
145+
}
146+
147+
return null;
148+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { readBinaryFileAsUint8Array, readTransaction } from '../../testdata/test.helper';
2+
import { DigitalArtifactType } from '../types/digital-artifact';
3+
import { ParsedStamp } from '../types/parsed-stamp';
4+
import { ParsedSrc721 } from '../types/parsed-src721';
5+
import { StampParserService } from './stamp-parser.service';
6+
7+
// =============================================================================
8+
// Classic Stamp -- OLGA P2WSH encoding (Counterparty-issued, no stamp: prefix)
9+
// =============================================================================
10+
11+
// Real mainnet Classic Stamp: PNG image, 1393 bytes, block 933,540
12+
// Stamp #1383565 -- pixel art encoded via OLGA P2WSH (44 outputs)
13+
// Counterparty issuance of CPID A197900000000002026
14+
const CLASSIC_STAMP_PNG_TXID = '516e62beeffb26fb37f8e95e809274e5bbde76eb75a28357f6bbcd4eedbfe8ca';
15+
16+
// =============================================================================
17+
// SRC-721 -- OLGA P2WSH encoding (JSON payload)
18+
// =============================================================================
19+
20+
// Real mainnet SRC-721 mint: {"p":"src-721","op":"mint","c":"A1473703777372088053","ts":[1,4,8,4,5,4,7,0,6,6]}
21+
// Stamp #1383566, block 933,541, SVG composable NFT
22+
const SRC721_TXID = 'b74313d300902c0cdf88dc101fb8f4c9ab7ad89c978edd30ca4ee7987cccdedd';
23+
24+
describe('StampParserService', () => {
25+
26+
// ===========================================================================
27+
// Classic Stamp -- OLGA P2WSH with PNG image
28+
// ===========================================================================
29+
30+
describe('Classic Stamp (OLGA P2WSH)', () => {
31+
it('should extract a PNG image from P2WSH outputs', () => {
32+
const txn = readTransaction(CLASSIC_STAMP_PNG_TXID);
33+
const result = StampParserService.parse(txn)!;
34+
35+
expect(result).not.toBeNull();
36+
expect(result.type).toBe(DigitalArtifactType.Stamp);
37+
expect(result.transactionId).toBe(CLASSIC_STAMP_PNG_TXID);
38+
expect(result.uniqueId).toBe(`${DigitalArtifactType.Stamp}-${CLASSIC_STAMP_PNG_TXID}`);
39+
40+
const stamp = result as ParsedStamp;
41+
expect(stamp.contentType).toBe('image/png');
42+
43+
// Verify image data
44+
const raw = stamp.getDataRaw();
45+
expect(raw.length).toBe(1393);
46+
47+
// PNG magic header: 89 50 4e 47 0d 0a 1a 0a
48+
expect(raw[0]).toBe(0x89);
49+
expect(raw[1]).toBe(0x50); // P
50+
expect(raw[2]).toBe(0x4e); // N
51+
expect(raw[3]).toBe(0x47); // G
52+
53+
// Compare byte-for-byte against the reference image
54+
const referenceImage = readBinaryFileAsUint8Array('stamp_1383565_image.png');
55+
expect(raw).toEqual(referenceImage);
56+
57+
// Verify data URI
58+
const dataUri = stamp.getDataUri();
59+
expect(dataUri.startsWith('data:image/png;base64,')).toBe(true);
60+
});
61+
62+
it('should return null for a transaction without P2WSH outputs', () => {
63+
// SRC-20 test tx uses multisig, not P2WSH
64+
const txn = readTransaction('50aeb77245a9483a5b077e4e7506c331dc2f628c22046e7d2b4c6ad6c6236ae1');
65+
expect(StampParserService.parse(txn)).toBeNull();
66+
});
67+
});
68+
69+
// ===========================================================================
70+
// SRC-721 -- OLGA P2WSH with JSON payload
71+
// ===========================================================================
72+
73+
describe('SRC-721 (OLGA P2WSH)', () => {
74+
it('should parse SRC-721 mint from P2WSH outputs', () => {
75+
const txn = readTransaction(SRC721_TXID);
76+
const result = StampParserService.parse(txn)!;
77+
78+
expect(result).not.toBeNull();
79+
expect(result.type).toBe(DigitalArtifactType.Src721);
80+
expect(result.transactionId).toBe(SRC721_TXID);
81+
82+
const src721 = result as ParsedSrc721;
83+
const content = src721.getContent();
84+
85+
// Verify exact JSON content
86+
const parsed = JSON.parse(content);
87+
expect(parsed.p).toBe('src-721');
88+
expect(parsed.op).toBe('mint');
89+
expect(parsed.c).toBe('A1473703777372088053');
90+
expect(parsed.ts).toEqual([1, 4, 8, 4, 5, 4, 7, 0, 6, 6]);
91+
});
92+
});
93+
});

0 commit comments

Comments
 (0)