@btc-vision/transaction 1.8.7-beta.0 → 1.8.7
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/browser/btc-vision-bitcoin.js +364 -364
- package/browser/index.js +1022 -903
- package/browser/noble-curves.js +1183 -2156
- package/browser/src/transaction/builders/TransactionBuilder.d.ts +1 -0
- package/browser/src/transaction/offline/OfflineTransactionManager.d.ts +50 -0
- package/browser/src/transaction/offline/interfaces/ISerializableState.d.ts +10 -2
- package/browser/vendors.js +1395 -1395
- package/build/transaction/TransactionFactory.js +1 -1
- package/build/transaction/builders/FundingTransaction.js +17 -13
- package/build/transaction/builders/TransactionBuilder.d.ts +1 -0
- package/build/transaction/builders/TransactionBuilder.js +32 -7
- package/build/transaction/offline/OfflineTransactionManager.d.ts +50 -0
- package/build/transaction/offline/OfflineTransactionManager.js +142 -1
- package/build/transaction/offline/TransactionSerializer.js +9 -0
- package/build/transaction/offline/interfaces/ISerializableState.d.ts +10 -2
- package/build/transaction/offline/interfaces/ISerializableState.js +3 -2
- package/build/transaction/shared/TweakedTransaction.js +27 -5
- package/package.json +1 -1
- package/src/transaction/TransactionFactory.ts +1 -1
- package/src/transaction/builders/FundingTransaction.ts +21 -16
- package/src/transaction/builders/TransactionBuilder.ts +46 -10
- package/src/transaction/offline/OfflineTransactionManager.ts +191 -1
- package/src/transaction/offline/TransactionSerializer.ts +11 -0
- package/src/transaction/offline/interfaces/ISerializableState.ts +10 -2
- package/src/transaction/shared/TweakedTransaction.ts +33 -5
- package/test/add-refund-output.test.ts +96 -11
- package/test/csv-multisig-offline-edges.test.ts +293 -0
- package/test/csv-multisig-offline.test.ts +202 -0
- package/test/transaction-builders.test.ts +69 -3
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* csv-multisig-offline-edges.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Edge cases for the OfflineTransactionManager CSV multisig API:
|
|
5
|
+
* - csvMultisigGetStatus standalone
|
|
6
|
+
* - csvMultisigFinalize error paths
|
|
7
|
+
* - addCSVMultisigSignature idempotency + non-cosigner no-op on first hop
|
|
8
|
+
* - Serializer v2 round-trip of partialPsbtBase64
|
|
9
|
+
* - signPSBT returns a partial PSBT for under-threshold CSV inputs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
crypto as bitCrypto,
|
|
15
|
+
networks,
|
|
16
|
+
payments,
|
|
17
|
+
Psbt,
|
|
18
|
+
toHex,
|
|
19
|
+
toXOnly,
|
|
20
|
+
type XOnlyPublicKey,
|
|
21
|
+
} from '@btc-vision/bitcoin';
|
|
22
|
+
import { type UniversalSigner } from '@btc-vision/ecpair';
|
|
23
|
+
import type { UTXO } from '../build/opnet.js';
|
|
24
|
+
import {
|
|
25
|
+
CSVMultisigProvider,
|
|
26
|
+
EcKeyPair,
|
|
27
|
+
FundingTransaction,
|
|
28
|
+
OfflineTransactionManager,
|
|
29
|
+
TransactionBuilder,
|
|
30
|
+
TransactionSerializer,
|
|
31
|
+
} from '../build/opnet.js';
|
|
32
|
+
|
|
33
|
+
const network = networks.regtest;
|
|
34
|
+
|
|
35
|
+
function makeSigner(seed: string): UniversalSigner {
|
|
36
|
+
return EcKeyPair.fromPrivateKey(
|
|
37
|
+
bitCrypto.sha256(new TextEncoder().encode(seed)),
|
|
38
|
+
network,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildAddr(signers: UniversalSigner[], csvBlocks: number, threshold: number) {
|
|
43
|
+
const pubkeys = signers.map((s) => toXOnly(s.publicKey) as XOnlyPublicKey);
|
|
44
|
+
return CSVMultisigProvider.generateAddress({ pubkeys, threshold, csvBlocks }, network);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeUtxo(
|
|
48
|
+
addr: ReturnType<typeof buildAddr>,
|
|
49
|
+
value: bigint,
|
|
50
|
+
txid: string = '22'.repeat(32),
|
|
51
|
+
): UTXO {
|
|
52
|
+
return {
|
|
53
|
+
transactionId: txid,
|
|
54
|
+
outputIndex: 0,
|
|
55
|
+
value,
|
|
56
|
+
scriptPubKey: {
|
|
57
|
+
hex: toHex(addr.scriptPubKey),
|
|
58
|
+
address: addr.address,
|
|
59
|
+
},
|
|
60
|
+
witnessScript: addr.tapscript,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fundingState(
|
|
65
|
+
addr: ReturnType<typeof buildAddr>,
|
|
66
|
+
utxo: UTXO,
|
|
67
|
+
placeholderSigner: UniversalSigner,
|
|
68
|
+
): string {
|
|
69
|
+
const recipient = payments.p2tr({
|
|
70
|
+
internalPubkey: toXOnly(placeholderSigner.publicKey) as XOnlyPublicKey,
|
|
71
|
+
network,
|
|
72
|
+
}).address as string;
|
|
73
|
+
|
|
74
|
+
return OfflineTransactionManager.exportFunding({
|
|
75
|
+
signer: placeholderSigner,
|
|
76
|
+
mldsaSigner: null,
|
|
77
|
+
network,
|
|
78
|
+
utxos: [utxo],
|
|
79
|
+
to: recipient,
|
|
80
|
+
amount: TransactionBuilder.MINIMUM_DUST,
|
|
81
|
+
feeRate: 1,
|
|
82
|
+
priorityFee: 0n,
|
|
83
|
+
gasSatFee: 0n,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('OfflineTransactionManager CSV multisig — edges', () => {
|
|
88
|
+
let signerA: UniversalSigner;
|
|
89
|
+
let signerB: UniversalSigner;
|
|
90
|
+
let signerC: UniversalSigner;
|
|
91
|
+
let signerD: UniversalSigner; // never a cosigner anywhere
|
|
92
|
+
|
|
93
|
+
beforeAll(() => {
|
|
94
|
+
signerA = makeSigner('edges-A');
|
|
95
|
+
signerB = makeSigner('edges-B');
|
|
96
|
+
signerC = makeSigner('edges-C');
|
|
97
|
+
signerD = makeSigner('edges-D');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ----------------------------------------------------------------
|
|
101
|
+
// csvMultisigGetStatus
|
|
102
|
+
// ----------------------------------------------------------------
|
|
103
|
+
describe('csvMultisigGetStatus', () => {
|
|
104
|
+
it('returns [] when no partial PSBT has been produced yet', () => {
|
|
105
|
+
const addr = buildAddr([signerA, signerB], 5, 2);
|
|
106
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
107
|
+
const state = fundingState(addr, utxo, signerA);
|
|
108
|
+
|
|
109
|
+
expect(OfflineTransactionManager.csvMultisigGetStatus(state)).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reflects the same status as the hop result after addCSVMultisigSignature', async () => {
|
|
113
|
+
const addr = buildAddr([signerA, signerB, signerC], 5, 2);
|
|
114
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
115
|
+
const state = fundingState(addr, utxo, signerA);
|
|
116
|
+
|
|
117
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
118
|
+
const direct = OfflineTransactionManager.csvMultisigGetStatus(hop1.state);
|
|
119
|
+
|
|
120
|
+
expect(direct).toEqual(hop1.perInput);
|
|
121
|
+
expect(direct[0]!.collected).toBe(1);
|
|
122
|
+
expect(direct[0]!.required).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('reports collected === threshold for finalized inputs (threshold=1 single hop)', async () => {
|
|
126
|
+
const addr = buildAddr([signerA], 3, 1);
|
|
127
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
128
|
+
const state = fundingState(addr, utxo, signerA);
|
|
129
|
+
|
|
130
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
131
|
+
|
|
132
|
+
const status = OfflineTransactionManager.csvMultisigGetStatus(hop1.state);
|
|
133
|
+
expect(status).toHaveLength(1);
|
|
134
|
+
expect(status[0]!.required).toBe(1);
|
|
135
|
+
expect(status[0]!.collected).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ----------------------------------------------------------------
|
|
140
|
+
// csvMultisigFinalize error paths
|
|
141
|
+
// ----------------------------------------------------------------
|
|
142
|
+
describe('csvMultisigFinalize', () => {
|
|
143
|
+
it('throws when no partial PSBT exists in state', () => {
|
|
144
|
+
const addr = buildAddr([signerA, signerB], 5, 2);
|
|
145
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
146
|
+
const state = fundingState(addr, utxo, signerA);
|
|
147
|
+
|
|
148
|
+
expect(() => OfflineTransactionManager.csvMultisigFinalize(state)).toThrow(
|
|
149
|
+
/No partial PSBT/i,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('throws when the partial PSBT is below threshold', async () => {
|
|
154
|
+
const addr = buildAddr([signerA, signerB, signerC], 5, 2);
|
|
155
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
156
|
+
const state = fundingState(addr, utxo, signerA);
|
|
157
|
+
|
|
158
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
159
|
+
expect(hop1.final).toBe(false);
|
|
160
|
+
|
|
161
|
+
expect(() => OfflineTransactionManager.csvMultisigFinalize(hop1.state)).toThrow(
|
|
162
|
+
/needs 2 signatures, got 1/,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ----------------------------------------------------------------
|
|
168
|
+
// addCSVMultisigSignature edge cases
|
|
169
|
+
// ----------------------------------------------------------------
|
|
170
|
+
describe('addCSVMultisigSignature', () => {
|
|
171
|
+
it('is idempotent — re-signing with the same cosigner does not double-count', async () => {
|
|
172
|
+
const addr = buildAddr([signerA, signerB, signerC], 5, 2);
|
|
173
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
174
|
+
const state = fundingState(addr, utxo, signerA);
|
|
175
|
+
|
|
176
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
177
|
+
expect(hop1.perInput[0]!.collected).toBe(1);
|
|
178
|
+
|
|
179
|
+
const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
180
|
+
hop1.state,
|
|
181
|
+
signerA,
|
|
182
|
+
);
|
|
183
|
+
expect(hop2.perInput[0]!.collected).toBe(1);
|
|
184
|
+
expect(hop2.final).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('non-cosigner on a non-first hop leaves the partial PSBT unchanged', async () => {
|
|
188
|
+
const addr = buildAddr([signerA, signerB], 5, 2);
|
|
189
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
190
|
+
const state = fundingState(addr, utxo, signerA);
|
|
191
|
+
|
|
192
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
193
|
+
expect(hop1.perInput[0]!.collected).toBe(1);
|
|
194
|
+
|
|
195
|
+
// signerD is in neither the tapscript nor the original signer slot
|
|
196
|
+
const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
197
|
+
hop1.state,
|
|
198
|
+
signerD,
|
|
199
|
+
);
|
|
200
|
+
expect(hop2.perInput[0]!.collected).toBe(1);
|
|
201
|
+
expect(hop2.final).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('reaches threshold across three distinct cosigners (2-of-3 with C+B)', async () => {
|
|
205
|
+
const addr = buildAddr([signerA, signerB, signerC], 5, 2);
|
|
206
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
207
|
+
// Placeholder signer is A — but the actual cosigners are C then B.
|
|
208
|
+
const state = fundingState(addr, utxo, signerA);
|
|
209
|
+
|
|
210
|
+
// Build PSBT using A as the placeholder for the first hop;
|
|
211
|
+
// immediately overlay C's signature in a second hop.
|
|
212
|
+
const hopA = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
213
|
+
expect(hopA.perInput[0]!.collected).toBe(1);
|
|
214
|
+
|
|
215
|
+
const hopC = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
216
|
+
hopA.state,
|
|
217
|
+
signerC,
|
|
218
|
+
);
|
|
219
|
+
expect(hopC.perInput[0]!.collected).toBe(2);
|
|
220
|
+
expect(hopC.final).toBe(true);
|
|
221
|
+
|
|
222
|
+
// Finalization succeeds even though signerB never participated.
|
|
223
|
+
const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hopC.state);
|
|
224
|
+
expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ----------------------------------------------------------------
|
|
229
|
+
// Serializer v2 round-trip
|
|
230
|
+
// ----------------------------------------------------------------
|
|
231
|
+
describe('serializer v2 — partialPsbtBase64 round-trip', () => {
|
|
232
|
+
it('omits the field on a freshly exported state', () => {
|
|
233
|
+
const addr = buildAddr([signerA, signerB], 5, 2);
|
|
234
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
235
|
+
const state = fundingState(addr, utxo, signerA);
|
|
236
|
+
|
|
237
|
+
const decoded = TransactionSerializer.fromBase64(state);
|
|
238
|
+
expect(decoded.partialPsbtBase64).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('round-trips the partial PSBT verbatim after a signing hop', async () => {
|
|
242
|
+
const addr = buildAddr([signerA, signerB], 5, 2);
|
|
243
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
244
|
+
const state = fundingState(addr, utxo, signerA);
|
|
245
|
+
|
|
246
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
|
|
247
|
+
|
|
248
|
+
const decoded = TransactionSerializer.fromBase64(hop1.state);
|
|
249
|
+
expect(decoded.partialPsbtBase64).toBeDefined();
|
|
250
|
+
|
|
251
|
+
// Sanity: the carried PSBT actually parses and has one tapScriptSig.
|
|
252
|
+
const psbt = Psbt.fromBase64(decoded.partialPsbtBase64!, { network });
|
|
253
|
+
expect(psbt.data.inputs[0]!.tapScriptSig).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
// Round-trip via toBase64 yields a byte-identical payload.
|
|
256
|
+
const reEncoded = TransactionSerializer.toBase64(decoded);
|
|
257
|
+
expect(reEncoded).toBe(hop1.state);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ----------------------------------------------------------------
|
|
262
|
+
// signPSBT semantic shift: returns the (possibly partial) PSBT
|
|
263
|
+
// ----------------------------------------------------------------
|
|
264
|
+
describe('TransactionBuilder.signPSBT — partial PSBT semantics', () => {
|
|
265
|
+
it('returns a PSBT with the tapScriptSig but no finalScriptWitness for under-threshold CSV input', async () => {
|
|
266
|
+
const addr = buildAddr([signerA, signerB, signerC], 5, 2);
|
|
267
|
+
const utxo = makeUtxo(addr, 200_000n);
|
|
268
|
+
const recipient = payments.p2tr({
|
|
269
|
+
internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
|
|
270
|
+
network,
|
|
271
|
+
}).address as string;
|
|
272
|
+
|
|
273
|
+
const tx = new FundingTransaction({
|
|
274
|
+
signer: signerA,
|
|
275
|
+
mldsaSigner: null,
|
|
276
|
+
network,
|
|
277
|
+
utxos: [utxo],
|
|
278
|
+
to: recipient,
|
|
279
|
+
amount: TransactionBuilder.MINIMUM_DUST,
|
|
280
|
+
feeRate: 1,
|
|
281
|
+
priorityFee: 0n,
|
|
282
|
+
gasSatFee: 0n,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const psbt = await tx.signPSBT();
|
|
286
|
+
const input = psbt.data.inputs[0]!;
|
|
287
|
+
|
|
288
|
+
expect(input.tapScriptSig).toBeDefined();
|
|
289
|
+
expect(input.tapScriptSig).toHaveLength(1);
|
|
290
|
+
expect(input.finalScriptWitness).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* csv-multisig-offline.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Library-native multi-signer flow for spending CSV multisig UTXOs using
|
|
5
|
+
* OfflineTransactionManager — the documented offline signing API. No raw
|
|
6
|
+
* @btc-vision/bitcoin Psbt construction.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
crypto as bitCrypto,
|
|
12
|
+
networks,
|
|
13
|
+
payments,
|
|
14
|
+
toHex,
|
|
15
|
+
toXOnly,
|
|
16
|
+
type XOnlyPublicKey,
|
|
17
|
+
} from '@btc-vision/bitcoin';
|
|
18
|
+
import { type UniversalSigner } from '@btc-vision/ecpair';
|
|
19
|
+
import type { UTXO } from '../build/opnet.js';
|
|
20
|
+
import {
|
|
21
|
+
CSVMultisigProvider,
|
|
22
|
+
EcKeyPair,
|
|
23
|
+
OfflineTransactionManager,
|
|
24
|
+
TransactionBuilder,
|
|
25
|
+
} from '../build/opnet.js';
|
|
26
|
+
|
|
27
|
+
const network = networks.regtest;
|
|
28
|
+
|
|
29
|
+
function makeSigner(seed: string): UniversalSigner {
|
|
30
|
+
return EcKeyPair.fromPrivateKey(
|
|
31
|
+
bitCrypto.sha256(new TextEncoder().encode(seed)),
|
|
32
|
+
network,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildAddr(signers: UniversalSigner[], csvBlocks: number, threshold: number) {
|
|
37
|
+
const pubkeys = signers.map((s) => toXOnly(s.publicKey) as XOnlyPublicKey);
|
|
38
|
+
return CSVMultisigProvider.generateAddress({ pubkeys, threshold, csvBlocks }, network);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeUtxo(addr: ReturnType<typeof buildAddr>, value: bigint): UTXO {
|
|
42
|
+
return {
|
|
43
|
+
transactionId: '11'.repeat(32),
|
|
44
|
+
outputIndex: 0,
|
|
45
|
+
value,
|
|
46
|
+
scriptPubKey: {
|
|
47
|
+
hex: toHex(addr.scriptPubKey),
|
|
48
|
+
address: addr.address,
|
|
49
|
+
},
|
|
50
|
+
witnessScript: addr.tapscript,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('CSV multisig offline multi-signer flow', () => {
|
|
55
|
+
let signerA: UniversalSigner;
|
|
56
|
+
let signerB: UniversalSigner;
|
|
57
|
+
let signerC: UniversalSigner;
|
|
58
|
+
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
signerA = makeSigner('offline-A');
|
|
61
|
+
signerB = makeSigner('offline-B');
|
|
62
|
+
signerC = makeSigner('offline-C');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('2-of-3 signers produce a broadcastable tx through OfflineTransactionManager', async () => {
|
|
66
|
+
const addr = buildAddr([signerA, signerB, signerC], 10, 2);
|
|
67
|
+
const utxo = makeUtxo(addr, 1_000_000n);
|
|
68
|
+
const recipient = payments.p2tr({
|
|
69
|
+
internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
|
|
70
|
+
network,
|
|
71
|
+
}).address as string;
|
|
72
|
+
|
|
73
|
+
// --- Coordinator (online): export funding state ---
|
|
74
|
+
const initialState = OfflineTransactionManager.exportFunding({
|
|
75
|
+
signer: signerA, // placeholder; real signer supplied per-hop
|
|
76
|
+
mldsaSigner: null,
|
|
77
|
+
network,
|
|
78
|
+
utxos: [utxo],
|
|
79
|
+
to: recipient,
|
|
80
|
+
amount: TransactionBuilder.MINIMUM_DUST,
|
|
81
|
+
feeRate: 1,
|
|
82
|
+
priorityFee: 0n,
|
|
83
|
+
gasSatFee: 0n,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Before any signing, there's no partial PSBT yet.
|
|
87
|
+
expect(OfflineTransactionManager.csvMultisigGetStatus(initialState)).toEqual([]);
|
|
88
|
+
|
|
89
|
+
// --- Hop 1: signer A adds signature (offline env) ---
|
|
90
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
91
|
+
initialState,
|
|
92
|
+
signerA,
|
|
93
|
+
);
|
|
94
|
+
expect(hop1.final).toBe(false);
|
|
95
|
+
expect(hop1.perInput).toHaveLength(1);
|
|
96
|
+
expect(hop1.perInput[0]!.required).toBe(2);
|
|
97
|
+
expect(hop1.perInput[0]!.collected).toBe(1);
|
|
98
|
+
expect(hop1.perInput[0]!.signers).toContain(toHex(toXOnly(signerA.publicKey)));
|
|
99
|
+
|
|
100
|
+
// --- Hop 2: signer B adds signature (different offline env) ---
|
|
101
|
+
const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
102
|
+
hop1.state,
|
|
103
|
+
signerB,
|
|
104
|
+
);
|
|
105
|
+
expect(hop2.final).toBe(true);
|
|
106
|
+
expect(hop2.perInput[0]!.collected).toBe(2);
|
|
107
|
+
expect(new Set(hop2.perInput[0]!.signers)).toEqual(
|
|
108
|
+
new Set([
|
|
109
|
+
toHex(toXOnly(signerA.publicKey)),
|
|
110
|
+
toHex(toXOnly(signerB.publicKey)),
|
|
111
|
+
]),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// --- Finalize + extract (anyone, no key material required) ---
|
|
115
|
+
const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hop2.state);
|
|
116
|
+
expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
|
|
117
|
+
|
|
118
|
+
// Sanity: the extracted tx has the expected witness shape and CSV sequence.
|
|
119
|
+
const state = OfflineTransactionManager.inspect(hop2.state);
|
|
120
|
+
expect(state.partialPsbtBase64).toBeDefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('signer pubkey not in the tapscript is a no-op', async () => {
|
|
124
|
+
const addr = buildAddr([signerA, signerB], 5, 2);
|
|
125
|
+
const utxo = makeUtxo(addr, 500_000n);
|
|
126
|
+
const recipient = payments.p2tr({
|
|
127
|
+
internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
|
|
128
|
+
network,
|
|
129
|
+
}).address as string;
|
|
130
|
+
|
|
131
|
+
const initialState = OfflineTransactionManager.exportFunding({
|
|
132
|
+
signer: signerA,
|
|
133
|
+
mldsaSigner: null,
|
|
134
|
+
network,
|
|
135
|
+
utxos: [utxo],
|
|
136
|
+
to: recipient,
|
|
137
|
+
amount: TransactionBuilder.MINIMUM_DUST,
|
|
138
|
+
feeRate: 1,
|
|
139
|
+
priorityFee: 0n,
|
|
140
|
+
gasSatFee: 0n,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
144
|
+
initialState,
|
|
145
|
+
signerA,
|
|
146
|
+
);
|
|
147
|
+
expect(hop1.perInput[0]!.collected).toBe(1);
|
|
148
|
+
|
|
149
|
+
// signerC is not in this 2-of-2 tapscript.
|
|
150
|
+
const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
151
|
+
hop1.state,
|
|
152
|
+
signerC,
|
|
153
|
+
);
|
|
154
|
+
expect(hop2.perInput[0]!.collected).toBe(1);
|
|
155
|
+
expect(hop2.final).toBe(false);
|
|
156
|
+
|
|
157
|
+
// Finalize should throw because we're still below threshold.
|
|
158
|
+
expect(() => OfflineTransactionManager.csvMultisigFinalize(hop2.state)).toThrow(
|
|
159
|
+
/needs 2 signatures, got 1/,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Now add the real second signer and finalize.
|
|
163
|
+
const hop3 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
164
|
+
hop2.state,
|
|
165
|
+
signerB,
|
|
166
|
+
);
|
|
167
|
+
expect(hop3.final).toBe(true);
|
|
168
|
+
|
|
169
|
+
const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hop3.state);
|
|
170
|
+
expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('threshold = 1 finalizes in a single hop and emits a ready-to-broadcast tx', async () => {
|
|
174
|
+
const addr = buildAddr([signerA], 3, 1);
|
|
175
|
+
const utxo = makeUtxo(addr, 500_000n);
|
|
176
|
+
const recipient = payments.p2tr({
|
|
177
|
+
internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
|
|
178
|
+
network,
|
|
179
|
+
}).address as string;
|
|
180
|
+
|
|
181
|
+
const initialState = OfflineTransactionManager.exportFunding({
|
|
182
|
+
signer: signerA,
|
|
183
|
+
mldsaSigner: null,
|
|
184
|
+
network,
|
|
185
|
+
utxos: [utxo],
|
|
186
|
+
to: recipient,
|
|
187
|
+
amount: TransactionBuilder.MINIMUM_DUST,
|
|
188
|
+
feeRate: 1,
|
|
189
|
+
priorityFee: 0n,
|
|
190
|
+
gasSatFee: 0n,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(
|
|
194
|
+
initialState,
|
|
195
|
+
signerA,
|
|
196
|
+
);
|
|
197
|
+
expect(hop1.final).toBe(true);
|
|
198
|
+
|
|
199
|
+
const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hop1.state);
|
|
200
|
+
expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -177,7 +177,7 @@ describe('Transaction Builders - End-to-End', () => {
|
|
|
177
177
|
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds/);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it('should tolerate small fee estimation shortfalls when effective fee is
|
|
180
|
+
it('should tolerate small fee estimation shortfalls when amount + effective fee is near to input utxos', async () => {
|
|
181
181
|
// When amount is close to totalInputAmount but leaves some sats for
|
|
182
182
|
// fees, the estimated fee may exceed what's available. As long as the
|
|
183
183
|
// effective fee (totalInputAmount - amountSpent) is positive, the
|
|
@@ -187,7 +187,7 @@ describe('Transaction Builders - End-to-End', () => {
|
|
|
187
187
|
|
|
188
188
|
// Leave 200 sats for fees , less than the estimated ~77 sats/vB * ~77 vB
|
|
189
189
|
// but still a positive effective fee
|
|
190
|
-
const amount = utxoValue -
|
|
190
|
+
const amount = utxoValue - 1110n;
|
|
191
191
|
|
|
192
192
|
const tx = new FundingTransaction({
|
|
193
193
|
signer,
|
|
@@ -209,7 +209,31 @@ describe('Transaction Builders - End-to-End', () => {
|
|
|
209
209
|
|
|
210
210
|
// Verify fee is exactly what was left over
|
|
211
211
|
const totalOutputValue = signed.outs.reduce((sum, out) => sum + BigInt(out.value), 0n);
|
|
212
|
-
expect(utxoValue - totalOutputValue).toBe(
|
|
212
|
+
expect(utxoValue - totalOutputValue).toBe(1110n);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should throw when leftover fee would underpay feeRate (no autoAdjust)', async () => {
|
|
216
|
+
// When amount is close to totalInputAmount but leaves less than the
|
|
217
|
+
// fee implied by feeRate, the transaction must NOT broadcast with an
|
|
218
|
+
// underpaid fee. Callers wanting send-max behavior must opt in via
|
|
219
|
+
// autoAdjustAmount=true.
|
|
220
|
+
const utxoValue = 100_000n;
|
|
221
|
+
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
|
|
222
|
+
const amount = utxoValue - 200n;
|
|
223
|
+
|
|
224
|
+
const tx = new FundingTransaction({
|
|
225
|
+
signer,
|
|
226
|
+
network,
|
|
227
|
+
utxos: [utxo],
|
|
228
|
+
to: taprootAddress,
|
|
229
|
+
amount,
|
|
230
|
+
feeRate: 10,
|
|
231
|
+
priorityFee: 0n,
|
|
232
|
+
gasSatFee: 0n,
|
|
233
|
+
mldsaSigner: null,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds for fee/);
|
|
213
237
|
});
|
|
214
238
|
|
|
215
239
|
it('should auto-adjust amount when amount equals total UTXO value with autoAdjustAmount', async () => {
|
|
@@ -248,6 +272,48 @@ describe('Transaction Builders - End-to-End', () => {
|
|
|
248
272
|
expect(fee).toBeLessThanOrEqual(expectedFee + 10n);
|
|
249
273
|
});
|
|
250
274
|
|
|
275
|
+
it('should auto-adjust amount when amount + fees is higher than total UTXO value with autoAdjustAmount', async () => {
|
|
276
|
+
const amount = 100_000n;
|
|
277
|
+
const utxoValue = 100_100n; // Only 100 sats available for fees
|
|
278
|
+
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
|
|
279
|
+
const feeRate = 2;
|
|
280
|
+
|
|
281
|
+
const tx = new FundingTransaction({
|
|
282
|
+
signer,
|
|
283
|
+
network,
|
|
284
|
+
utxos: [utxo],
|
|
285
|
+
to: taprootAddress,
|
|
286
|
+
amount: amount,
|
|
287
|
+
autoAdjustAmount: true,
|
|
288
|
+
feeRate,
|
|
289
|
+
priorityFee: 0n,
|
|
290
|
+
gasSatFee: 0n,
|
|
291
|
+
mldsaSigner: null,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const signed = await tx.signTransaction();
|
|
295
|
+
|
|
296
|
+
expect(signed.ins.length).toBeGreaterThan(0);
|
|
297
|
+
expect(signed.outs.length).toBeGreaterThan(0);
|
|
298
|
+
expect(signed.toHex()).toBeTruthy();
|
|
299
|
+
|
|
300
|
+
// Calculate total ouput for following tests
|
|
301
|
+
const totalOutputValue = signed.outs.reduce((sum, out) => sum + BigInt(out.value), 0n);
|
|
302
|
+
|
|
303
|
+
// The fee should be roughly feeRate * virtualSize
|
|
304
|
+
const fee = utxoValue - totalOutputValue;
|
|
305
|
+
const expectedFee = BigInt(Math.ceil(feeRate * signed.virtualSize()));
|
|
306
|
+
// Allow small variance due to fee estimation iterations
|
|
307
|
+
expect(fee).toBeGreaterThan(0n);
|
|
308
|
+
expect(fee).toBeGreaterThanOrEqual(expectedFee - 10n);
|
|
309
|
+
expect(fee).toBeLessThanOrEqual(expectedFee + 10n);
|
|
310
|
+
|
|
311
|
+
// The total output value should be less than utxoValue (fees deducted)
|
|
312
|
+
const expectedAmount = utxoValue - expectedFee;
|
|
313
|
+
expect(totalOutputValue).toBeLessThan(expectedAmount + 10n);
|
|
314
|
+
expect(totalOutputValue).toBeGreaterThan(expectedAmount - 10n);
|
|
315
|
+
});
|
|
316
|
+
|
|
251
317
|
it('should not adjust amount when autoAdjustAmount is true but amount < totalInputAmount', async () => {
|
|
252
318
|
const utxoValue = 100_000n;
|
|
253
319
|
const sendAmount = 50_000n;
|