@exodus/bitcoin-api 4.2.2 → 4.3.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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [4.3.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.3.0) (2025-11-11)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: add PSBT builder infrastructure (#6822)
13
+
14
+
15
+
6
16
  ## [4.2.2](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.2.2) (2025-11-10)
7
17
 
8
18
  **Note:** Version bump only for package @exodus/bitcoin-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.2.2",
3
+ "version": "4.3.0",
4
4
  "description": "Bitcoin transaction and fee monitors, RPC with the blockchain node, other networking code.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -60,5 +60,5 @@
60
60
  "type": "git",
61
61
  "url": "git+https://github.com/ExodusMovement/assets.git"
62
62
  },
63
- "gitHead": "ba11d8958a6416d939b43f5297930f4069f7daa8"
63
+ "gitHead": "7a2a429c2a182a889e550761d9106da3296815ca"
64
64
  }
@@ -0,0 +1,248 @@
1
+ import { payments, Psbt, Transaction } from '@exodus/bitcoinjs'
2
+ import { publicKeyToX } from '@exodus/crypto/secp256k1'
3
+ import assert from 'minimalistic-assert'
4
+
5
+ import { SubType, writePsbtGlobalField, writePsbtOutputField } from './psbt-proprietary-types.js'
6
+ import { getAddressType, getPurposeXPubs, validatePurpose } from './psbt-utils.js'
7
+
8
+ function canParseTx(rawTxBuffer) {
9
+ try {
10
+ Transaction.fromBuffer(rawTxBuffer)
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ function getWrappedSegwitRedeemScript({ publicKey, address, context = '' }) {
18
+ const p2wpkh = payments.p2wpkh({ pubkey: publicKey })
19
+ const p2sh = payments.p2sh({ redeem: p2wpkh })
20
+
21
+ if (address !== p2sh.address) {
22
+ throw new Error(`Expected P2SH script to be a nested p2wpkh${context ? ' for ' + context : ''}`)
23
+ }
24
+
25
+ return p2sh.redeem.output
26
+ }
27
+
28
+ function addBip32Derivation({ isTaprootAddress, publicKey, path, masterFingerprint }) {
29
+ if (isTaprootAddress) {
30
+ const pubkey = publicKeyToX({ publicKey, format: 'buffer' })
31
+ return {
32
+ tapBip32Derivation: [
33
+ {
34
+ path,
35
+ leafHashes: [],
36
+ masterFingerprint,
37
+ pubkey,
38
+ },
39
+ ],
40
+ tapInternalKey: pubkey,
41
+ }
42
+ }
43
+
44
+ return {
45
+ bip32Derivation: [
46
+ {
47
+ path,
48
+ masterFingerprint,
49
+ pubkey: publicKey,
50
+ },
51
+ ],
52
+ }
53
+ }
54
+
55
+ function writeGlobalMetadata(psbt, metadata) {
56
+ if (metadata.blockHeight !== undefined) {
57
+ writePsbtGlobalField(psbt, SubType.BlockHeight, metadata.blockHeight)
58
+ }
59
+
60
+ if (metadata.rbfEnabled) {
61
+ writePsbtGlobalField(psbt, SubType.RbfEnabled, metadata.rbfEnabled)
62
+ }
63
+
64
+ if (metadata.bumpTxId) {
65
+ writePsbtGlobalField(psbt, SubType.BumpTxId, metadata.bumpTxId)
66
+ }
67
+
68
+ if (metadata.txType) {
69
+ writePsbtGlobalField(psbt, SubType.TxType, metadata.txType)
70
+ }
71
+ }
72
+
73
+ function createPsbtInput({
74
+ input,
75
+ asset,
76
+ addressPathsMap,
77
+ purposeXPubs,
78
+ nonWitnessTxs,
79
+ allowedPurposes,
80
+ }) {
81
+ const psbtInput = {
82
+ hash: input.txId,
83
+ index: input.vout,
84
+ sequence: input.sequence,
85
+ }
86
+
87
+ const purpose = asset.address.resolvePurpose(input.address)
88
+ validatePurpose(purpose, allowedPurposes, `address ${input.address}`)
89
+
90
+ const { isSegwitAddress, isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
91
+
92
+ const path = addressPathsMap[input.address]
93
+ assert(path, `Derivation path is missing for address ${input.address}`)
94
+
95
+ const publicKey = purposeXPubs[purpose].hdkey.derive(path).publicKey
96
+ const masterFingerprint = purposeXPubs[purpose].masterFingerprint
97
+
98
+ if (isWrappedSegwitAddress) {
99
+ psbtInput.redeemScript = getWrappedSegwitRedeemScript({
100
+ publicKey,
101
+ address: input.address,
102
+ context: `input address ${input.address}`,
103
+ })
104
+ }
105
+
106
+ if (isWrappedSegwitAddress || isSegwitAddress || isTaprootAddress) {
107
+ psbtInput.witnessUtxo = {
108
+ value: input.value,
109
+ script: Buffer.from(input.script, 'hex'),
110
+ }
111
+ }
112
+
113
+ if (!isTaprootAddress) {
114
+ const rawTx = (nonWitnessTxs || []).find((t) => t.txId === input.txId)
115
+ assert(
116
+ !!rawTx?.rawData,
117
+ `Non-taproot outputs require the full previous transaction for address ${input.address}`
118
+ )
119
+
120
+ const rawTxBuffer = Buffer.from(rawTx.rawData, 'hex')
121
+ if (canParseTx(rawTxBuffer)) {
122
+ psbtInput.nonWitnessUtxo = rawTxBuffer
123
+ } else {
124
+ // bitcoinjs can’t parse a handful of edge-case transactions (Litecoin MWEB, odd
125
+ // vendor forks, malformed historical data). When that happens we fall back to a
126
+ // witness-only record and rely on the signer to opt-in to __UNSAFE_SIGN_NONSEGWIT.
127
+ psbtInput.witnessUtxo = {
128
+ value: input.value,
129
+ script: Buffer.from(input.script, 'hex'),
130
+ }
131
+ }
132
+ }
133
+
134
+ const derivationData = addBip32Derivation({
135
+ isTaprootAddress,
136
+ publicKey,
137
+ path,
138
+ masterFingerprint,
139
+ })
140
+
141
+ return { ...psbtInput, ...derivationData }
142
+ }
143
+
144
+ function createPsbtOutput({
145
+ address,
146
+ amount,
147
+ asset,
148
+ addressPathsMap,
149
+ purposeXPubs,
150
+ allowedPurposes,
151
+ }) {
152
+ const psbtOutput = {
153
+ address,
154
+ value: amount,
155
+ }
156
+
157
+ const path = addressPathsMap[address]
158
+ if (!path) {
159
+ return psbtOutput
160
+ }
161
+
162
+ const purpose = asset.address.resolvePurpose(address)
163
+ validatePurpose(purpose, allowedPurposes, `output address ${address}`)
164
+
165
+ const { isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
166
+
167
+ const publicKey = purposeXPubs[purpose].hdkey.derive(path).publicKey
168
+ const masterFingerprint = purposeXPubs[purpose].masterFingerprint
169
+
170
+ if (isWrappedSegwitAddress) {
171
+ psbtOutput.redeemScript = getWrappedSegwitRedeemScript({
172
+ publicKey,
173
+ address,
174
+ context: `output address ${address}`,
175
+ })
176
+ }
177
+
178
+ const derivationData = addBip32Derivation({
179
+ isTaprootAddress,
180
+ publicKey,
181
+ path,
182
+ masterFingerprint,
183
+ })
184
+
185
+ return { ...psbtOutput, ...derivationData }
186
+ }
187
+
188
+ export async function createPsbtWithMetadata({
189
+ inputs,
190
+ outputs,
191
+ asset,
192
+ assetClientInterface,
193
+ walletAccount,
194
+ nonWitnessTxs,
195
+ addressPathsMap,
196
+ metadata,
197
+ allowedPurposes,
198
+ }) {
199
+ assert(inputs, 'inputs is required')
200
+ assert(outputs, 'outputs is required')
201
+ assert(asset, 'asset is required')
202
+ assert(assetClientInterface, 'assetClientInterface is required')
203
+ assert(walletAccount, 'walletAccount is required')
204
+ assert(addressPathsMap, 'addressPathsMap is required')
205
+ assert(metadata, 'metadata is required')
206
+
207
+ const psbt = new Psbt({ network: asset.coinInfo.toBitcoinJS() })
208
+
209
+ const purposeXPubs = await getPurposeXPubs({
210
+ assetClientInterface,
211
+ walletAccount,
212
+ asset,
213
+ allowedPurposes,
214
+ })
215
+
216
+ writeGlobalMetadata(psbt, metadata)
217
+
218
+ for (const input of inputs) {
219
+ const psbtInput = createPsbtInput({
220
+ input,
221
+ asset,
222
+ addressPathsMap,
223
+ purposeXPubs,
224
+ nonWitnessTxs,
225
+ allowedPurposes,
226
+ })
227
+ psbt.addInput(psbtInput)
228
+ }
229
+
230
+ outputs.forEach(([address, amount]) => {
231
+ const psbtOutput = createPsbtOutput({
232
+ address,
233
+ amount,
234
+ asset,
235
+ addressPathsMap,
236
+ purposeXPubs,
237
+ allowedPurposes,
238
+ })
239
+ psbt.addOutput(psbtOutput)
240
+ })
241
+
242
+ // sendOutputIndexes is optional (e.g., bump transactions).
243
+ for (const sendOutputIndex of metadata.sendOutputIndexes || []) {
244
+ writePsbtOutputField(psbt, sendOutputIndex, SubType.OutputRole, 'primary')
245
+ }
246
+
247
+ return psbt.toBase64()
248
+ }
@@ -11,6 +11,10 @@ const PROP_TYPE_MARKER = 0xfc
11
11
 
12
12
  export const SubType = Object.freeze({
13
13
  BlockHeight: 0x01,
14
+ BumpTxId: 0x03,
15
+ RbfEnabled: 0x04,
16
+ TxType: 0x05,
17
+ OutputRole: 0x06,
14
18
  })
15
19
 
16
20
  const buildPropKey = (subType) => {
@@ -51,3 +55,97 @@ export const readPsbtBlockHeight = (psbt) => {
51
55
  const kv = findProprietaryVal(psbt.data.globalMap.unknownKeyVals, SubType.BlockHeight)
52
56
  return kv ? kv.value.readUInt32LE(0) : undefined
53
57
  }
58
+
59
+ const encodeProprietaryValue = (subType, value) => {
60
+ switch (subType) {
61
+ case SubType.BlockHeight:
62
+ assert(
63
+ Number.isInteger(value) && value >= 0 && value <= 4_294_967_295,
64
+ 'blockHeight must be a positive integer between 0 and 4294967295'
65
+ )
66
+ return u32LE(value)
67
+ case SubType.RbfEnabled:
68
+ return Buffer.from([value ? 1 : 0])
69
+ case SubType.BumpTxId:
70
+ case SubType.TxType:
71
+ case SubType.OutputRole:
72
+ return Buffer.from(value, 'utf8')
73
+ default:
74
+ throw new Error(`Unknown proprietary field subType: ${subType}`)
75
+ }
76
+ }
77
+
78
+ const decodeProprietaryValue = (subType, buffer) => {
79
+ switch (subType) {
80
+ case SubType.BlockHeight:
81
+ return buffer.readUInt32LE(0)
82
+ case SubType.RbfEnabled:
83
+ return buffer[0] === 1
84
+ case SubType.BumpTxId:
85
+ case SubType.TxType:
86
+ case SubType.OutputRole:
87
+ return buffer.toString('utf8')
88
+ default:
89
+ throw new Error(`Unknown proprietary field subType: ${subType}`)
90
+ }
91
+ }
92
+
93
+ export const writePsbtGlobalField = (psbt, subType, value) => {
94
+ assert(psbt, 'psbt is required')
95
+ assert(Number.isInteger(subType), 'subType must be an integer')
96
+ assert(value !== undefined, 'value is required')
97
+
98
+ const encodedValue = encodeProprietaryValue(subType, value)
99
+ psbt.addUnknownKeyValToGlobal({
100
+ key: buildPropKey(subType),
101
+ value: encodedValue,
102
+ })
103
+ }
104
+
105
+ export const writePsbtOutputField = (psbt, outputIndex, subType, value) => {
106
+ assert(psbt, 'psbt is required')
107
+ assert(
108
+ Number.isInteger(outputIndex) && outputIndex >= 0,
109
+ 'outputIndex must be a non-negative integer'
110
+ )
111
+ assert(Number.isInteger(subType), 'subType must be an integer')
112
+ assert(value !== undefined, 'value is required')
113
+
114
+ const encodedValue = encodeProprietaryValue(subType, value)
115
+ psbt.addUnknownKeyValToOutput(outputIndex, {
116
+ key: buildPropKey(subType),
117
+ value: encodedValue,
118
+ })
119
+ }
120
+
121
+ export const readPsbtGlobalField = (psbt, subType) => {
122
+ assert(psbt, 'psbt is required')
123
+ assert(Number.isInteger(subType), 'subType must be an integer')
124
+
125
+ const kv = findProprietaryVal(psbt.data.globalMap.unknownKeyVals, subType)
126
+ return kv ? decodeProprietaryValue(subType, kv.value) : undefined
127
+ }
128
+
129
+ export const readPsbtOutputField = (psbt, outputIndex) => {
130
+ assert(psbt, 'psbt is required')
131
+ assert(
132
+ Number.isInteger(outputIndex) && outputIndex >= 0,
133
+ 'outputIndex must be a non-negative integer'
134
+ )
135
+
136
+ const output = psbt.data.outputs[outputIndex]
137
+ if (!output) return Object.create(null)
138
+
139
+ const result = Object.create(null)
140
+ for (const [key, subTypeVal] of Object.entries(SubType)) {
141
+ const kv = findProprietaryVal(output.unknownKeyVals, subTypeVal)
142
+ if (kv) {
143
+ result[key.charAt(0).toLowerCase() + key.slice(1)] = decodeProprietaryValue(
144
+ subTypeVal,
145
+ kv.value
146
+ )
147
+ }
148
+ }
149
+
150
+ return result
151
+ }
package/src/psbt-utils.js CHANGED
@@ -1,10 +1,62 @@
1
+ import BIP32 from '@exodus/bip32'
1
2
  import BipPath from 'bip32-path'
2
3
  import lodash from 'lodash'
4
+ import assert from 'minimalistic-assert'
5
+
6
+ export const PURPOSE_TYPES = {
7
+ LEGACY: 44, // P2PKH
8
+ WRAPPED_SEGWIT: 49, // P2SH-P2WPKH
9
+ SEGWIT: 84, // P2WPKH
10
+ TAPROOT: 86, // P2TR
11
+ }
12
+
13
+ export function getAddressType(purpose) {
14
+ return {
15
+ isLegacyAddress: purpose === PURPOSE_TYPES.LEGACY,
16
+ isWrappedSegwitAddress: purpose === PURPOSE_TYPES.WRAPPED_SEGWIT,
17
+ isSegwitAddress: purpose === PURPOSE_TYPES.SEGWIT,
18
+ isTaprootAddress: purpose === PURPOSE_TYPES.TAPROOT,
19
+ }
20
+ }
21
+
22
+ export async function getPurposeXPubs({
23
+ assetClientInterface,
24
+ walletAccount,
25
+ asset,
26
+ allowedPurposes,
27
+ }) {
28
+ assert(allowedPurposes, 'allowedPurposes is required')
29
+ const purposeXPubs = Object.create(null)
30
+
31
+ for (const purpose of allowedPurposes) {
32
+ const xpub = await assetClientInterface.getExtendedPublicKey({
33
+ walletAccount,
34
+ assetName: asset.name,
35
+ purpose,
36
+ })
37
+ const hdkey = BIP32.fromXPub(xpub)
38
+ const masterFingerprint = Buffer.alloc(4)
39
+ masterFingerprint.writeUint32BE(hdkey.fingerprint)
40
+ purposeXPubs[purpose] = {
41
+ hdkey,
42
+ masterFingerprint,
43
+ }
44
+ }
45
+
46
+ return purposeXPubs
47
+ }
48
+
49
+ export function validatePurpose(purpose, allowedPurposes, context = '') {
50
+ assert(allowedPurposes, 'allowedPurposes is required')
51
+ if (!allowedPurposes.includes(purpose)) {
52
+ throw new Error(`Purpose ${purpose} not found${context ? ' for ' + context : ''}`)
53
+ }
54
+ }
3
55
 
4
56
  export const createPsbtToUnsignedTx =
5
57
  ({ assetClientInterface, assetName }) =>
6
58
  async ({ psbt, walletAccount, purpose = 86 }) => {
7
- const addressPathsMap = {}
59
+ const addressPathsMap = Object.create(null)
8
60
  const inputsToSign = []
9
61
 
10
62
  const addressOpts = {
@@ -59,3 +111,18 @@ export const createPsbtToUnsignedTx =
59
111
  },
60
112
  }
61
113
  }
114
+
115
+ /**
116
+ * Temporarily turns on __UNSAFE_SIGN_NONSEGWIT so we can sign or validate PSBTs
117
+ * whose legacy inputs are missing nonWitnessUtxo.
118
+ */
119
+ export async function withUnsafeNonSegwit({ psbt, fn, unsafe = true }) {
120
+ const cache = psbt.__CACHE
121
+ const prevValue = cache.__UNSAFE_SIGN_NONSEGWIT
122
+ cache.__UNSAFE_SIGN_NONSEGWIT = unsafe
123
+ try {
124
+ return await fn()
125
+ } finally {
126
+ cache.__UNSAFE_SIGN_NONSEGWIT = prevValue
127
+ }
128
+ }