@meshconnect/uwc-bitcoin 0.4.3 → 0.5.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/psbt.ts ADDED
@@ -0,0 +1,373 @@
1
+ import { WalletConnectorError } from '@meshconnect/uwc-types'
2
+ import { bytesToHex, hexToBytes } from './encoding'
3
+
4
+ /**
5
+ * Finalize a wallet-signed PSBT (BIP-174 v0) and extract the network-ready
6
+ * raw transaction as hex.
7
+ *
8
+ * Hand-rolled rather than pulling in a Bitcoin library so `uwc-bitcoin` stays
9
+ * dependency-free. That is only safe because the scope is deliberately narrow:
10
+ * single-key spends, where finalizing is pure re-arrangement of what the wallet
11
+ * already produced — no script interpretation, no signature checks. Anything
12
+ * else (multisig, Taproot script-path, bare scripts) throws instead of guessing.
13
+ *
14
+ * Per input, in order:
15
+ * - Already final (`final_scriptsig` / `final_scriptwitness` present) → used
16
+ * as-is. Required, not optional: OKX finalizes every input it signs even when
17
+ * asked not to (`autoFinalized: false`), so a finalizer that only accepted
18
+ * partial signatures would reject every OKX transaction.
19
+ * - P2WPKH, P2TR key-path, P2PKH, P2SH-P2WPKH → finalized from the partial
20
+ * signature (or `tap_key_sig`).
21
+ *
22
+ * The caller owns the trust decision about the PSBT's contents — this never
23
+ * inspects outputs or amounts.
24
+ */
25
+ export function finalizePsbt(signedPsbt: Uint8Array): string {
26
+ const psbt = parsePsbt(signedPsbt)
27
+ const finals = psbt.inputs.map((input, index) =>
28
+ finalizeInput(input, index, psbt.tx.inputs[index]!.prevIndex)
29
+ )
30
+ return bytesToHex(serializeTransaction(psbt.tx, finals))
31
+ }
32
+
33
+ /**
34
+ * The txid of a raw transaction (hex, legacy or segwit serialization): the
35
+ * double SHA-256 of its witness-stripped serialization, byte-reversed.
36
+ *
37
+ * Lets a broadcaster know the txid before the network answers, so a lost
38
+ * response or an "already known" rejection can still be tied to the payment.
39
+ */
40
+ export async function computeTxid(rawTxHex: string): Promise<string> {
41
+ const tx = parseTransaction(hexToBytes(rawTxHex))
42
+ const stripped = serializeTransaction(
43
+ tx,
44
+ tx.inputs.map(input => ({ scriptSig: input.scriptSig, witness: [] }))
45
+ )
46
+ const once = await crypto.subtle.digest('SHA-256', stripped)
47
+ const twice = new Uint8Array(await crypto.subtle.digest('SHA-256', once))
48
+ return bytesToHex(twice.reverse())
49
+ }
50
+
51
+ // BIP-174 key types used here.
52
+ const GLOBAL_UNSIGNED_TX = 0x00
53
+ const GLOBAL_VERSION = 0xfb
54
+ const IN_NON_WITNESS_UTXO = 0x00
55
+ const IN_WITNESS_UTXO = 0x01
56
+ const IN_PARTIAL_SIG = 0x02
57
+ const IN_REDEEM_SCRIPT = 0x04
58
+ const IN_FINAL_SCRIPTSIG = 0x07
59
+ const IN_FINAL_SCRIPTWITNESS = 0x08
60
+ const IN_TAP_KEY_SIG = 0x13
61
+
62
+ const PSBT_MAGIC = [0x70, 0x73, 0x62, 0x74, 0xff]
63
+
64
+ interface TxInput {
65
+ /** Serialized outpoint (32-byte txid LE + 4-byte index LE). */
66
+ outpoint: Uint8Array
67
+ prevIndex: number
68
+ scriptSig: Uint8Array
69
+ sequence: Uint8Array
70
+ }
71
+
72
+ interface Transaction {
73
+ version: Uint8Array
74
+ inputs: TxInput[]
75
+ outputs: Uint8Array[]
76
+ locktime: Uint8Array
77
+ }
78
+
79
+ interface KeyValue {
80
+ type: number
81
+ keyData: Uint8Array
82
+ value: Uint8Array
83
+ }
84
+
85
+ interface ParsedPsbt {
86
+ tx: Transaction
87
+ inputs: KeyValue[][]
88
+ }
89
+
90
+ interface FinalInput {
91
+ scriptSig: Uint8Array
92
+ witness: Uint8Array[]
93
+ }
94
+
95
+ function fail(message: string): never {
96
+ throw new WalletConnectorError({
97
+ type: 'unknown',
98
+ message: `finalizePsbt: ${message}`
99
+ })
100
+ }
101
+
102
+ class Reader {
103
+ private offset = 0
104
+
105
+ constructor(private readonly bytes: Uint8Array) {}
106
+
107
+ take(length: number): Uint8Array {
108
+ if (this.offset + length > this.bytes.length) fail('truncated data')
109
+ const slice = this.bytes.subarray(this.offset, this.offset + length)
110
+ this.offset += length
111
+ return slice
112
+ }
113
+
114
+ byte(): number {
115
+ return this.take(1)[0]!
116
+ }
117
+
118
+ uint32(): number {
119
+ const b = this.take(4)
120
+ return (b[0]! | (b[1]! << 8) | (b[2]! << 16) | (b[3]! << 24)) >>> 0
121
+ }
122
+
123
+ compactSize(): number {
124
+ const first = this.byte()
125
+ if (first < 0xfd) return first
126
+ if (first === 0xfd) {
127
+ const b = this.take(2)
128
+ return b[0]! | (b[1]! << 8)
129
+ }
130
+ if (first === 0xfe) return this.uint32()
131
+ const low = this.uint32()
132
+ const high = this.uint32()
133
+ // No PSBT field legitimately approaches 2^53; anything that large is corrupt.
134
+ if (high > 0x1fffff) fail('length out of range')
135
+ return high * 0x100000000 + low
136
+ }
137
+
138
+ varBytes(): Uint8Array {
139
+ return this.take(this.compactSize())
140
+ }
141
+ }
142
+
143
+ function parsePsbt(bytes: Uint8Array): ParsedPsbt {
144
+ const reader = new Reader(bytes)
145
+ if (!PSBT_MAGIC.every(b => reader.byte() === b)) fail('not a PSBT')
146
+
147
+ const global = readMap(reader)
148
+ const version = global.find(kv => kv.type === GLOBAL_VERSION)
149
+ if (version && new Reader(version.value).uint32() !== 0) {
150
+ fail('only PSBT version 0 is supported')
151
+ }
152
+ const unsignedTx = global.find(kv => kv.type === GLOBAL_UNSIGNED_TX)
153
+ if (!unsignedTx) fail('missing unsigned transaction')
154
+ const tx = parseTransaction(unsignedTx.value)
155
+
156
+ const inputs = tx.inputs.map(() => readMap(reader))
157
+ // Output maps carry nothing extraction needs, but must be present — a PSBT
158
+ // cut short there is malformed and should not be trusted.
159
+ for (let i = 0; i < tx.outputs.length; i++) readMap(reader)
160
+ return { tx, inputs }
161
+ }
162
+
163
+ function readMap(reader: Reader): KeyValue[] {
164
+ const map: KeyValue[] = []
165
+ for (;;) {
166
+ const key = reader.varBytes()
167
+ if (key.length === 0) return map
168
+ map.push({
169
+ type: key[0]!,
170
+ keyData: key.subarray(1),
171
+ value: reader.varBytes()
172
+ })
173
+ }
174
+ }
175
+
176
+ /** Parse a transaction in either legacy or segwit serialization. */
177
+ function parseTransaction(bytes: Uint8Array): Transaction {
178
+ const reader = new Reader(bytes)
179
+ const version = reader.take(4)
180
+ let inputCount = reader.compactSize()
181
+ const segwit = inputCount === 0
182
+ if (segwit) {
183
+ if (reader.byte() !== 0x01) fail('invalid segwit flag')
184
+ inputCount = reader.compactSize()
185
+ }
186
+ const inputs: TxInput[] = []
187
+ for (let i = 0; i < inputCount; i++) {
188
+ const outpoint = reader.take(36)
189
+ inputs.push({
190
+ outpoint,
191
+ prevIndex: new Reader(outpoint.subarray(32)).uint32(),
192
+ scriptSig: reader.varBytes(),
193
+ sequence: reader.take(4)
194
+ })
195
+ }
196
+ const outputCount = reader.compactSize()
197
+ const outputs: Uint8Array[] = []
198
+ for (let i = 0; i < outputCount; i++) {
199
+ const value = reader.take(8)
200
+ const script = reader.varBytes()
201
+ outputs.push(concat([value, compactSize(script.length), script]))
202
+ }
203
+ if (segwit) {
204
+ for (let i = 0; i < inputCount; i++) {
205
+ const items = reader.compactSize()
206
+ for (let j = 0; j < items; j++) reader.varBytes()
207
+ }
208
+ }
209
+ return { version, inputs, outputs, locktime: reader.take(4) }
210
+ }
211
+
212
+ function finalizeInput(
213
+ input: KeyValue[],
214
+ index: number,
215
+ prevIndex: number
216
+ ): FinalInput {
217
+ const get = (type: number) => input.find(kv => kv.type === type)?.value
218
+
219
+ const finalScriptSig = get(IN_FINAL_SCRIPTSIG)
220
+ const finalWitness = get(IN_FINAL_SCRIPTWITNESS)
221
+ if (finalScriptSig || finalWitness) {
222
+ return {
223
+ scriptSig: finalScriptSig ?? new Uint8Array(),
224
+ witness: finalWitness ? parseWitness(finalWitness) : []
225
+ }
226
+ }
227
+
228
+ const script = prevoutScript(input, index, prevIndex)
229
+ const partialSigs = input.filter(kv => kv.type === IN_PARTIAL_SIG)
230
+ const singleSig = (): [Uint8Array, Uint8Array] => {
231
+ if (partialSigs.length === 0) fail(`input ${index} is not signed`)
232
+ if (partialSigs.length > 1) {
233
+ fail(
234
+ `input ${index} has ${partialSigs.length} signatures; only single-key spends are supported`
235
+ )
236
+ }
237
+ const { value: signature, keyData: pubkey } = partialSigs[0]!
238
+ return [signature, pubkey]
239
+ }
240
+
241
+ if (isP2wpkh(script)) {
242
+ return { scriptSig: new Uint8Array(), witness: singleSig() }
243
+ }
244
+ if (isP2tr(script)) {
245
+ const tapKeySig = get(IN_TAP_KEY_SIG)
246
+ if (!tapKeySig) {
247
+ fail(
248
+ `input ${index} has no Taproot key-path signature; script-path spends are not supported`
249
+ )
250
+ }
251
+ return { scriptSig: new Uint8Array(), witness: [tapKeySig] }
252
+ }
253
+ if (isP2pkh(script)) {
254
+ const [signature, pubkey] = singleSig()
255
+ return {
256
+ scriptSig: concat([pushData(signature), pushData(pubkey)]),
257
+ witness: []
258
+ }
259
+ }
260
+ if (isP2sh(script)) {
261
+ const redeemScript = get(IN_REDEEM_SCRIPT)
262
+ if (!redeemScript || !isP2wpkh(redeemScript)) {
263
+ fail(`input ${index} is P2SH but not P2SH-P2WPKH; unsupported`)
264
+ }
265
+ return { scriptSig: pushData(redeemScript), witness: singleSig() }
266
+ }
267
+ fail(`input ${index} has an unsupported script type`)
268
+ }
269
+
270
+ /** The scriptPubKey this input spends, from whichever UTXO field is present. */
271
+ function prevoutScript(
272
+ input: KeyValue[],
273
+ index: number,
274
+ prevIndex: number
275
+ ): Uint8Array {
276
+ const witnessUtxo = input.find(kv => kv.type === IN_WITNESS_UTXO)?.value
277
+ if (witnessUtxo) {
278
+ const reader = new Reader(witnessUtxo)
279
+ reader.take(8) // amount
280
+ return reader.varBytes()
281
+ }
282
+ const nonWitnessUtxo = input.find(
283
+ kv => kv.type === IN_NON_WITNESS_UTXO
284
+ )?.value
285
+ if (nonWitnessUtxo) {
286
+ const output = parseTransaction(nonWitnessUtxo).outputs[prevIndex]
287
+ if (!output) fail(`input ${index} spends an output its UTXO does not have`)
288
+ const reader = new Reader(output)
289
+ reader.take(8)
290
+ return reader.varBytes()
291
+ }
292
+ fail(`input ${index} has no UTXO information`)
293
+ }
294
+
295
+ function parseWitness(bytes: Uint8Array): Uint8Array[] {
296
+ const reader = new Reader(bytes)
297
+ const items: Uint8Array[] = []
298
+ const count = reader.compactSize()
299
+ for (let i = 0; i < count; i++) items.push(reader.varBytes())
300
+ return items
301
+ }
302
+
303
+ const isP2wpkh = (s: Uint8Array) =>
304
+ s.length === 22 && s[0] === 0x00 && s[1] === 0x14
305
+ const isP2tr = (s: Uint8Array) =>
306
+ s.length === 34 && s[0] === 0x51 && s[1] === 0x20
307
+ const isP2pkh = (s: Uint8Array) =>
308
+ s.length === 25 &&
309
+ s[0] === 0x76 &&
310
+ s[1] === 0xa9 &&
311
+ s[2] === 0x14 &&
312
+ s[23] === 0x88 &&
313
+ s[24] === 0xac
314
+ const isP2sh = (s: Uint8Array) =>
315
+ s.length === 23 && s[0] === 0xa9 && s[1] === 0x14 && s[22] === 0x87
316
+
317
+ function serializeTransaction(
318
+ tx: Transaction,
319
+ finals: FinalInput[]
320
+ ): Uint8Array<ArrayBuffer> {
321
+ const segwit = finals.some(f => f.witness.length > 0)
322
+ const parts: Uint8Array[] = [tx.version]
323
+ if (segwit) parts.push(Uint8Array.of(0x00, 0x01))
324
+ parts.push(compactSize(tx.inputs.length))
325
+ tx.inputs.forEach((input, i) => {
326
+ const { scriptSig } = finals[i]!
327
+ parts.push(
328
+ input.outpoint,
329
+ compactSize(scriptSig.length),
330
+ scriptSig,
331
+ input.sequence
332
+ )
333
+ })
334
+ parts.push(compactSize(tx.outputs.length), ...tx.outputs)
335
+ if (segwit) {
336
+ for (const { witness } of finals) {
337
+ parts.push(compactSize(witness.length))
338
+ for (const item of witness) parts.push(compactSize(item.length), item)
339
+ }
340
+ }
341
+ parts.push(tx.locktime)
342
+ return concat(parts)
343
+ }
344
+
345
+ /** Minimal script push for data up to 255 bytes (signatures, keys, redeem scripts). */
346
+ function pushData(data: Uint8Array): Uint8Array {
347
+ if (data.length <= 75) return concat([Uint8Array.of(data.length), data])
348
+ if (data.length <= 0xff)
349
+ return concat([Uint8Array.of(0x4c, data.length), data])
350
+ fail('script push too large')
351
+ }
352
+
353
+ function compactSize(n: number): Uint8Array {
354
+ if (n < 0xfd) return Uint8Array.of(n)
355
+ if (n <= 0xffff) return Uint8Array.of(0xfd, n & 0xff, n >> 8)
356
+ return Uint8Array.of(
357
+ 0xfe,
358
+ n & 0xff,
359
+ (n >> 8) & 0xff,
360
+ (n >> 16) & 0xff,
361
+ n >>> 24
362
+ )
363
+ }
364
+
365
+ function concat(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
366
+ const out = new Uint8Array(parts.reduce((sum, p) => sum + p.length, 0))
367
+ let offset = 0
368
+ for (const part of parts) {
369
+ out.set(part, offset)
370
+ offset += part.length
371
+ }
372
+ return out
373
+ }