stxer 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bitcoin.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Bitcoin SPV utilities for cross-chain simulations.
3
+ *
4
+ * Stacks bridge contracts (sBTC, Brotocol, hBTC, …) verify Bitcoin
5
+ * deposit transactions against burn-block headers and Merkle proofs
6
+ * stored in the Stacks chainstate. To exercise these contracts under
7
+ * the simulator you need three things on the wire:
8
+ *
9
+ * 1. A non-witness Bitcoin transaction whose `txid` (= `sha256d` of
10
+ * the bytes) the Stacks contract can recover.
11
+ * 2. An 80-byte block header whose hash matches the
12
+ * `BurnchainHeaderHash` value the simulator stores. The simulator
13
+ * accepts any 32-byte hash via the `burn_header_hashes` override
14
+ * on `AdvanceBlocks`; pair the override with `buildHeader` so the
15
+ * contract's `verify-block-header` resolves to the same value.
16
+ * 3. A Merkle proof linking the txid to the block header's
17
+ * `merkleRoot`. {@link merkleProof} produces the proof; the
18
+ * contract's on-chain verifier follows the same Bitcoin
19
+ * convention this module implements (leaves are NOT pre-hashed;
20
+ * `sha256d` of concatenated children for inner nodes).
21
+ *
22
+ * All wire-byte conventions match `clarity-bitcoin-v1-07` (the
23
+ * current on-chain SPV reference): hash slots inside the header are
24
+ * stored in *internal byte order* (raw `sha256d`), not the
25
+ * display-reversed form Bitcoin block explorers show.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * import {
30
+ * forgeBitcoinTx, p2wpkhScript, opReturnScript,
31
+ * buildBitcoinHeader, singleTxMerkleRoot,
32
+ * hexToBytes, bytesToHex,
33
+ * } from 'stxer';
34
+ *
35
+ * const tx = forgeBitcoinTx({
36
+ * inputs: [{ prevTxid: hexToBytes('00'.repeat(32)), prevVout: 0,
37
+ * scriptSig: new Uint8Array() }],
38
+ * outputs: [
39
+ * { value: 100_000n, scriptPubKey: p2wpkhScript(hexToBytes('aa'.repeat(20))) },
40
+ * { value: 0n, scriptPubKey: opReturnScript(hexToBytes('5832' + '00'.repeat(20))) },
41
+ * ],
42
+ * });
43
+ * const header = buildBitcoinHeader({
44
+ * merkleRoot: singleTxMerkleRoot(tx.txid),
45
+ * });
46
+ * // Pass `bytesToHex(header.hash)` as `burn_header_hashes[0]` on
47
+ * // `addAdvanceBlocks`; pass `bytesToHex(tx.txid)` to the bridge's
48
+ * // `complete-deposit-wrapper` (or equivalent).
49
+ * ```
50
+ */
51
+ import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js';
52
+
53
+ // ============================================================================
54
+ // Hashing primitives
55
+ // ============================================================================
56
+
57
+ /**
58
+ * SHA-256. Pure-JS via `@noble/hashes` so this module works in
59
+ * browsers, Node, and Bun without a polyfill.
60
+ */
61
+ export function sha256(data: Uint8Array): Uint8Array {
62
+ return nobleSha256(data);
63
+ }
64
+
65
+ /** Bitcoin-style double-sha256 used for txids, block hashes, and Merkle nodes. */
66
+ export function sha256d(data: Uint8Array): Uint8Array {
67
+ return sha256(sha256(data));
68
+ }
69
+
70
+ // ============================================================================
71
+ // Hex encoding
72
+ // ============================================================================
73
+
74
+ /** Decode a hex string (with or without `0x` prefix). */
75
+ export function hexToBytes(s: string): Uint8Array {
76
+ const t = s.toLowerCase().replace(/^0x/, '');
77
+ if (t.length % 2 !== 0) throw new Error('odd-length hex');
78
+ const out = new Uint8Array(t.length / 2);
79
+ for (let i = 0; i < out.length; i++) {
80
+ out[i] = Number.parseInt(t.slice(i * 2, i * 2 + 2), 16);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Lower-case hex (no `0x` prefix). */
86
+ export function bytesToHex(b: Uint8Array): string {
87
+ return Array.from(b)
88
+ .map((x) => x.toString(16).padStart(2, '0'))
89
+ .join('');
90
+ }
91
+
92
+ // ============================================================================
93
+ // Bitcoin script builders
94
+ // ============================================================================
95
+
96
+ /** P2WPKH output script: `OP_0 <20-byte pubkey-hash>`. */
97
+ export function p2wpkhScript(pubKeyHash20: Uint8Array): Uint8Array {
98
+ if (pubKeyHash20.length !== 20) {
99
+ throw new Error('pubKeyHash must be 20 bytes');
100
+ }
101
+ const out = new Uint8Array(22);
102
+ out[0] = 0x00; // OP_0
103
+ out[1] = 0x14; // pushdata length 20
104
+ out.set(pubKeyHash20, 2);
105
+ return out;
106
+ }
107
+
108
+ /** P2PKH output script: `OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG`. */
109
+ export function p2pkhScript(pubKeyHash20: Uint8Array): Uint8Array {
110
+ if (pubKeyHash20.length !== 20) {
111
+ throw new Error('pubKeyHash must be 20 bytes');
112
+ }
113
+ const out = new Uint8Array(25);
114
+ out[0] = 0x76; // OP_DUP
115
+ out[1] = 0xa9; // OP_HASH160
116
+ out[2] = 0x14; // pushdata 20
117
+ out.set(pubKeyHash20, 3);
118
+ out[23] = 0x88; // OP_EQUALVERIFY
119
+ out[24] = 0xac; // OP_CHECKSIG
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * `OP_RETURN <data>` output. Used to embed peg-in metadata
125
+ * (recipient principal, marker bytes, etc.). Bitcoin's standard relay
126
+ * limit caps `data` at 80 bytes; this helper rejects > 75 to leave
127
+ * room for the prefix bytes.
128
+ */
129
+ export function opReturnScript(data: Uint8Array): Uint8Array {
130
+ if (data.length > 75) {
131
+ throw new Error(`OP_RETURN data too long: ${data.length}`);
132
+ }
133
+ const out = new Uint8Array(2 + data.length);
134
+ out[0] = 0x6a; // OP_RETURN
135
+ out[1] = data.length; // direct push
136
+ out.set(data, 2);
137
+ return out;
138
+ }
139
+
140
+ // ============================================================================
141
+ // Bitcoin transaction forging
142
+ // ============================================================================
143
+
144
+ export interface BitcoinTxInput {
145
+ /** 32-byte previous txid in *internal byte order* (raw `sha256d`). */
146
+ prevTxid: Uint8Array;
147
+ prevVout: number;
148
+ /** Script signature bytes. Empty for segwit-style inputs. */
149
+ scriptSig: Uint8Array;
150
+ /** Sequence number; default `0xfffffffd`. */
151
+ sequence?: number;
152
+ }
153
+
154
+ export interface BitcoinTxOutput {
155
+ /** Satoshi amount. */
156
+ value: bigint;
157
+ /** Locking script — use {@link p2wpkhScript} / {@link p2pkhScript} / {@link opReturnScript}. */
158
+ scriptPubKey: Uint8Array;
159
+ }
160
+
161
+ export interface ForgedBitcoinTx {
162
+ /** Wire-encoded non-witness tx bytes (this is what the txid is computed over). */
163
+ bytes: Uint8Array;
164
+ /** Canonical txid: `sha256d(bytes)`. Internal byte order. */
165
+ txid: Uint8Array;
166
+ /** Display-form (reversed) txid — what block explorers render. */
167
+ txidDisplay: Uint8Array;
168
+ }
169
+
170
+ /**
171
+ * Build a non-witness Bitcoin transaction (legacy serialization). The
172
+ * txid is `sha256d` of the returned `bytes`. Use this form when you
173
+ * only need to assert SPV inclusion; segwit witness data is irrelevant
174
+ * to the txid.
175
+ *
176
+ * Wire format:
177
+ * ```
178
+ * 4 version (LE)
179
+ * varint in-count
180
+ * per-input: 32 prev_txid + 4 prev_vout + varint script-len + script + 4 sequence
181
+ * varint out-count
182
+ * per-output: 8 value (LE) + varint script-len + script
183
+ * 4 locktime (LE)
184
+ * ```
185
+ */
186
+ export function forgeBitcoinTx(opts: {
187
+ version?: number;
188
+ inputs: BitcoinTxInput[];
189
+ outputs: BitcoinTxOutput[];
190
+ locktime?: number;
191
+ }): ForgedBitcoinTx {
192
+ const chunks: Uint8Array[] = [];
193
+ const u32le = (n: number) => {
194
+ const b = new Uint8Array(4);
195
+ new DataView(b.buffer).setUint32(0, n >>> 0, true);
196
+ return b;
197
+ };
198
+ const u64le = (n: bigint) => {
199
+ const b = new Uint8Array(8);
200
+ const view = new DataView(b.buffer);
201
+ view.setUint32(0, Number(n & 0xffffffffn), true);
202
+ view.setUint32(4, Number((n >> 32n) & 0xffffffffn), true);
203
+ return b;
204
+ };
205
+ const varint = (n: number): Uint8Array => {
206
+ if (n < 0xfd) return new Uint8Array([n]);
207
+ if (n <= 0xffff) {
208
+ const b = new Uint8Array(3);
209
+ b[0] = 0xfd;
210
+ new DataView(b.buffer).setUint16(1, n, true);
211
+ return b;
212
+ }
213
+ if (n <= 0xffffffff) {
214
+ const b = new Uint8Array(5);
215
+ b[0] = 0xfe;
216
+ new DataView(b.buffer).setUint32(1, n, true);
217
+ return b;
218
+ }
219
+ throw new Error(`varint too large: ${n}`);
220
+ };
221
+
222
+ chunks.push(u32le(opts.version ?? 2));
223
+ chunks.push(varint(opts.inputs.length));
224
+ for (const i of opts.inputs) {
225
+ if (i.prevTxid.length !== 32) {
226
+ throw new Error('prevTxid must be 32 bytes');
227
+ }
228
+ chunks.push(i.prevTxid);
229
+ chunks.push(u32le(i.prevVout));
230
+ chunks.push(varint(i.scriptSig.length));
231
+ chunks.push(i.scriptSig);
232
+ chunks.push(u32le(i.sequence ?? 0xfffffffd));
233
+ }
234
+ chunks.push(varint(opts.outputs.length));
235
+ for (const o of opts.outputs) {
236
+ chunks.push(u64le(o.value));
237
+ chunks.push(varint(o.scriptPubKey.length));
238
+ chunks.push(o.scriptPubKey);
239
+ }
240
+ chunks.push(u32le(opts.locktime ?? 0));
241
+
242
+ const total = chunks.reduce((s, c) => s + c.length, 0);
243
+ const bytes = new Uint8Array(total);
244
+ let off = 0;
245
+ for (const c of chunks) {
246
+ bytes.set(c, off);
247
+ off += c.length;
248
+ }
249
+ const txid = sha256d(bytes);
250
+ const txidDisplay = reverseBytes(txid);
251
+ return { bytes, txid, txidDisplay };
252
+ }
253
+
254
+ // ============================================================================
255
+ // Bitcoin block header
256
+ // ============================================================================
257
+
258
+ export interface BitcoinHeader {
259
+ version: number;
260
+ prevHash: Uint8Array;
261
+ merkleRoot: Uint8Array;
262
+ timestamp: number;
263
+ bits: number;
264
+ nonce: number;
265
+ }
266
+
267
+ export interface BuiltBitcoinHeader {
268
+ /** Raw 80-byte serialized header. */
269
+ header: Uint8Array;
270
+ /**
271
+ * Display-form hash (`reverse(sha256d(header))`) — what the Stacks
272
+ * chainstate stores as `BurnchainHeaderHash`. Pass this as the
273
+ * `burn_header_hashes[i]` override on `addAdvanceBlocks`.
274
+ */
275
+ hash: Uint8Array;
276
+ /** Raw `sha256d(header)` (internal byte order). */
277
+ rawHash: Uint8Array;
278
+ }
279
+
280
+ /**
281
+ * Build an 80-byte Bitcoin block header. `prevHash` and `merkleRoot`
282
+ * are written in internal byte order (raw `sha256d` output) — matching
283
+ * the on-chain `clarity-bitcoin-v1-07` parser convention.
284
+ */
285
+ export function buildBitcoinHeader(
286
+ h: Partial<BitcoinHeader> & { merkleRoot: Uint8Array },
287
+ ): BuiltBitcoinHeader {
288
+ const out = new Uint8Array(80);
289
+ const view = new DataView(out.buffer);
290
+ view.setUint32(0, h.version ?? 0x20000000, true);
291
+ out.set(h.prevHash ?? new Uint8Array(32), 4);
292
+ out.set(h.merkleRoot, 36);
293
+ view.setUint32(68, h.timestamp ?? Math.floor(Date.now() / 1000), true);
294
+ view.setUint32(72, h.bits ?? 0x1d00ffff, true);
295
+ view.setUint32(76, h.nonce ?? 0, true);
296
+ const rawHash = sha256d(out);
297
+ return { header: out, hash: reverseBytes(rawHash), rawHash };
298
+ }
299
+
300
+ // ============================================================================
301
+ // Merkle proofs (Bitcoin convention)
302
+ // ============================================================================
303
+
304
+ /**
305
+ * Single-tx Merkle root — the txid itself. Bitcoin's Merkle tree
306
+ * convention does NOT pre-hash the leaves; for a one-tx block the
307
+ * root equals the txid.
308
+ */
309
+ export function singleTxMerkleRoot(txid: Uint8Array): Uint8Array {
310
+ return new Uint8Array(txid);
311
+ }
312
+
313
+ /**
314
+ * Compute a Bitcoin Merkle proof for `txid` at `index` in `txids`
315
+ * (transaction order within the block). Returns the sibling hashes
316
+ * from leaf to root.
317
+ *
318
+ * Bitcoin convention:
319
+ * - Leaves are the txids themselves (no pre-hashing).
320
+ * - Inner nodes are `sha256d(left || right)`.
321
+ * - Odd levels duplicate the last entry to pair.
322
+ */
323
+ export function merkleProof(
324
+ txids: Uint8Array[],
325
+ index: number,
326
+ ): { proof: Uint8Array[]; root: Uint8Array } {
327
+ if (index < 0 || index >= txids.length) {
328
+ throw new Error(`merkleProof: index ${index} out of range ${txids.length}`);
329
+ }
330
+ let level: Uint8Array[] = txids.map((t) => new Uint8Array(t));
331
+ const proof: Uint8Array[] = [];
332
+ let i = index;
333
+ while (level.length > 1) {
334
+ if (level.length % 2 === 1) level.push(level[level.length - 1]);
335
+ const sibling = level[i ^ 1];
336
+ proof.push(sibling);
337
+ const next: Uint8Array[] = [];
338
+ for (let j = 0; j < level.length; j += 2) {
339
+ next.push(sha256d(concatBytes(level[j], level[j + 1])));
340
+ }
341
+ level = next;
342
+ i = Math.floor(i / 2);
343
+ }
344
+ return { proof, root: level[0] };
345
+ }
346
+
347
+ /**
348
+ * Verify a Bitcoin Merkle proof against `root`. Returns `true` iff the
349
+ * proof reconstructs the root from `txid` at `index`. Matches the
350
+ * on-chain `verify-merkle-proof` semantic.
351
+ */
352
+ export function verifyMerkleProof(
353
+ txid: Uint8Array,
354
+ index: number,
355
+ proof: Uint8Array[],
356
+ root: Uint8Array,
357
+ ): boolean {
358
+ let h: Uint8Array = new Uint8Array(txid);
359
+ let i = index;
360
+ for (const sibling of proof) {
361
+ h =
362
+ (i & 1) === 0
363
+ ? sha256d(concatBytes(h, sibling))
364
+ : sha256d(concatBytes(sibling, h));
365
+ i = Math.floor(i / 2);
366
+ }
367
+ return bytesEqual(h, root);
368
+ }
369
+
370
+ // ============================================================================
371
+ // Internal byte helpers
372
+ // ============================================================================
373
+
374
+ function reverseBytes(a: Uint8Array): Uint8Array {
375
+ const out = new Uint8Array(a.length);
376
+ for (let i = 0; i < a.length; i++) out[i] = a[a.length - 1 - i];
377
+ return out;
378
+ }
379
+
380
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
381
+ const out = new Uint8Array(a.length + b.length);
382
+ out.set(a, 0);
383
+ out.set(b, a.length);
384
+ return out;
385
+ }
386
+
387
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
388
+ if (a.length !== b.length) return false;
389
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
390
+ return true;
391
+ }
package/src/index.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  // the build (babel falls back to flow grammar). Stick with `export *`.
5
5
  export * from './ast';
6
6
  export * from './batch-api';
7
+ export * from './bitcoin';
7
8
  export * from './clarity-api';
8
9
  export * from './constants';
9
10
  export * from './simulation';
10
11
  export * from './simulation-api';
11
12
  export * from './tip';
13
+ export * from './transaction';
12
14
  export * from './types';
@@ -12,10 +12,61 @@ import type {
12
12
  SimulationBatchReadsRequest,
13
13
  SimulationBatchReadsResponse,
14
14
  SimulationResult,
15
+ SimulationTipResponse,
15
16
  SubmitSimulationStepsRequest,
16
17
  SubmitSimulationStepsResponse,
18
+ U64,
17
19
  } from './types';
18
20
 
21
+ /**
22
+ * Server-side error markers. `simulation_busy` ↔ HTTP 409,
23
+ * `simulation_outdated` ↔ HTTP 410. Other errors keep `marker = null`.
24
+ */
25
+ export type SimulationErrorMarker =
26
+ | 'simulation_busy'
27
+ | 'simulation_outdated'
28
+ | null;
29
+
30
+ /**
31
+ * Thrown by the simulation-api fetch wrappers when the API responds
32
+ * with a non-2xx status. `status` carries the wire HTTP code so
33
+ * callers can branch on 409 (busy) / 410 (outdated) without parsing
34
+ * the message string. `marker` is the server-side classification
35
+ * extracted from the body prefix (`simulation_busy:` /
36
+ * `simulation_outdated:`).
37
+ *
38
+ * Pre-0.8.0 SDKs threw a plain `Error` whose message embedded the body
39
+ * text; that lossy form is still used for the message field here so
40
+ * existing log scrapers keep working.
41
+ */
42
+ export class SimulationError extends Error {
43
+ readonly status: number;
44
+ readonly marker: SimulationErrorMarker;
45
+ readonly body: string;
46
+ constructor(operation: string, status: number, body: string) {
47
+ const marker = detectMarker(body);
48
+ super(`${operation} (HTTP ${status}): ${body}`);
49
+ this.name = 'SimulationError';
50
+ this.status = status;
51
+ this.marker = marker;
52
+ this.body = body;
53
+ }
54
+ }
55
+
56
+ function detectMarker(body: string): SimulationErrorMarker {
57
+ if (body.includes('simulation_busy:')) return 'simulation_busy';
58
+ if (body.includes('simulation_outdated:')) return 'simulation_outdated';
59
+ return null;
60
+ }
61
+
62
+ async function throwSimulationError(
63
+ operation: string,
64
+ response: Response,
65
+ ): Promise<never> {
66
+ const text = await response.text();
67
+ throw new SimulationError(operation, response.status, text);
68
+ }
69
+
19
70
  /**
20
71
  * Options for API calls
21
72
  */
@@ -28,8 +79,11 @@ export interface SimulationApiOptions {
28
79
  * Create simulation session options
29
80
  */
30
81
  export interface CreateSessionOptions {
31
- /** Block height for simulation (optional, uses tip if not provided) */
32
- block_height?: number;
82
+ /**
83
+ * Block height for simulation (optional, uses tip if not provided).
84
+ * u64 — `number | string`; see {@link U64}.
85
+ */
86
+ block_height?: U64;
33
87
  /** Block hash corresponding to block_height */
34
88
  block_hash?: string;
35
89
  /** Skip debug tracing for faster simulations */
@@ -81,8 +135,7 @@ export async function instantSimulation(
81
135
  );
82
136
 
83
137
  if (!response.ok) {
84
- const text = await response.text();
85
- throw new Error(`Instant simulation failed: ${text}`);
138
+ await throwSimulationError('Instant simulation failed', response);
86
139
  }
87
140
 
88
141
  return response.json() as Promise<InstantSimulationResponse>;
@@ -123,8 +176,7 @@ export async function createSimulationSession(
123
176
  });
124
177
 
125
178
  if (!response.ok) {
126
- const text = await response.text();
127
- throw new Error(`Failed to create simulation session: ${text}`);
179
+ await throwSimulationError('Failed to create simulation session', response);
128
180
  }
129
181
 
130
182
  const result = (await response.json()) as { id: string };
@@ -174,8 +226,7 @@ export async function submitSimulationSteps(
174
226
  );
175
227
 
176
228
  if (!response.ok) {
177
- const text = await response.text();
178
- throw new Error(`Failed to submit simulation steps: ${text}`);
229
+ await throwSimulationError('Failed to submit simulation steps', response);
179
230
  }
180
231
 
181
232
  return response.json() as Promise<SubmitSimulationStepsResponse>;
@@ -216,13 +267,57 @@ export async function getSimulationResult(
216
267
  );
217
268
 
218
269
  if (!response.ok) {
219
- const text = await response.text();
220
- throw new Error(`Failed to get simulation result: ${text}`);
270
+ await throwSimulationError('Failed to get simulation result', response);
221
271
  }
222
272
 
223
273
  return response.json() as Promise<SimulationResult>;
224
274
  }
225
275
 
276
+ /**
277
+ * Get the current tip of a simulation session.
278
+ *
279
+ * Returns the latest synthetic tip when at least one `AdvanceBlocks`
280
+ * step has run (`synthetic: true`, includes `vrf_seed` and
281
+ * `tenure_change`). Returns the parent metadata pinned at session
282
+ * start otherwise (`synthetic: false`, `vrf_seed` and `tenure_change`
283
+ * omitted).
284
+ *
285
+ * Backed by `GET /devtools/v2/simulations/{id}/tip`. Older simulator
286
+ * builds that predate this route respond 404.
287
+ *
288
+ * @example
289
+ * ```typescript
290
+ * import { getSimulationTip } from 'stxer';
291
+ *
292
+ * const tip = await getSimulationTip(sessionId);
293
+ * if (tip.synthetic) {
294
+ * console.log('synthetic tip vrf_seed:', tip.vrf_seed);
295
+ * }
296
+ * ```
297
+ */
298
+ export async function getSimulationTip(
299
+ sessionId: string,
300
+ options: SimulationApiOptions = {},
301
+ ): Promise<SimulationTipResponse> {
302
+ const apiEndpoint = options.stxerApi ?? DEFAULT_STXER_API;
303
+
304
+ const response = await fetch(
305
+ `${apiEndpoint}/devtools/v2/simulations/${sessionId}/tip`,
306
+ {
307
+ method: 'GET',
308
+ headers: {
309
+ Accept: 'application/json',
310
+ },
311
+ },
312
+ );
313
+
314
+ if (!response.ok) {
315
+ await throwSimulationError('Failed to get simulation tip', response);
316
+ }
317
+
318
+ return response.json() as Promise<SimulationTipResponse>;
319
+ }
320
+
226
321
  /**
227
322
  * Batch reads from a simulation session
228
323
  *
@@ -265,8 +360,10 @@ export async function simulationBatchReads(
265
360
  );
266
361
 
267
362
  if (!response.ok) {
268
- const text = await response.text();
269
- throw new Error(`Failed to batch reads from simulation: ${text}`);
363
+ await throwSimulationError(
364
+ 'Failed to batch reads from simulation',
365
+ response,
366
+ );
270
367
  }
271
368
 
272
369
  return response.json() as Promise<SimulationBatchReadsResponse>;