@meshconnect/uwc-bitcoin 0.4.1 → 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.
- package/dist/bitcoin-namespace.d.ts +11 -4
- package/dist/bitcoin-namespace.d.ts.map +1 -1
- package/dist/bitcoin-namespace.js +18 -4
- package/dist/bitcoin-namespace.js.map +1 -1
- package/dist/broadcast.d.ts +24 -0
- package/dist/broadcast.d.ts.map +1 -0
- package/dist/broadcast.js +68 -0
- package/dist/broadcast.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/psbt.d.ts +31 -0
- package/dist/psbt.d.ts.map +1 -0
- package/dist/psbt.js +299 -0
- package/dist/psbt.js.map +1 -0
- package/package.json +3 -3
- package/src/bitcoin-namespace.test.ts +65 -1
- package/src/bitcoin-namespace.ts +22 -4
- package/src/broadcast.test.ts +137 -0
- package/src/broadcast.ts +75 -0
- package/src/index.ts +11 -1
- package/src/psbt.test.ts +113 -0
- package/src/psbt.ts +373 -0
|
@@ -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 }
|
package/src/bitcoin-namespace.ts
CHANGED
|
@@ -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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
+
})
|
package/src/broadcast.ts
ADDED
|
@@ -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 {
|
|
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'
|
package/src/psbt.test.ts
ADDED
|
@@ -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
|
+
})
|