11import { expect } from 'chai'
22
3- import { OtsParseResult , parseOtsFile , validateOtsProof } from '../../../src/utils/nip03'
3+ import { OtsParseResult , OtsReader , parseOtsFile , validateOtsProof } from '../../../src/utils/nip03'
4+
5+ /** LEB128-encode a non-negative integer that may exceed `Number.MAX_SAFE_INTEGER`. */
6+ function leb128FromBigInt ( n : bigint ) : Buffer {
7+ const bytes : number [ ] = [ ]
8+ let v = n
9+ while ( true ) {
10+ const b = Number ( v & 0x7fn )
11+ v >>= 7n
12+ if ( v !== 0n ) {
13+ bytes . push ( b | 0x80 )
14+ } else {
15+ bytes . push ( b )
16+ break
17+ }
18+ }
19+ return Buffer . from ( bytes )
20+ }
421
522function expectFailure ( result : OtsParseResult ) : { ok : false ; reason : string } {
623 if ( result . ok !== false ) {
@@ -34,12 +51,20 @@ const PENDING_TAG = Buffer.from([0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]
3451const LITECOIN_TAG = Buffer . from ( [ 0x06 , 0x86 , 0x9a , 0x0d , 0x73 , 0xd7 , 0x1b , 0x45 ] )
3552const UNKNOWN_TAG = Buffer . from ( [ 0xaa , 0xbb , 0xcc , 0xdd , 0xee , 0xff , 0x00 , 0x11 ] )
3653
54+ const OP_SHA1 = 0x02
55+ const OP_RIPEMD160 = 0x03
3756const OP_SHA256 = 0x08
57+ const OP_KECCAK256 = 0x67
3858const OP_APPEND = 0xf0
59+ const OP_PREPEND = 0xf1
60+ const OP_REVERSE = 0xf2
61+ const OP_HEXLIFY = 0xf3
3962
4063const TAG_BRANCH = 0xff
4164const TAG_ATTESTATION = 0x00
4265
66+ const ETHEREUM_TAG = Buffer . from ( [ 0x30 , 0xfe , 0x80 , 0x87 , 0xb5 , 0xc7 , 0xea , 0xd7 ] )
67+
4368function writeVarUint ( n : number ) : Buffer {
4469 const bytes : number [ ] = [ ]
4570 let value = n
@@ -81,6 +106,29 @@ function unknownAttestation(payload: Buffer): Buffer {
81106 return Buffer . concat ( [ Buffer . from ( [ TAG_ATTESTATION ] ) , UNKNOWN_TAG , writeVarBytes ( payload ) ] )
82107}
83108
109+ function ethereumAttestation ( height : number ) : Buffer {
110+ const payload = writeVarUint ( height )
111+ return Buffer . concat ( [ Buffer . from ( [ TAG_ATTESTATION ] ) , ETHEREUM_TAG , writeVarBytes ( payload ) ] )
112+ }
113+
114+ /**
115+ * Build a minimal `.ots` file with a given file-hash op and digest length.
116+ * `subItems` are the attestation/op byte sequences; all but the last are
117+ * prefixed with the 0xff branch marker (same layout as `buildOts`).
118+ */
119+ function buildOtsWithFileHashOp ( fileHashOp : number , digest : Buffer , subItems : Buffer [ ] ) : Buffer {
120+ if ( subItems . length === 0 ) {
121+ throw new Error ( 'need at least one sub-item' )
122+ }
123+ const parts : Buffer [ ] = [ MAGIC , writeVarUint ( 1 ) , Buffer . from ( [ fileHashOp ] ) , digest ]
124+ for ( let i = 0 ; i < subItems . length - 1 ; i ++ ) {
125+ parts . push ( Buffer . from ( [ TAG_BRANCH ] ) )
126+ parts . push ( subItems [ i ] )
127+ }
128+ parts . push ( subItems [ subItems . length - 1 ] )
129+ return Buffer . concat ( parts )
130+ }
131+
84132/**
85133 * Build a minimal SHA256-digest `.ots` file whose commitment tree is a single
86134 * leaf attestation. `subItems` are the attestation/op byte sequences that
@@ -102,6 +150,19 @@ function buildOts(digest: Buffer, subItems: Buffer[]): Buffer {
102150const EVENT_ID = 'e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323'
103151const DIGEST = Buffer . from ( EVENT_ID , 'hex' )
104152
153+ describe ( 'OtsReader' , ( ) => {
154+ it ( 'readBytes rejects a negative length' , ( ) => {
155+ const r = new OtsReader ( Buffer . from ( [ 1 , 2 , 3 ] ) )
156+ expect ( ( ) => r . readBytes ( - 1 ) ) . to . throw ( / i n v a l i d n e g a t i v e r e a d l e n g t h / )
157+ } )
158+
159+ it ( 'readVarUint rejects values above Number.MAX_SAFE_INTEGER' , ( ) => {
160+ const encoded = leb128FromBigInt ( BigInt ( Number . MAX_SAFE_INTEGER ) + 1n )
161+ const r = new OtsReader ( encoded )
162+ expect ( ( ) => r . readVarUint ( ) ) . to . throw ( / e x c e e d s s a f e i n t e g e r r a n g e / )
163+ } )
164+ } )
165+
105166describe ( 'NIP-03 — OpenTimestamps' , ( ) => {
106167 describe ( 'parseOtsFile' , ( ) => {
107168 it ( 'parses a minimal proof with a single bitcoin attestation' , ( ) => {
@@ -176,6 +237,102 @@ describe('NIP-03 — OpenTimestamps', () => {
176237 it ( 'returns a structured failure on empty input instead of throwing' , ( ) => {
177238 expectFailure ( parseOtsFile ( Buffer . alloc ( 0 ) ) )
178239 } )
240+
241+ it ( 'rejects non-Buffer input the same as empty (typed array is not a Buffer)' , ( ) => {
242+ const result = expectFailure ( parseOtsFile ( new Uint8Array ( [ 0x01 , 0x02 ] ) as unknown as Buffer ) )
243+ expect ( result . reason ) . to . equal ( 'empty ots file' )
244+ } )
245+
246+ it ( 'parses sha1 file digest (20-byte) proofs' , ( ) => {
247+ const digest = Buffer . alloc ( 20 , 0xab )
248+ const buf = buildOtsWithFileHashOp ( OP_SHA1 , digest , [ bitcoinAttestation ( 1 ) ] )
249+ const result = expectSuccess ( parseOtsFile ( buf ) )
250+ expect ( result . summary . fileHashOp ) . to . equal ( 'sha1' )
251+ expect ( result . summary . digest ) . to . equal ( digest . toString ( 'hex' ) )
252+ } )
253+
254+ it ( 'parses ripemd160 file digest proofs' , ( ) => {
255+ const digest = Buffer . alloc ( 20 , 0xcd )
256+ const buf = buildOtsWithFileHashOp ( OP_RIPEMD160 , digest , [ bitcoinAttestation ( 2 ) ] )
257+ const result = expectSuccess ( parseOtsFile ( buf ) )
258+ expect ( result . summary . fileHashOp ) . to . equal ( 'ripemd160' )
259+ } )
260+
261+ it ( 'parses keccak256 file digest proofs' , ( ) => {
262+ const digest = Buffer . alloc ( 32 , 0xef )
263+ const buf = buildOtsWithFileHashOp ( OP_KECCAK256 , digest , [ bitcoinAttestation ( 3 ) ] )
264+ const result = expectSuccess ( parseOtsFile ( buf ) )
265+ expect ( result . summary . fileHashOp ) . to . equal ( 'keccak256' )
266+ } )
267+
268+ it ( 'parses prepend binary op in the commitment tree' , ( ) => {
269+ const prepend = Buffer . concat ( [ Buffer . from ( [ OP_PREPEND ] ) , writeVarBytes ( Buffer . from ( [ 0x01 ] ) ) ] )
270+ const buf = buildOts ( DIGEST , [ Buffer . concat ( [ prepend , bitcoinAttestation ( 4 ) ] ) ] )
271+ const result = expectSuccess ( parseOtsFile ( buf ) )
272+ expect ( result . summary . attestations . some ( ( a ) => a . kind === 'bitcoin' ) ) . to . equal ( true )
273+ } )
274+
275+ it ( 'parses reverse and hexlify unary ops wrapping an attestation' , ( ) => {
276+ const revThenHex = Buffer . concat ( [ Buffer . from ( [ OP_REVERSE ] ) , Buffer . from ( [ OP_HEXLIFY ] ) , bitcoinAttestation ( 5 ) ] )
277+ const buf = buildOts ( DIGEST , [ revThenHex ] )
278+ const result = expectSuccess ( parseOtsFile ( buf ) )
279+ expect ( result . summary . attestations [ 0 ] . kind ) . to . equal ( 'bitcoin' )
280+ } )
281+
282+ it ( 'classifies ethereum block header attestations' , ( ) => {
283+ const buf = buildOts ( DIGEST , [ ethereumAttestation ( 18_000_000 ) ] )
284+ const result = expectSuccess ( parseOtsFile ( buf ) )
285+ const eth = result . summary . attestations . find ( ( a ) => a . kind === 'ethereum' )
286+ expect ( eth ) . to . exist
287+ expect ( eth ?. height ) . to . equal ( 18_000_000 )
288+ } )
289+
290+ it ( 'treats a truncated bitcoin attestation payload as height-less' , ( ) => {
291+ const broken = Buffer . concat ( [ Buffer . from ( [ TAG_ATTESTATION ] ) , BITCOIN_TAG , writeVarUint ( 0 ) ] )
292+ const buf = buildOts ( DIGEST , [ broken ] )
293+ const result = expectSuccess ( parseOtsFile ( buf ) )
294+ expect ( result . summary . attestations [ 0 ] ) . to . include ( { kind : 'bitcoin' } )
295+ expect ( result . summary . attestations [ 0 ] . height ) . to . equal ( undefined )
296+ } )
297+
298+ it ( 'rejects unknown commitment op tags' , ( ) => {
299+ const buf = Buffer . concat ( [ MAGIC , writeVarUint ( 1 ) , Buffer . from ( [ OP_SHA256 ] ) , DIGEST , Buffer . from ( [ 0xfe ] ) ] )
300+ const result = expectFailure ( parseOtsFile ( buf ) )
301+ expect ( result . reason ) . to . match ( / u n k n o w n o p t a g / )
302+ } )
303+
304+ it ( 'rejects varints that overflow the LEB128 decoder' , ( ) => {
305+ const buf = Buffer . concat ( [ MAGIC , Buffer . alloc ( 9 , 0x80 ) ] )
306+ const result = expectFailure ( parseOtsFile ( buf ) )
307+ expect ( result . reason ) . to . match ( / v a r i n t o v e r f l o w / )
308+ } )
309+
310+ it ( 'rejects varbytes length fields above the relay maximum' , ( ) => {
311+ const buf = Buffer . concat ( [
312+ MAGIC ,
313+ writeVarUint ( 1 ) ,
314+ Buffer . from ( [ OP_SHA256 ] ) ,
315+ DIGEST ,
316+ Buffer . from ( [ OP_APPEND ] ) ,
317+ writeVarUint ( 8193 ) ,
318+ ] )
319+ const result = expectFailure ( parseOtsFile ( buf ) )
320+ expect ( result . reason ) . to . match ( / e x c e e d s m a x i m u m / )
321+ } )
322+
323+ it ( 'rejects commitment trees deeper than the recursion cap' , ( ) => {
324+ // 129 nested (0xff, OP_SHA256) pairs: readTimestamp(128) then dispatches into
325+ // readTimestamp(129), which exceeds MAX_RECURSION_DEPTH (128).
326+ const pairs = 129
327+ const nested = Buffer . alloc ( pairs * 2 )
328+ for ( let i = 0 ; i < pairs ; i ++ ) {
329+ nested [ i * 2 ] = TAG_BRANCH
330+ nested [ i * 2 + 1 ] = OP_SHA256
331+ }
332+ const buf = Buffer . concat ( [ MAGIC , writeVarUint ( 1 ) , Buffer . from ( [ OP_SHA256 ] ) , DIGEST , nested ] )
333+ const result = expectFailure ( parseOtsFile ( buf ) )
334+ expect ( result . reason ) . to . match ( / t o o d e e p / )
335+ } )
179336 } )
180337
181338 describe ( 'validateOtsProof' , ( ) => {
@@ -206,6 +363,10 @@ describe('NIP-03 — OpenTimestamps', () => {
206363 expect ( validateOtsProof ( validProof , 'not-an-id' ) ) . to . match ( / n o t a 3 2 - b y t e h e x / )
207364 } )
208365
366+ it ( 'rejects a non-string target event id' , ( ) => {
367+ expect ( validateOtsProof ( validProof , null as unknown as string ) ) . to . match ( / n o t a 3 2 - b y t e h e x / )
368+ } )
369+
209370 it ( 'rejects proofs without any bitcoin attestation' , ( ) => {
210371 const onlyPending = buildOts ( DIGEST , [ pendingAttestation ( 'https://a.pool.opentimestamps.org' ) ] ) . toString ( 'base64' )
211372 expect ( validateOtsProof ( onlyPending , EVENT_ID ) ) . to . match ( / b i t c o i n a t t e s t a t i o n / )
0 commit comments