@totemsdk/txpow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * serialization.ts — TxPoW envelope serialization.
3
+ *
4
+ * Ports serializeTxHeader / serializeTxBody / serializeTxPoW from
5
+ * packages/totem-extension/src/core/transaction/MinimaTransactionBuilder.ts
6
+ * into the standalone @totemsdk/txpow package.
7
+ *
8
+ * KEY DESIGN DECISIONS
9
+ * ─────────────────────────────────────────────────────────────────────────
10
+ * • serializeTxBody accepts PRE-SERIALIZED transaction bytes and witness bytes.
11
+ * Callers use @totemsdk/core's serializeTransaction() + their own witness
12
+ * serializer, then pass the byte arrays here. This keeps @totemsdk/txpow
13
+ * free of WotsSignatureData / InputScriptInfo / CoinProof dependencies.
14
+ *
15
+ * • txnDifficulty defaults to MAX_HASH (all 0xFF) to produce byte-identical
16
+ * output to the extension's current MEG-mined path. For locally mined TxPoWs
17
+ * the caller MUST pass a value ≤ TX_POW_MIN_DIFFICULTY, otherwise the
18
+ * transaction will fail TxPoWChecker.checkTxPoWSimple() at block inclusion.
19
+ *
20
+ * • nonce defaults to 0n (the mining loop updates it). timeMilli defaults to
21
+ * Date.now() at call time.
22
+ *
23
+ * WIRE FORMAT REFERENCE
24
+ * ─────────────────────────────────────────────────────────────────────────
25
+ * TxHeader.writeDataStream():
26
+ * 1. mNonce MiniNumber
27
+ * 2. mChainID MiniData (MAIN_NET = [0x00])
28
+ * 3. mTimeMilli MiniNumber
29
+ * 4. mBlockNumber MiniNumber (0 for fresh TxPoW)
30
+ * 5. mBlockDifficulty MiniData (MAX_HASH for fresh TxPoW)
31
+ * 6. Super Parents RLE: 1 byte count + writeHashToStream(parent)
32
+ * 7. mMMRRoot writeHashToStream (ZERO_HASH for fresh TxPoW)
33
+ * 8. mMMRTotal MiniNumber (0)
34
+ * 9. mMagic Magic.writeDataStream()
35
+ * 10. mCustomHash writeHashToStream (ZERO_HASH)
36
+ * 11. mTxBodyHash writeHashToStream
37
+ *
38
+ * TxBody.writeDataStream():
39
+ * 1. mPRNG writeHashToStream (32 random bytes)
40
+ * 2. mTxnDifficulty MiniData
41
+ * 3. mTransaction Transaction.writeDataStream() (pre-serialized)
42
+ * 4. mWitness Witness.writeDataStream() (pre-serialized)
43
+ * 5. mBurnTransaction Transaction.writeDataStream() (empty)
44
+ * 6. mBurnWitness Witness.writeDataStream() (empty)
45
+ * 7. mTxPowIDList MiniNumber(0)
46
+ *
47
+ * TxPoW.writeDataStream():
48
+ * TxHeader bytes + MiniByte(0x01=hasBody) + TxBody bytes
49
+ *
50
+ * TxPoW ID:
51
+ * SHA3-256( TxHeader bytes ) — Java: Crypto.getInstance().hashObject(mHeader)
52
+ */
53
+ import { sha3_256 } from '@noble/hashes/sha3.js';
54
+ import { writeMiniNumber, writeMiniData, concat, writeHashToStream, } from '@totemsdk/core';
55
+ import { serializeMagic } from './magic.js';
56
+ import { MAX_HASH, ZERO_HASH, CASCADE_LEVELS, MAIN_NET_CHAIN_ID, } from './constants.js';
57
+ /**
58
+ * Serialize a TxHeader per Minima's TxHeader.writeDataStream().
59
+ *
60
+ * @param txBodyHash SHA3-256 of the serialized TxBody (32 bytes)
61
+ * @param options Optional nonce and timeMilli overrides
62
+ */
63
+ export function serializeTxHeader(txBodyHash, options) {
64
+ const nonce = options?.nonce ?? 0n;
65
+ const timeMilli = options?.timeMilli ?? BigInt(Date.now());
66
+ const parts = [];
67
+ parts.push(writeMiniNumber(nonce));
68
+ parts.push(writeMiniData(MAIN_NET_CHAIN_ID));
69
+ parts.push(writeMiniNumber(timeMilli));
70
+ parts.push(writeMiniNumber(0n));
71
+ parts.push(writeMiniData(MAX_HASH));
72
+ parts.push(new Uint8Array([CASCADE_LEVELS]));
73
+ parts.push(writeHashToStream(ZERO_HASH));
74
+ parts.push(writeHashToStream(ZERO_HASH));
75
+ parts.push(writeMiniNumber(0n));
76
+ parts.push(serializeMagic());
77
+ parts.push(writeHashToStream(ZERO_HASH));
78
+ parts.push(writeHashToStream(txBodyHash));
79
+ return concat(...parts);
80
+ }
81
+ /**
82
+ * Compute TxPoW ID = SHA3-256(TxHeader bytes).
83
+ * Matches Java: Crypto.getInstance().hashObject(mHeader) via SHA3Digest(256).
84
+ */
85
+ export function computeTxPoWId(headerBytes) {
86
+ return sha3_256(headerBytes);
87
+ }
88
+ /**
89
+ * Build the empty burn transaction bytes.
90
+ *
91
+ * Equivalent to serializeTransaction({ linkHash: new Uint8Array([0x00]),
92
+ * inputs: [], outputs: [], state: [] })
93
+ * which produces:
94
+ * writeMiniNumber(0n) inputs count
95
+ * writeMiniNumber(0n) outputs count
96
+ * writeMiniNumber(0n) state count
97
+ * writeHashToStream([0x00]) linkHash = ZERO_TXPOWID (1 byte)
98
+ */
99
+ function buildEmptyBurnTxBytes() {
100
+ return concat(writeMiniNumber(0n), writeMiniNumber(0n), writeMiniNumber(0n), writeHashToStream(new Uint8Array([0x00])));
101
+ }
102
+ /**
103
+ * Build the empty burn witness bytes.
104
+ *
105
+ * Equivalent to serializeWitness() with no arguments:
106
+ * writeMiniNumber(0n) signature count
107
+ * writeMiniNumber(0n) coinproof count
108
+ * writeMiniNumber(0n) scriptproof count
109
+ */
110
+ function buildEmptyBurnWitnessBytes() {
111
+ return concat(writeMiniNumber(0n), writeMiniNumber(0n), writeMiniNumber(0n));
112
+ }
113
+ /**
114
+ * Serialize a TxBody per Minima's TxBody.writeDataStream().
115
+ *
116
+ * @param txBytes Pre-serialized Transaction bytes (from @totemsdk/core's serializeTransaction)
117
+ * @param witnessBytes Pre-serialized Witness bytes (from extension's serializeWitness)
118
+ * @param options Optional txnDifficulty override and test PRNG
119
+ */
120
+ export function serializeTxBody(txBytes, witnessBytes, options) {
121
+ const txnDifficulty = options?.txnDifficulty ?? MAX_HASH;
122
+ let prng;
123
+ if (options?.prng) {
124
+ prng = options.prng;
125
+ }
126
+ else {
127
+ prng = new Uint8Array(32);
128
+ if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
129
+ throw new Error('SECURITY: crypto.getRandomValues unavailable — cannot generate secure random bytes for transaction PRNG');
130
+ }
131
+ crypto.getRandomValues(prng);
132
+ }
133
+ return concat(writeHashToStream(prng), writeMiniData(txnDifficulty), txBytes, witnessBytes, buildEmptyBurnTxBytes(), buildEmptyBurnWitnessBytes(), writeMiniNumber(0n));
134
+ }
135
+ /**
136
+ * Serialize a complete TxPoW per Minima's TxPoW.writeDataStream().
137
+ *
138
+ * Assembles TxBody from pre-serialized tx+witness bytes, hashes the body to
139
+ * obtain mTxBodyHash, builds TxHeader with nonce=0, then concatenates:
140
+ * TxHeader | 0x01 (hasBody) | TxBody
141
+ *
142
+ * The returned bytes have nonce=0. Pass them to mineTxPoW() to find a valid
143
+ * nonce for local mining, or send as-is when MEG will re-mine.
144
+ *
145
+ * @param txBytes Pre-serialized Transaction bytes
146
+ * @param witnessBytes Pre-serialized Witness bytes
147
+ * @param options Optional txnDifficulty, nonce, timeMilli, prng
148
+ */
149
+ export function serializeTxPoW(txBytes, witnessBytes, options) {
150
+ const txBody = serializeTxBody(txBytes, witnessBytes, options);
151
+ const txBodyHash = sha3_256(txBody);
152
+ return concat(serializeTxHeader(txBodyHash, options), new Uint8Array([0x01]), txBody);
153
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * verify.ts — TxPoW proof-of-work verification.
3
+ *
4
+ * verifyProofOfWork checks that a given txpowId beats the difficulty target
5
+ * without re-mining. Used by relay nodes to reject under-work submissions.
6
+ */
7
+ export interface VerifyResult {
8
+ valid: boolean;
9
+ txpowId: string;
10
+ difficulty: string;
11
+ reason?: string;
12
+ }
13
+ /**
14
+ * Verify that a TxPoW ID beats the stated difficulty target.
15
+ *
16
+ * valid = txpowId < mTxnDifficulty (big-endian 256-bit comparison)
17
+ *
18
+ * @param txpowId The 32-byte TxPoW ID (SHA3-256 of the header).
19
+ * @param mTxnDifficulty The 32-byte difficulty target from the TxBody.
20
+ */
21
+ export declare function verifyProofOfWork(txpowId: Uint8Array, mTxnDifficulty: Uint8Array): VerifyResult;
package/dist/verify.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * verify.ts — TxPoW proof-of-work verification.
3
+ *
4
+ * verifyProofOfWork checks that a given txpowId beats the difficulty target
5
+ * without re-mining. Used by relay nodes to reject under-work submissions.
6
+ */
7
+ import { isLessThan } from './mine.js';
8
+ function toHex(bytes) {
9
+ return '0x' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
10
+ }
11
+ /**
12
+ * Verify that a TxPoW ID beats the stated difficulty target.
13
+ *
14
+ * valid = txpowId < mTxnDifficulty (big-endian 256-bit comparison)
15
+ *
16
+ * @param txpowId The 32-byte TxPoW ID (SHA3-256 of the header).
17
+ * @param mTxnDifficulty The 32-byte difficulty target from the TxBody.
18
+ */
19
+ export function verifyProofOfWork(txpowId, mTxnDifficulty) {
20
+ if (txpowId.length !== 32) {
21
+ return {
22
+ valid: false,
23
+ txpowId: toHex(txpowId),
24
+ difficulty: toHex(mTxnDifficulty),
25
+ reason: `txpowId must be 32 bytes, got ${txpowId.length}`,
26
+ };
27
+ }
28
+ if (mTxnDifficulty.length !== 32) {
29
+ return {
30
+ valid: false,
31
+ txpowId: toHex(txpowId),
32
+ difficulty: toHex(mTxnDifficulty),
33
+ reason: `mTxnDifficulty must be 32 bytes, got ${mTxnDifficulty.length}`,
34
+ };
35
+ }
36
+ const valid = isLessThan(txpowId, mTxnDifficulty);
37
+ return {
38
+ valid,
39
+ txpowId: toHex(txpowId),
40
+ difficulty: toHex(mTxnDifficulty),
41
+ reason: valid ? undefined : 'txpowId ≥ mTxnDifficulty',
42
+ };
43
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@totemsdk/txpow",
3
+ "version": "0.1.0",
4
+ "description": "TxPoW envelope serialization and proof-of-work mining for the Minima protocol",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "clean": "rm -rf dist",
17
+ "test": "jest --passWithNoTests"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "dependencies": {
24
+ "@totemsdk/core": "^1.0.5"
25
+ },
26
+ "peerDependencies": {
27
+ "@noble/hashes": ">=1.3.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@noble/hashes": {
31
+ "optional": false
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "@noble/hashes": "^1.3.0",
36
+ "@types/jest": "^29.0.0",
37
+ "@types/node": "^20.0.0",
38
+ "jest": "^29.0.0",
39
+ "ts-jest": "^29.0.0",
40
+ "typescript": "^5.0.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=16.0.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "license": "MIT"
49
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Parity tests for @totemsdk/txpow serialization.
3
+ *
4
+ * These tests verify byte-exact output against known reference vectors derived
5
+ * from the extension's private serializeTxHeader / serializeTxBody / serializeTxPoW
6
+ * and from TxHeaderTests_1768991294482.java.
7
+ *
8
+ * GATE: serializeTxPoW output must be byte-identical to the extension's functions
9
+ * for the same inputs before the extension's private copies are deleted (Task #130).
10
+ */
11
+
12
+ import { sha3_256 } from '@noble/hashes/sha3';
13
+ import {
14
+ serializeTxHeader,
15
+ serializeTxBody,
16
+ serializeTxPoW,
17
+ computeTxPoWId,
18
+ TX_POW_MIN_DIFFICULTY,
19
+ MAX_HASH,
20
+ ZERO_HASH,
21
+ CASCADE_LEVELS,
22
+ serializeMagic,
23
+ } from '../index.js';
24
+ import { writeMiniNumber, writeMiniData, writeHashToStream, concat } from '@totemsdk/core';
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Helpers
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ function bytesToHex(b: Uint8Array): string {
31
+ return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
32
+ }
33
+
34
+ function hexToBytes(hex: string): Uint8Array {
35
+ const h = hex.startsWith('0x') ? hex.slice(2) : hex;
36
+ const out = new Uint8Array(h.length / 2);
37
+ for (let i = 0; i < h.length; i += 2) out[i / 2] = parseInt(h.slice(i, i + 2), 16);
38
+ return out;
39
+ }
40
+
41
+ // Known 32-byte body hash (all 0xAB for determinism)
42
+ const MOCK_BODY_HASH = new Uint8Array(32).fill(0xab);
43
+
44
+ // Deterministic PRNG for test reproducibility
45
+ const MOCK_PRNG = new Uint8Array(32).fill(0xcd);
46
+
47
+ // Empty pre-serialized transaction and witness (smallest valid inputs)
48
+ const EMPTY_TX_BYTES = concat(
49
+ writeMiniNumber(0n),
50
+ writeMiniNumber(0n),
51
+ writeMiniNumber(0n),
52
+ writeHashToStream(new Uint8Array([0x00]))
53
+ );
54
+ const EMPTY_WITNESS_BYTES = concat(
55
+ writeMiniNumber(0n),
56
+ writeMiniNumber(0n),
57
+ writeMiniNumber(0n)
58
+ );
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // TX_POW_MIN_DIFFICULTY constant
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ describe('TX_POW_MIN_DIFFICULTY', () => {
65
+ it('is 32 bytes', () => {
66
+ expect(TX_POW_MIN_DIFFICULTY.length).toBe(32);
67
+ });
68
+
69
+ it('is less than MAX_HASH (big-endian 256-bit compare)', () => {
70
+ for (let i = 0; i < 32; i++) {
71
+ if (TX_POW_MIN_DIFFICULTY[i] < MAX_HASH[i]) return;
72
+ if (TX_POW_MIN_DIFFICULTY[i] > MAX_HASH[i]) throw new Error('TX_POW_MIN_DIFFICULTY > MAX_HASH');
73
+ }
74
+ throw new Error('TX_POW_MIN_DIFFICULTY === MAX_HASH');
75
+ });
76
+
77
+ it('equals floor((2^256 - 1) / 1_000_000)', () => {
78
+ const expected = ((2n ** 256n) - 1n) / 1_000_000n;
79
+ let hex = expected.toString(16);
80
+ if (hex.length % 2 !== 0) hex = '0' + hex;
81
+ while (hex.length < 64) hex = '00' + hex;
82
+ expect(bytesToHex(TX_POW_MIN_DIFFICULTY)).toBe(hex);
83
+ });
84
+ });
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // computeTxPoWId
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ describe('computeTxPoWId', () => {
91
+ it('returns SHA3-256 of the provided bytes', () => {
92
+ const input = new Uint8Array([1, 2, 3, 4]);
93
+ const result = computeTxPoWId(input);
94
+ const expected = sha3_256(input);
95
+ expect(result).toEqual(expected);
96
+ });
97
+
98
+ it('returns 32 bytes', () => {
99
+ const result = computeTxPoWId(MOCK_BODY_HASH);
100
+ expect(result.length).toBe(32);
101
+ });
102
+ });
103
+
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+ // serializeMagic
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+
108
+ describe('serializeMagic', () => {
109
+ it('produces a non-empty byte array', () => {
110
+ const magic = serializeMagic();
111
+ expect(magic.length).toBeGreaterThan(0);
112
+ });
113
+
114
+ it('contains expected MiniNumber values for maxTxPoWSize=65536', () => {
115
+ const magic = serializeMagic();
116
+ const hex = bytesToHex(magic);
117
+ // writeMiniNumber(65536n): scale=0 [00], len=3 [03], bytes=[01,00,00]
118
+ // so "000301 0000" — confirm first 6 bytes contain expected value
119
+ expect(hex.startsWith('000301')).toBe(true);
120
+ });
121
+
122
+ it('is deterministic', () => {
123
+ expect(serializeMagic()).toEqual(serializeMagic());
124
+ });
125
+ });
126
+
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+ // serializeTxHeader
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+
131
+ describe('serializeTxHeader', () => {
132
+ const FIXED_TIME = 1700000000000n;
133
+ const FIXED_NONCE = 0n;
134
+
135
+ it('is deterministic for fixed nonce + timeMilli', () => {
136
+ const h1 = serializeTxHeader(MOCK_BODY_HASH, { nonce: FIXED_NONCE, timeMilli: FIXED_TIME });
137
+ const h2 = serializeTxHeader(MOCK_BODY_HASH, { nonce: FIXED_NONCE, timeMilli: FIXED_TIME });
138
+ expect(h1).toEqual(h2);
139
+ });
140
+
141
+ it('starts with nonce=0 MiniNumber [00 01 00]', () => {
142
+ const h = serializeTxHeader(MOCK_BODY_HASH, { nonce: 0n, timeMilli: FIXED_TIME });
143
+ expect(Array.from(h.slice(0, 3))).toEqual([0x00, 0x01, 0x00]);
144
+ });
145
+
146
+ it('starts with nonce=1 MiniNumber [00 01 01] for nonce=1', () => {
147
+ const h = serializeTxHeader(MOCK_BODY_HASH, { nonce: 1n, timeMilli: FIXED_TIME });
148
+ expect(Array.from(h.slice(0, 3))).toEqual([0x00, 0x01, 0x01]);
149
+ });
150
+
151
+ it('encodes nonce=128 with 4 bytes (MiniNumber boundary)', () => {
152
+ const h = serializeTxHeader(MOCK_BODY_HASH, { nonce: 128n, timeMilli: FIXED_TIME });
153
+ // writeMiniNumber(128n): scale=0 [00], len=2 [02], bytes=[00,80]
154
+ expect(Array.from(h.slice(0, 4))).toEqual([0x00, 0x02, 0x00, 0x80]);
155
+ });
156
+
157
+ it('encodes nonce=32768 with 5 bytes (MiniNumber boundary)', () => {
158
+ const h = serializeTxHeader(MOCK_BODY_HASH, { nonce: 32768n, timeMilli: FIXED_TIME });
159
+ // writeMiniNumber(32768n): scale=0 [00], len=3 [03], bytes=[00,80,00]
160
+ expect(Array.from(h.slice(0, 5))).toEqual([0x00, 0x03, 0x00, 0x80, 0x00]);
161
+ });
162
+
163
+ it('includes CASCADE_LEVELS=32 as single byte in super-parents section', () => {
164
+ const h = serializeTxHeader(MOCK_BODY_HASH, { nonce: 0n, timeMilli: FIXED_TIME });
165
+ const hex = bytesToHex(h);
166
+ // After nonce[3] + chainID[5] + timeMilli[?] + blockNum[3] + blockDiff[36] = starts with 00 01 00
167
+ // We check that the byte 0x20 (=32=CASCADE_LEVELS) appears exactly once in isolation
168
+ expect(h).toContain(CASCADE_LEVELS);
169
+ const idx = h.indexOf(CASCADE_LEVELS);
170
+ expect(idx).toBeGreaterThan(0);
171
+ expect(hex).toContain('20'); // 0x20 = 32
172
+ });
173
+
174
+ it('ends with the txBodyHash via writeHashToStream (last 36 bytes = 4-byte len + 32 bytes)', () => {
175
+ const h = serializeTxHeader(MOCK_BODY_HASH, { nonce: 0n, timeMilli: FIXED_TIME });
176
+ const tail = h.slice(h.length - 36);
177
+ // writeHashToStream produces: 00 00 00 20 + 32 bytes
178
+ expect(Array.from(tail.slice(0, 4))).toEqual([0x00, 0x00, 0x00, 0x20]);
179
+ expect(tail.slice(4)).toEqual(MOCK_BODY_HASH);
180
+ });
181
+
182
+ it('changes when nonce changes (mining loop property)', () => {
183
+ const h0 = serializeTxHeader(MOCK_BODY_HASH, { nonce: 0n, timeMilli: FIXED_TIME });
184
+ const h1 = serializeTxHeader(MOCK_BODY_HASH, { nonce: 1n, timeMilli: FIXED_TIME });
185
+ expect(h0).not.toEqual(h1);
186
+ });
187
+ });
188
+
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // serializeTxBody
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+
193
+ describe('serializeTxBody', () => {
194
+ it('is deterministic when prng is provided', () => {
195
+ const b1 = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
196
+ const b2 = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
197
+ expect(b1).toEqual(b2);
198
+ });
199
+
200
+ it('starts with writeHashToStream(prng): [00 00 00 20] + 32 prng bytes', () => {
201
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
202
+ expect(Array.from(body.slice(0, 4))).toEqual([0x00, 0x00, 0x00, 0x20]);
203
+ expect(body.slice(4, 36)).toEqual(MOCK_PRNG);
204
+ });
205
+
206
+ it('uses MAX_HASH as default txnDifficulty (36 bytes after PRNG)', () => {
207
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
208
+ // After PRNG (36 bytes): writeMiniData(MAX_HASH) = [00 00 00 20] + 32×FF
209
+ const diffStart = 36;
210
+ expect(Array.from(body.slice(diffStart, diffStart + 4))).toEqual([0x00, 0x00, 0x00, 0x20]);
211
+ expect(Array.from(body.slice(diffStart + 4, diffStart + 36))).toEqual(Array(32).fill(0xff));
212
+ });
213
+
214
+ it('uses provided txnDifficulty correctly', () => {
215
+ const customDiff = new Uint8Array(32).fill(0x11);
216
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, {
217
+ prng: MOCK_PRNG,
218
+ txnDifficulty: customDiff,
219
+ });
220
+ const diffStart = 36;
221
+ expect(Array.from(body.slice(diffStart + 4, diffStart + 36))).toEqual(Array(32).fill(0x11));
222
+ });
223
+
224
+ it('includes the pre-serialized txBytes verbatim', () => {
225
+ const mockTx = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
226
+ const body = serializeTxBody(mockTx, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
227
+ const bodyHex = bytesToHex(body);
228
+ expect(bodyHex).toContain('deadbeef');
229
+ });
230
+
231
+ it('includes the pre-serialized witnessBytes verbatim', () => {
232
+ const mockWitness = new Uint8Array([0xca, 0xfe, 0xba, 0xbe]);
233
+ const body = serializeTxBody(EMPTY_TX_BYTES, mockWitness, { prng: MOCK_PRNG });
234
+ const bodyHex = bytesToHex(body);
235
+ expect(bodyHex).toContain('cafebabe');
236
+ });
237
+
238
+ it('ends with MiniNumber(0) for empty txpowid list', () => {
239
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
240
+ // Last 3 bytes: writeMiniNumber(0n) = [00, 01, 00]
241
+ expect(Array.from(body.slice(body.length - 3))).toEqual([0x00, 0x01, 0x00]);
242
+ });
243
+ });
244
+
245
+ // ─────────────────────────────────────────────────────────────────────────────
246
+ // serializeTxPoW — structure tests
247
+ // ─────────────────────────────────────────────────────────────────────────────
248
+
249
+ describe('serializeTxPoW', () => {
250
+ it('byte at header.length is 0x01 (hasBody flag)', () => {
251
+ const FIXED_TIME = 1700000000000n;
252
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
253
+ const bodyHash = sha3_256(body);
254
+ const header = serializeTxHeader(bodyHash, { nonce: 0n, timeMilli: FIXED_TIME });
255
+
256
+ const txpow = serializeTxPoW(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, {
257
+ nonce: 0n,
258
+ timeMilli: FIXED_TIME,
259
+ prng: MOCK_PRNG,
260
+ });
261
+
262
+ expect(txpow[header.length]).toBe(0x01);
263
+ });
264
+
265
+ it('header prefix matches standalone serializeTxHeader output', () => {
266
+ const FIXED_TIME = 1700000000000n;
267
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
268
+ const bodyHash = sha3_256(body);
269
+ const expectedHeader = serializeTxHeader(bodyHash, { nonce: 0n, timeMilli: FIXED_TIME });
270
+
271
+ const txpow = serializeTxPoW(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, {
272
+ nonce: 0n,
273
+ timeMilli: FIXED_TIME,
274
+ prng: MOCK_PRNG,
275
+ });
276
+
277
+ expect(txpow.slice(0, expectedHeader.length)).toEqual(expectedHeader);
278
+ });
279
+
280
+ it('txpowId derived from first headerLen bytes matches computeTxPoWId', () => {
281
+ const FIXED_TIME = 1700000000000n;
282
+ const body = serializeTxBody(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, { prng: MOCK_PRNG });
283
+ const bodyHash = sha3_256(body);
284
+ const header = serializeTxHeader(bodyHash, { nonce: 0n, timeMilli: FIXED_TIME });
285
+
286
+ const txpow = serializeTxPoW(EMPTY_TX_BYTES, EMPTY_WITNESS_BYTES, {
287
+ nonce: 0n,
288
+ timeMilli: FIXED_TIME,
289
+ prng: MOCK_PRNG,
290
+ });
291
+
292
+ const idFromTxPoW = computeTxPoWId(txpow.slice(0, header.length));
293
+ const idFromHeader = computeTxPoWId(header);
294
+ expect(idFromTxPoW).toEqual(idFromHeader);
295
+ });
296
+ });
297
+
298
+ // ─────────────────────────────────────────────────────────────────────────────
299
+ // Nonce MiniNumber encoding boundaries
300
+ // ─────────────────────────────────────────────────────────────────────────────
301
+
302
+ describe('nonce MiniNumber encoding boundaries', () => {
303
+ const BODY_HASH = new Uint8Array(32).fill(0x77);
304
+ const TIME = 1700000000000n;
305
+
306
+ it('nonce=0 encodes as 3 bytes [00 01 00]', () => {
307
+ const n = writeMiniNumber(0n);
308
+ expect(n.length).toBe(3);
309
+ expect(Array.from(n)).toEqual([0x00, 0x01, 0x00]);
310
+ });
311
+
312
+ it('nonce=127 encodes as 3 bytes (still 1-byte unscaled)', () => {
313
+ const n = writeMiniNumber(127n);
314
+ expect(n.length).toBe(3);
315
+ expect(Array.from(n)).toEqual([0x00, 0x01, 0x7f]);
316
+ });
317
+
318
+ it('nonce=128 encodes as 4 bytes (2-byte unscaled: leading zero + 0x80)', () => {
319
+ const n = writeMiniNumber(128n);
320
+ expect(n.length).toBe(4);
321
+ expect(Array.from(n)).toEqual([0x00, 0x02, 0x00, 0x80]);
322
+ });
323
+
324
+ it('nonce=32767 encodes as 4 bytes', () => {
325
+ const n = writeMiniNumber(32767n);
326
+ expect(n.length).toBe(4);
327
+ expect(Array.from(n)).toEqual([0x00, 0x02, 0x7f, 0xff]);
328
+ });
329
+
330
+ it('nonce=32768 encodes as 5 bytes (3-byte unscaled: leading zero + 0x80 0x00)', () => {
331
+ const n = writeMiniNumber(32768n);
332
+ expect(n.length).toBe(5);
333
+ expect(Array.from(n)).toEqual([0x00, 0x03, 0x00, 0x80, 0x00]);
334
+ });
335
+
336
+ it('header byte length changes at nonce 128 boundary', () => {
337
+ const h127 = serializeTxHeader(BODY_HASH, { nonce: 127n, timeMilli: TIME });
338
+ const h128 = serializeTxHeader(BODY_HASH, { nonce: 128n, timeMilli: TIME });
339
+ expect(h128.length).toBe(h127.length + 1);
340
+ });
341
+
342
+ it('header byte length changes at nonce 32768 boundary', () => {
343
+ const h32767 = serializeTxHeader(BODY_HASH, { nonce: 32767n, timeMilli: TIME });
344
+ const h32768 = serializeTxHeader(BODY_HASH, { nonce: 32768n, timeMilli: TIME });
345
+ expect(h32768.length).toBe(h32767.length + 1);
346
+ });
347
+ });