@meshconnect/uwc-bitcoin 0.4.2 → 0.4.3-snapshot.2fa10fc

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.
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest'
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
2
  import type {
3
3
  BitcoinAccount,
4
4
  BitcoinSignMessageParams,
@@ -207,6 +207,70 @@ describe('BitcoinNamespace', () => {
207
207
  expect(result).toBe('c2lnbmVk')
208
208
  })
209
209
 
210
+ describe('signAndSendPsbt', () => {
211
+ // Wallet-signed, already-finalized P2WPKH PSBT and the tx it extracts to
212
+ // (bitcoinjs-lib reference output; full coverage lives in psbt.test.ts).
213
+ const SIGNED =
214
+ 'cHNidP8BAHECAAAAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABAR+AOAEAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDAQhrAkcwRAIgb6l7rI+g7Z79v+ZrhhfyHJQdQE5d92QS85f76ysSLqwCIBDgQmiFXUfEN79dj4CMid5jIiIWebHuQnREy+jWfC3vASEDG4TFVnsSZECZXT7VqroFZdceGDRgSBn/nBf16dXdB48AAAA='
215
+ const RAW_TX =
216
+ '0200000000010101010101010101010101010101010101010101010101010101010101010101010000000000fdffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b2359830247304402206fa97bac8fa0ed9efdbfe66b8617f21c941d404e5df76412f397fbeb2b122eac022010e04268855d47c437bf5d8f808c89de6322221679b1ee427444cbe8d67c2def0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f50f80c00'
217
+ const TXID =
218
+ '49274930c45612befb7a6f0b9d73e19e81322e6aefa8effc764cf6177d21f126'
219
+ const request = { from: 'bc1qexampleaddress', psbt: 'cHNidA==' }
220
+
221
+ afterEach(() => {
222
+ vi.unstubAllGlobals()
223
+ })
224
+
225
+ it('signs, finalizes, and broadcasts to the given endpoint, returning the txid', async () => {
226
+ const fetchMock = vi
227
+ .fn()
228
+ .mockResolvedValue(new Response(TXID, { status: 200 }))
229
+ vi.stubGlobal('fetch', fetchMock)
230
+ const adapter = {
231
+ ...fakeAdapter(),
232
+ signPsbt: vi.fn().mockResolvedValue(SIGNED)
233
+ }
234
+
235
+ const txid = await new BitcoinNamespace(adapter).signAndSendPsbt(
236
+ request,
237
+ 'https://esplora.example/api'
238
+ )
239
+
240
+ expect(adapter.signPsbt).toHaveBeenCalledWith(request)
241
+ expect(fetchMock).toHaveBeenCalledWith(
242
+ 'https://esplora.example/api/tx',
243
+ expect.objectContaining({ body: RAW_TX })
244
+ )
245
+ expect(txid).toBe(TXID)
246
+ })
247
+
248
+ it('does not broadcast when the wallet rejects signing', async () => {
249
+ const fetchMock = vi.fn()
250
+ vi.stubGlobal('fetch', fetchMock)
251
+ const adapter = {
252
+ ...fakeAdapter(),
253
+ signPsbt: vi.fn().mockRejectedValue(new Error('User rejected'))
254
+ }
255
+
256
+ await expect(
257
+ new BitcoinNamespace(adapter).signAndSendPsbt(request)
258
+ ).rejects.toThrow('User rejected')
259
+ expect(fetchMock).not.toHaveBeenCalled()
260
+ })
261
+
262
+ it('does not broadcast a PSBT that cannot be finalized', async () => {
263
+ const fetchMock = vi.fn()
264
+ vi.stubGlobal('fetch', fetchMock)
265
+ const adapter = fakeAdapter() // returns a non-PSBT string
266
+
267
+ await expect(
268
+ new BitcoinNamespace(adapter).signAndSendPsbt(request)
269
+ ).rejects.toBeInstanceOf(WalletConnectorError)
270
+ expect(fetchMock).not.toHaveBeenCalled()
271
+ })
272
+ })
273
+
210
274
  it('delegates sendTransfer to the adapter', async () => {
211
275
  const adapter = fakeAdapter()
212
276
  const request = { from: 'bc1qexampleaddress', to: 'bc1qdest', amount: 1000 }
@@ -11,6 +11,9 @@ import type {
11
11
  import { bitcoinNetwork } from '@meshconnect/uwc-constants'
12
12
  import { WalletConnectorError } from '@meshconnect/uwc-types'
13
13
  import type { BitcoinTransportAdapter } from './adapter'
14
+ import { broadcastTransaction } from './broadcast'
15
+ import { base64ToBytes } from './encoding'
16
+ import { finalizePsbt } from './psbt'
14
17
 
15
18
  /** CAIP-2 namespace this module serves. */
16
19
  export const BITCOIN_NAMESPACE: Namespace = 'bip122'
@@ -51,10 +54,11 @@ export function assertBitcoinNetwork(network: Network, method: string): void {
51
54
  * was constructed with — the transport builds that adapter bound to the live
52
55
  * session.
53
56
  *
54
- * Phase 2 exposes the grounded verify-flow operations (account enumeration and
55
- * message signing). PSBT signing (Path B) plus finalize/broadcast, and the
56
- * high-level `sendTransfer` (Path A), land in Phase 3 alongside their concrete
57
- * adapters.
57
+ * Sending is Mesh-broadcast: the wallet only signs a Mesh-built PSBT, and this
58
+ * module finalizes and broadcasts it ({@link signAndSendPsbt}). Wallet-side
59
+ * broadcast can't be the primary path — framed Link's bridge relays only
60
+ * `bitcoin:signTransaction`, and some wallets (Bitget) expose no working
61
+ * broadcast at all.
58
62
  *
59
63
  * Phased build plan:
60
64
  * https://app.notion.com/p/3a5f862e950a8187b3d3f0e255d13f45
@@ -90,6 +94,20 @@ export class BitcoinNamespace {
90
94
  return this.adapter.signPsbt(request)
91
95
  }
92
96
 
97
+ /**
98
+ * Sign a PSBT with the wallet, finalize it, and broadcast the result through
99
+ * the Esplora API at `broadcastUrl` (mempool.space when omitted). Returns the
100
+ * txid — the same shape every other namespace returns from a send.
101
+ */
102
+ async signAndSendPsbt(
103
+ request: BitcoinPsbtSignRequest,
104
+ broadcastUrl?: string
105
+ ): Promise<TransactionResult> {
106
+ const signed = await this.signPsbt(request)
107
+ const rawTx = finalizePsbt(base64ToBytes(signed))
108
+ return broadcastTransaction(rawTx, broadcastUrl)
109
+ }
110
+
93
111
  /**
94
112
  * High-level transfer (Path A): the wallet signs and broadcasts, returning a
95
113
  * txid. Rejects if the active adapter's transport does not offer it.
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { WalletConnectorError } from '@meshconnect/uwc-types'
3
+ import { broadcastTransaction } from './broadcast'
4
+
5
+ // P2WPKH raw tx and its txid (bitcoinjs-lib reference, see psbt.test.ts).
6
+ const RAW_TX =
7
+ '0200000000010101010101010101010101010101010101010101010101010101010101010101010000000000fdffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b2359830247304402206fa97bac8fa0ed9efdbfe66b8617f21c941d404e5df76412f397fbeb2b122eac022010e04268855d47c437bf5d8f808c89de6322221679b1ee427444cbe8d67c2def0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f50f80c00'
8
+ const TXID = '49274930c45612befb7a6f0b9d73e19e81322e6aefa8effc764cf6177d21f126'
9
+
10
+ function stubFetch(body: string, status = 200) {
11
+ const fetchMock = vi.fn().mockResolvedValue(new Response(body, { status }))
12
+ vi.stubGlobal('fetch', fetchMock)
13
+ return fetchMock
14
+ }
15
+
16
+ describe('broadcastTransaction', () => {
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals()
19
+ })
20
+
21
+ it('POSTs the raw hex to mempool.space by default and returns the txid', async () => {
22
+ const fetchMock = stubFetch(TXID)
23
+
24
+ await expect(broadcastTransaction(RAW_TX)).resolves.toBe(TXID)
25
+ expect(fetchMock).toHaveBeenCalledWith(
26
+ 'https://mempool.space/api/tx',
27
+ expect.objectContaining({
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'text/plain' },
30
+ body: RAW_TX,
31
+ signal: expect.any(AbortSignal)
32
+ })
33
+ )
34
+ })
35
+
36
+ it('uses a custom Esplora base URL, tolerating a trailing slash', async () => {
37
+ const fetchMock = stubFetch(TXID)
38
+
39
+ await broadcastTransaction(RAW_TX, 'https://esplora.example/api/')
40
+ expect(fetchMock).toHaveBeenCalledWith(
41
+ 'https://esplora.example/api/tx',
42
+ expect.anything()
43
+ )
44
+ })
45
+
46
+ it('accepts the txid with whitespace or upper case', async () => {
47
+ stubFetch(`${TXID.toUpperCase()}\n`)
48
+
49
+ await expect(broadcastTransaction(RAW_TX)).resolves.toBe(TXID)
50
+ })
51
+
52
+ it('throws with the API error message on a non-2xx response', async () => {
53
+ stubFetch(
54
+ 'sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}',
55
+ 400
56
+ )
57
+
58
+ const error = await broadcastTransaction(RAW_TX).catch(e => e)
59
+ expect(error).toBeInstanceOf(WalletConnectorError)
60
+ expect(error.message).toContain('(400)')
61
+ expect(error.message).toContain('min relay fee not met')
62
+ })
63
+
64
+ // A retry after a lost response: the network already holds this exact tx.
65
+ it.each([
66
+ 'sendrawtransaction RPC error: {"code":-27,"message":"Transaction already in block chain"}',
67
+ 'sendrawtransaction RPC error: {"code":-27,"message":"Transaction outputs already in utxo set"}',
68
+ 'sendrawtransaction RPC error: {"code":-26,"message":"txn-already-known"}',
69
+ 'sendrawtransaction RPC error: {"code":-26,"message":"txn-already-in-mempool"}'
70
+ ])(
71
+ 'resolves with the local txid when the tx is already broadcast: %s',
72
+ async body => {
73
+ stubFetch(body, 400)
74
+
75
+ await expect(broadcastTransaction(RAW_TX)).resolves.toBe(TXID)
76
+ }
77
+ )
78
+
79
+ it('throws on a 2xx whose body is not this txid', async () => {
80
+ stubFetch('<html>maintenance</html>')
81
+
82
+ await expect(broadcastTransaction(RAW_TX)).rejects.toThrow(
83
+ `expected txid ${TXID}`
84
+ )
85
+ })
86
+
87
+ it('throws on a 2xx carrying a different txid', async () => {
88
+ stubFetch('ff'.repeat(32))
89
+
90
+ await expect(broadcastTransaction(RAW_TX)).rejects.toThrow(
91
+ WalletConnectorError
92
+ )
93
+ })
94
+
95
+ it('names the txid when a network failure leaves the outcome unknown', async () => {
96
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline')))
97
+
98
+ const error = await broadcastTransaction(RAW_TX).catch(e => e)
99
+ expect(error).toBeInstanceOf(WalletConnectorError)
100
+ expect(error.message).toContain('outcome unknown')
101
+ expect(error.message).toContain(TXID)
102
+ expect(error.message).toContain('offline')
103
+ })
104
+
105
+ // Timing out after signing must not read as a failed payment: the tx may
106
+ // already be on the network, so the error has to carry the txid.
107
+ it('names the txid when the 30s timeout aborts the request', async () => {
108
+ const controller = new AbortController()
109
+ const timeoutSpy = vi
110
+ .spyOn(AbortSignal, 'timeout')
111
+ .mockReturnValue(controller.signal)
112
+ vi.stubGlobal(
113
+ 'fetch',
114
+ vi.fn(
115
+ (_url: string, init: RequestInit) =>
116
+ new Promise((_resolve, reject) => {
117
+ // Like real fetch: reject at once if the signal is already aborted.
118
+ if (init.signal?.aborted) reject(init.signal.reason)
119
+ init.signal?.addEventListener('abort', () =>
120
+ reject(init.signal?.reason)
121
+ )
122
+ })
123
+ )
124
+ )
125
+
126
+ const pending = broadcastTransaction(RAW_TX).catch(e => e)
127
+ controller.abort(new DOMException('signal timed out', 'TimeoutError'))
128
+ const error = await pending
129
+
130
+ expect(timeoutSpy).toHaveBeenCalledWith(30_000)
131
+ expect(error).toBeInstanceOf(WalletConnectorError)
132
+ expect(error.message).toContain('outcome unknown')
133
+ expect(error.message).toContain(TXID)
134
+ expect(error.message).toContain('signal timed out')
135
+ timeoutSpy.mockRestore()
136
+ })
137
+ })
@@ -0,0 +1,75 @@
1
+ import { WalletConnectorError } from '@meshconnect/uwc-types'
2
+ import { computeTxid } from './psbt'
3
+
4
+ /** mempool.space's Esplora API for Bitcoin mainnet. */
5
+ export const DEFAULT_BITCOIN_BROADCAST_URL = 'https://mempool.space/api'
6
+
7
+ const BROADCAST_TIMEOUT_MS = 30_000
8
+
9
+ /**
10
+ * Bitcoin Core rejection reasons meaning the network already has this exact
11
+ * transaction — e.g. a retry after a lost response. Same txid, so it succeeded.
12
+ * A confirmed tx reads "already in block chain" before Core 24 and "outputs
13
+ * already in utxo set" from 24 on; nodes behind an Esplora host vary.
14
+ */
15
+ const ALREADY_BROADCAST =
16
+ /txn-already-known|txn-already-in-mempool|already in block ?chain|outputs already in utxo set/i
17
+
18
+ /**
19
+ * Broadcast a raw transaction through an Esplora-compatible REST API
20
+ * (`POST {baseUrl}/tx`, raw hex body, txid as the plain-text response) and
21
+ * return the txid.
22
+ *
23
+ * `baseUrl` is any Esplora host — mempool.space by default, or a proxy with
24
+ * the same contract — so moving broadcast behind a Mesh endpoint is a config
25
+ * change, not a code change.
26
+ *
27
+ * The txid is computed locally first, because a thrown broadcast is not proof
28
+ * the payment failed:
29
+ * - "already known" means an earlier attempt landed, so it resolves with the
30
+ * txid instead of reporting a payment that went through as failed;
31
+ * - a lost response or timeout leaves the outcome unknown, so the error names
32
+ * the txid for the caller to reconcile against the chain;
33
+ * - a response that isn't this txid throws rather than being recorded as one.
34
+ *
35
+ * Any other non-2xx throws with the API's own message (e.g. `min relay fee not
36
+ * met`): the payment did not happen, and the reason is what makes it actionable.
37
+ */
38
+ export async function broadcastTransaction(
39
+ rawTxHex: string,
40
+ baseUrl: string = DEFAULT_BITCOIN_BROADCAST_URL
41
+ ): Promise<string> {
42
+ const txid = await computeTxid(rawTxHex)
43
+
44
+ let response: Response
45
+ let body: string
46
+ try {
47
+ response = await fetch(`${baseUrl.replace(/\/+$/, '')}/tx`, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'text/plain' },
50
+ body: rawTxHex,
51
+ signal: AbortSignal.timeout(BROADCAST_TIMEOUT_MS)
52
+ })
53
+ body = (await response.text()).trim()
54
+ } catch (error) {
55
+ throw new WalletConnectorError({
56
+ type: 'unknown',
57
+ message: `Bitcoin broadcast outcome unknown for txid ${txid}: ${error instanceof Error ? error.message : String(error)}`
58
+ })
59
+ }
60
+
61
+ if (!response.ok) {
62
+ if (ALREADY_BROADCAST.test(body)) return txid
63
+ throw new WalletConnectorError({
64
+ type: 'unknown',
65
+ message: `Bitcoin broadcast failed (${response.status}): ${body || response.statusText}`
66
+ })
67
+ }
68
+ if (body.toLowerCase() !== txid) {
69
+ throw new WalletConnectorError({
70
+ type: 'unknown',
71
+ message: `Bitcoin broadcast returned ${body.slice(0, 200) || 'an empty response'}, expected txid ${txid}`
72
+ })
73
+ }
74
+ return txid
75
+ }
package/src/index.ts CHANGED
@@ -11,4 +11,14 @@ export type {
11
11
  } from './adapters/wallet-standard'
12
12
  export { OkxBitcoinAdapter } from './adapters/okx'
13
13
  export type { OkxBitcoinProvider } from './adapters/okx'
14
- export { bytesToHex, hexToBytes } from './encoding'
14
+ export {
15
+ bytesToHex,
16
+ hexToBytes,
17
+ base64ToBytes,
18
+ bytesToBase64
19
+ } from './encoding'
20
+ export { finalizePsbt, computeTxid } from './psbt'
21
+ export {
22
+ broadcastTransaction,
23
+ DEFAULT_BITCOIN_BROADCAST_URL
24
+ } from './broadcast'
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { WalletConnectorError } from '@meshconnect/uwc-types'
3
+ import { computeTxid, finalizePsbt } from './psbt'
4
+ import { base64ToBytes } from './encoding'
5
+
6
+ // Generated once with bitcoinjs-lib 6 (finalizeAllInputs + extractTransaction)
7
+ // from deterministic test keys — the reference output this finalizer must match.
8
+ const FIXTURES = {
9
+ p2wpkh: {
10
+ signed:
11
+ 'cHNidP8BAHECAAAAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABAR+AOAEAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDIgIDG4TFVnsSZECZXT7VqroFZdceGDRgSBn/nBf16dXdB49HMEQCIG+pe6yPoO2e/b/ma4YX8hyUHUBOXfdkEvOX++srEi6sAiAQ4EJohV1HxDe/XY+AjIneYyIiFnmx7kJ0RMvo1nwt7wEAAAA=',
12
+ finalized:
13
+ 'cHNidP8BAHECAAAAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABAR+AOAEAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDAQhrAkcwRAIgb6l7rI+g7Z79v+ZrhhfyHJQdQE5d92QS85f76ysSLqwCIBDgQmiFXUfEN79dj4CMid5jIiIWebHuQnREy+jWfC3vASEDG4TFVnsSZECZXT7VqroFZdceGDRgSBn/nBf16dXdB48AAAA=',
14
+ rawTx:
15
+ '0200000000010101010101010101010101010101010101010101010101010101010101010101010000000000fdffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b2359830247304402206fa97bac8fa0ed9efdbfe66b8617f21c941d404e5df76412f397fbeb2b122eac022010e04268855d47c437bf5d8f808c89de6322221679b1ee427444cbe8d67c2def0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f50f80c00',
16
+ txid: '49274930c45612befb7a6f0b9d73e19e81322e6aefa8effc764cf6177d21f126'
17
+ },
18
+ p2tr: {
19
+ signed:
20
+ 'cHNidP8BAHECAAAAAQICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAQAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABASuAOAEAAAAAACJRIKZ0wrFSo4MSax4qOgaD6wek1laJg6c9+ybewqep+wZDARNAIr2m4yiSX+7SBh7qguPdpLdemKfqEvKbVVFtzKPC7TE+NHCTqHQ7ODSaZISBp33swcaDcf0sIjzami69OMCgtQEXIE1LbNE2EDLKm9KuudkAqk1F2erYCslCM3TEUaclTQdmAAAA',
21
+ finalized:
22
+ 'cHNidP8BAHECAAAAAQICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAQAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABASuAOAEAAAAAACJRIKZ0wrFSo4MSax4qOgaD6wek1laJg6c9+ybewqep+wZDAQhCAUAivabjKJJf7tIGHuqC492kt16Yp+oS8ptVUW3Mo8LtMT40cJOodDs4NJpkhIGnfezBxoNx/SwiPNqaLr04wKC1AAAA',
23
+ rawTx:
24
+ '0200000000010102020202020202020202020202020202020202020202020202020202020202020100000000fdffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b235983014022bda6e328925feed2061eea82e3dda4b75e98a7ea12f29b55516dcca3c2ed313e347093a8743b38349a648481a77decc1c68371fd2c223cda9a2ebd38c0a0b550f80c00',
25
+ txid: 'a38a1578f26624da7b3e2d4f60f45b95eac701475e30ebbfbc241fea7d854c09'
26
+ },
27
+ p2pkh: {
28
+ signed:
29
+ 'cHNidP8BAHECAAAAAfJ1cvry7cFk+E7symofVIqxUXjtwRT5QTErVGi6Vl2pAAAAAAD/////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABAFUCAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAD/////AYA4AQAAAAAAGXapFEF9S+kNNTYyZ7jyr6/JUxERxBrkiKwAAAAAIgICUx/mBoE0UD0nIxMyJ8hnrI+myDxTfppEw8W9vcsf4zdHMEQCICO2869Iqe86cT9EmLXZiWp5I5XtnEoghepLEBmvti9qAiB7gWZU0DVeJSA4T+0uDnPQzZDjtgi6FT0NKPuPKDz/VgEAAAA=',
30
+ finalized:
31
+ 'cHNidP8BAHECAAAAAfJ1cvry7cFk+E7symofVIqxUXjtwRT5QTErVGi6Vl2pAAAAAAD/////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABAFUCAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAD/////AYA4AQAAAAAAGXapFEF9S+kNNTYyZ7jyr6/JUxERxBrkiKwAAAAAAQdqRzBEAiAjtvOvSKnvOnE/RJi12YlqeSOV7ZxKIIXqSxAZr7YvagIge4FmVNA1XiUgOE/tLg5z0M2Q47YIuhU9DSj7jyg8/1YBIQJTH+YGgTRQPScjEzInyGesj6bIPFN+mkTDxb29yx/jNwAAAA==',
32
+ rawTx:
33
+ '0200000001f27572faf2edc164f84eecca6a1f548ab15178edc114f941312b5468ba565da9000000006a473044022023b6f3af48a9ef3a713f4498b5d9896a792395ed9c4a2085ea4b1019afb62f6a02207b816654d0355e2520384fed2e0e73d0cd90e3b608ba153d0d28fb8f283cff56012102531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ffffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b23598350f80c00',
34
+ txid: 'ae8e9b1f06212108c68cdeab38317301f89863e2ae7154ecf719b7a54591f7dd'
35
+ },
36
+ 'p2sh-p2wpkh': {
37
+ signed:
38
+ 'cHNidP8BAHECAAAAAQQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAgAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABASCAOAEAAAAAABepFEoatoZPIlugnDDXf3ql2fVQWAH7hyICA0Ynea1KrTlRRhR1GnEIXy8Q4celk+TgMO+1uHIc5VsLSDBFAiEAmKvSJoND+fkDu5PqlPjnT+x3kWLp4BFZpeXVPvwDyHECIFEtIQrxyBPsmirPem3QIMhu9RPl9UT3EKgMBKVSlnGKAQEEFgAUiI2715mPn4DU+CtPnObViC8xCuwAAAA=',
39
+ finalized:
40
+ 'cHNidP8BAHECAAAAAQQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAgAAAAD9////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABASCAOAEAAAAAABepFEoatoZPIlugnDDXf3ql2fVQWAH7hwEHFxYAFIiNu9eZj5+A1PgrT5zm1YgvMQrsAQhsAkgwRQIhAJir0iaDQ/n5A7uT6pT450/sd5Fi6eARWaXl1T78A8hxAiBRLSEK8cgT7Joqz3pt0CDIbvUT5fVE9xCoDASlUpZxigEhA0Ynea1KrTlRRhR1GnEIXy8Q4celk+TgMO+1uHIc5VsLAAAA',
41
+ rawTx:
42
+ '0200000000010104040404040404040404040404040404040404040404040404040404040404040200000017160014888dbbd7998f9f80d4f82b4f9ce6d5882f310aecfdffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b2359830248304502210098abd2268343f9f903bb93ea94f8e74fec779162e9e01159a5e5d53efc03c8710220512d210af1c813ec9a2acf7a6dd020c86ef513e5f544f710a80c04a55296718a012103462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b50f80c00',
43
+ txid: '20669172342d160723aed87b22b753ce37790a65b621001ccff8fae36a3cc7f9'
44
+ },
45
+ mixed: {
46
+ signed:
47
+ 'cHNidP8BAJoCAAAAAgUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFAAAAAAD9////EXOuCENnqp7HHdzQHK4AG91XoN02XQrgdv+C/AjdBBgAAAAAAP3///8CUMMAAAAAAAAWABRnDKp55R147QxYO4n/OdnEm3GZ50hxAAAAAAAAFgAUebAAiHYmspSpFFAaTNImtYsjWYNQ+AwAAAEBH0CcAAAAAAAAFgAUebAAiHYmspSpFFAaTNImtYsjWYMiAgMbhMVWexJkQJldPtWqugVl1x4YNGBIGf+cF/Xp1d0Hj0gwRQIhAP0OSqBZqIdg68dvrgSimKyawqTSoEdwEDMNiF0htY1OAiAX9Qcf+6FsQ6PAd1a8WAocqKZSc3UY8Z5DkldGlMgq7gEAAQBVAgAAAAGqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqgAAAAAA/////wFAnAAAAAAAABl2qRRBfUvpDTU2Mme48q+vyVMREcQa5IisAAAAACICAlMf5gaBNFA9JyMTMifIZ6yPpsg8U36aRMPFvb3LH+M3SDBFAiEAvSZyO8/TxyX1YlNx/zRKT6M/47IcnP+YaZYrnV14KDMCIAlPeu87PznpRl4RxtzCml1pVZionjFti4Gu9C+h8ZZxAQAAAA==',
48
+ finalized:
49
+ 'cHNidP8BAJoCAAAAAgUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFAAAAAAD9////EXOuCENnqp7HHdzQHK4AG91XoN02XQrgdv+C/AjdBBgAAAAAAP3///8CUMMAAAAAAAAWABRnDKp55R147QxYO4n/OdnEm3GZ50hxAAAAAAAAFgAUebAAiHYmspSpFFAaTNImtYsjWYNQ+AwAAAEBH0CcAAAAAAAAFgAUebAAiHYmspSpFFAaTNImtYsjWYMBCGwCSDBFAiEA/Q5KoFmoh2Drx2+uBKKYrJrCpNKgR3AQMw2IXSG1jU4CIBf1Bx/7oWxDo8B3VrxYChyoplJzdRjxnkOSV0aUyCruASEDG4TFVnsSZECZXT7VqroFZdceGDRgSBn/nBf16dXdB48AAQBVAgAAAAGqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqgAAAAAA/////wFAnAAAAAAAABl2qRRBfUvpDTU2Mme48q+vyVMREcQa5IisAAAAAAEHa0gwRQIhAL0mcjvP08cl9WJTcf80Sk+jP+OyHJz/mGmWK51deCgzAiAJT3rvOz856UZeEcbcwppdaVWYqJ4xbYuBrvQvofGWcQEhAlMf5gaBNFA9JyMTMifIZ6yPpsg8U36aRMPFvb3LH+M3AAAA',
50
+ rawTx:
51
+ '0200000000010205050505050505050505050505050505050505050505050505050505050505050000000000fdffffff1173ae084367aa9ec71ddcd01cae001bdd57a0dd365d0ae076ff82fc08dd0418000000006b483045022100bd26723bcfd3c725f5625371ff344a4fa33fe3b21c9cff9869962b9d5d7828330220094f7aef3b3f39e9465e11c6dcc29a5d695598a89e316d8b81aef42fa1f19671012102531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337fdffffff0250c3000000000000160014670caa79e51d78ed0c583b89ff39d9c49b7199e7487100000000000016001479b000887626b294a914501a4cd226b58b23598302483045022100fd0e4aa059a88760ebc76fae04a298ac9ac2a4d2a0477010330d885d21b58d4e022017f5071ffba16c43a3c07756bc580a1ca8a652737518f19e4392574694c82aee0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0050f80c00',
52
+ txid: '0bc7db69500768465cd8bd0646217eefc0b91ed62bab603318cf4e58c005e99d'
53
+ },
54
+ 'unsigned-p2wpkh': {
55
+ signed:
56
+ 'cHNidP8BAHECAAAAAQYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGAAAAAAD/////AlDDAAAAAAAAFgAUZwyqeeUdeO0MWDuJ/znZxJtxmedIcQAAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDUPgMAAABAR+AOAEAAAAAABYAFHmwAIh2JrKUqRRQGkzSJrWLI1mDAAAA'
57
+ }
58
+ }
59
+
60
+ const SPEND_TYPES = ['p2wpkh', 'p2tr', 'p2pkh', 'p2sh-p2wpkh', 'mixed'] as const
61
+
62
+ describe('finalizePsbt', () => {
63
+ it.each(SPEND_TYPES)(
64
+ 'finalizes a wallet-signed %s PSBT into the reference raw tx',
65
+ name => {
66
+ const fixture = FIXTURES[name]
67
+ expect(finalizePsbt(base64ToBytes(fixture.signed))).toBe(fixture.rawTx)
68
+ }
69
+ )
70
+
71
+ // OKX returns every input already finalized, even with autoFinalized: false.
72
+ it.each(SPEND_TYPES)(
73
+ 'extracts an already-finalized %s PSBT without re-finalizing',
74
+ name => {
75
+ const fixture = FIXTURES[name]
76
+ expect(finalizePsbt(base64ToBytes(fixture.finalized))).toBe(fixture.rawTx)
77
+ }
78
+ )
79
+
80
+ it.each(SPEND_TYPES)(
81
+ 'computes the reference txid for a %s raw tx',
82
+ async name => {
83
+ const fixture = FIXTURES[name]
84
+ await expect(computeTxid(fixture.rawTx)).resolves.toBe(fixture.txid)
85
+ }
86
+ )
87
+
88
+ it('rejects an input the wallet did not sign', () => {
89
+ expect(() =>
90
+ finalizePsbt(base64ToBytes(FIXTURES['unsigned-p2wpkh'].signed))
91
+ ).toThrow('input 0 is not signed')
92
+ })
93
+
94
+ it('rejects bytes that are not a PSBT', () => {
95
+ expect(() => finalizePsbt(Uint8Array.of(1, 2, 3, 4, 5))).toThrow(
96
+ WalletConnectorError
97
+ )
98
+ expect(() => finalizePsbt(Uint8Array.of(1, 2, 3, 4, 5))).toThrow(
99
+ 'not a PSBT'
100
+ )
101
+ })
102
+
103
+ it('rejects a truncated PSBT instead of extracting a partial tx', () => {
104
+ const bytes = base64ToBytes(FIXTURES.p2wpkh.signed)
105
+ expect(() => finalizePsbt(bytes.subarray(0, bytes.length - 10))).toThrow(
106
+ 'truncated data'
107
+ )
108
+ })
109
+
110
+ it('rejects an empty input', () => {
111
+ expect(() => finalizePsbt(new Uint8Array())).toThrow(WalletConnectorError)
112
+ })
113
+ })