@btc-vision/transaction 1.8.7-beta.0 → 1.8.8
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 +832 -828
- package/browser/index.js +1074 -947
- package/browser/noble-curves.js +1183 -2156
- package/browser/noble-hashes.js +88 -88
- package/browser/rolldown-runtime.js +26 -19
- package/browser/src/epoch/validator/EpochValidator.d.ts +28 -6
- 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 +3267 -3229
- package/build/epoch/validator/EpochValidator.d.ts +28 -6
- package/build/epoch/validator/EpochValidator.js +44 -10
- 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/epoch/validator/EpochValidator.ts +67 -6
- 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
|
@@ -6,9 +6,31 @@ export declare class EpochValidator {
|
|
|
6
6
|
*/
|
|
7
7
|
static sha1(data: Uint8Array): Uint8Array;
|
|
8
8
|
/**
|
|
9
|
-
* Calculate mining preimage
|
|
9
|
+
* Calculate mining preimage.
|
|
10
|
+
*
|
|
11
|
+
* When useConcatenatedPreimage is false the legacy XOR construction is used
|
|
12
|
+
* (checksumRoot ^ publicKey ^ salt). That construction is malleable: a
|
|
13
|
+
* miner can set salt == publicKey to force preimage == checksumRoot (max
|
|
14
|
+
* difficulty for free), or transform any solution to another key via
|
|
15
|
+
* salt' = publicKey' ^ publicKey ^ salt. When useConcatenatedPreimage is true
|
|
16
|
+
* the preimage is the 96-byte concatenation checksumRoot || publicKey || salt,
|
|
17
|
+
* which removes both (the SHA-1 proof-of-work over it is unchanged).
|
|
18
|
+
*
|
|
19
|
+
* The switch is a consensus rule: callers pass useConcatenatedPreimage =
|
|
20
|
+
* (epochBlockHeight >= activationHeight) using the activation height for the
|
|
21
|
+
* network they are on (opnet-node exposes it as
|
|
22
|
+
* consensusEpochPatches.PREIMAGE_CONCAT_PATCH_BLOCK_HEIGHT). The value MUST be
|
|
23
|
+
* identical across opnet-node, this library and the mining pool.
|
|
10
24
|
*/
|
|
11
|
-
static calculatePreimage(checksumRoot: Uint8Array, publicKey: Uint8Array, salt: Uint8Array): Uint8Array;
|
|
25
|
+
static calculatePreimage(checksumRoot: Uint8Array, publicKey: Uint8Array, salt: Uint8Array, useConcatenatedPreimage?: boolean): Uint8Array;
|
|
26
|
+
/**
|
|
27
|
+
* Non-malleable mining preimage: the 96-byte concatenation
|
|
28
|
+
* checksumRoot || publicKey || salt. The public key sits in its own segment,
|
|
29
|
+
* so a miner-chosen salt can neither cancel it nor be transformed to
|
|
30
|
+
* re-attribute another miner's solution. The SHA-1 proof-of-work over it is
|
|
31
|
+
* unchanged.
|
|
32
|
+
*/
|
|
33
|
+
static concatenatePreimage(checksumRoot: Uint8Array, publicKey: Uint8Array, salt: Uint8Array): Uint8Array;
|
|
12
34
|
/**
|
|
13
35
|
* Count matching bits between two hashes
|
|
14
36
|
*/
|
|
@@ -16,7 +38,7 @@ export declare class EpochValidator {
|
|
|
16
38
|
/**
|
|
17
39
|
* Verify an epoch solution using IPreimage
|
|
18
40
|
*/
|
|
19
|
-
static verifySolution(challenge: IChallengeSolution, log?: boolean): boolean;
|
|
41
|
+
static verifySolution(challenge: IChallengeSolution, log?: boolean, useHashedPreimage?: boolean): boolean;
|
|
20
42
|
/**
|
|
21
43
|
* Get the mining target block for an epoch
|
|
22
44
|
*/
|
|
@@ -24,11 +46,11 @@ export declare class EpochValidator {
|
|
|
24
46
|
/**
|
|
25
47
|
* Validate epoch winner from raw data
|
|
26
48
|
*/
|
|
27
|
-
static validateEpochWinner(epochData: RawChallenge): boolean;
|
|
49
|
+
static validateEpochWinner(epochData: RawChallenge, useHashedPreimage?: boolean): boolean;
|
|
28
50
|
/**
|
|
29
51
|
* Validate epoch winner from Preimage instance
|
|
30
52
|
*/
|
|
31
|
-
static validateChallengeSolution(challenge: IChallengeSolution): boolean;
|
|
53
|
+
static validateChallengeSolution(challenge: IChallengeSolution, useHashedPreimage?: boolean): boolean;
|
|
32
54
|
/**
|
|
33
55
|
* Calculate solution hash from preimage components
|
|
34
56
|
* @param targetChecksum The target checksum (32 bytes)
|
|
@@ -36,7 +58,7 @@ export declare class EpochValidator {
|
|
|
36
58
|
* @param salt The salt buffer (32 bytes)
|
|
37
59
|
* @returns The SHA-1 hash of the preimage
|
|
38
60
|
*/
|
|
39
|
-
static calculateSolution(targetChecksum: Uint8Array, publicKey: Uint8Array, salt: Uint8Array): Uint8Array;
|
|
61
|
+
static calculateSolution(targetChecksum: Uint8Array, publicKey: Uint8Array, salt: Uint8Array, useHashedPreimage?: boolean): Uint8Array;
|
|
40
62
|
/**
|
|
41
63
|
* Check if a solution meets the minimum difficulty requirement
|
|
42
64
|
*/
|
|
@@ -10,13 +10,30 @@ export class EpochValidator {
|
|
|
10
10
|
return crypto.sha1(data);
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Calculate mining preimage
|
|
13
|
+
* Calculate mining preimage.
|
|
14
|
+
*
|
|
15
|
+
* When useConcatenatedPreimage is false the legacy XOR construction is used
|
|
16
|
+
* (checksumRoot ^ publicKey ^ salt). That construction is malleable: a
|
|
17
|
+
* miner can set salt == publicKey to force preimage == checksumRoot (max
|
|
18
|
+
* difficulty for free), or transform any solution to another key via
|
|
19
|
+
* salt' = publicKey' ^ publicKey ^ salt. When useConcatenatedPreimage is true
|
|
20
|
+
* the preimage is the 96-byte concatenation checksumRoot || publicKey || salt,
|
|
21
|
+
* which removes both (the SHA-1 proof-of-work over it is unchanged).
|
|
22
|
+
*
|
|
23
|
+
* The switch is a consensus rule: callers pass useConcatenatedPreimage =
|
|
24
|
+
* (epochBlockHeight >= activationHeight) using the activation height for the
|
|
25
|
+
* network they are on (opnet-node exposes it as
|
|
26
|
+
* consensusEpochPatches.PREIMAGE_CONCAT_PATCH_BLOCK_HEIGHT). The value MUST be
|
|
27
|
+
* identical across opnet-node, this library and the mining pool.
|
|
14
28
|
*/
|
|
15
|
-
static calculatePreimage(checksumRoot, publicKey, salt) {
|
|
29
|
+
static calculatePreimage(checksumRoot, publicKey, salt, useConcatenatedPreimage = false) {
|
|
16
30
|
// Ensure all are 32 bytes
|
|
17
31
|
if (checksumRoot.length !== 32 || publicKey.length !== 32 || salt.length !== 32) {
|
|
18
32
|
throw new Error('All inputs must be 32 bytes');
|
|
19
33
|
}
|
|
34
|
+
if (useConcatenatedPreimage) {
|
|
35
|
+
return this.concatenatePreimage(checksumRoot, publicKey, salt);
|
|
36
|
+
}
|
|
20
37
|
const preimage = new Uint8Array(32);
|
|
21
38
|
for (let i = 0; i < 32; i++) {
|
|
22
39
|
preimage[i] =
|
|
@@ -24,6 +41,23 @@ export class EpochValidator {
|
|
|
24
41
|
}
|
|
25
42
|
return preimage;
|
|
26
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Non-malleable mining preimage: the 96-byte concatenation
|
|
46
|
+
* checksumRoot || publicKey || salt. The public key sits in its own segment,
|
|
47
|
+
* so a miner-chosen salt can neither cancel it nor be transformed to
|
|
48
|
+
* re-attribute another miner's solution. The SHA-1 proof-of-work over it is
|
|
49
|
+
* unchanged.
|
|
50
|
+
*/
|
|
51
|
+
static concatenatePreimage(checksumRoot, publicKey, salt) {
|
|
52
|
+
if (checksumRoot.length !== 32 || publicKey.length !== 32 || salt.length !== 32) {
|
|
53
|
+
throw new Error('All inputs must be 32 bytes');
|
|
54
|
+
}
|
|
55
|
+
const message = new Uint8Array(96);
|
|
56
|
+
message.set(checksumRoot, 0);
|
|
57
|
+
message.set(publicKey, 32);
|
|
58
|
+
message.set(salt, 64);
|
|
59
|
+
return message;
|
|
60
|
+
}
|
|
27
61
|
/**
|
|
28
62
|
* Count matching bits between two hashes
|
|
29
63
|
*/
|
|
@@ -56,10 +90,10 @@ export class EpochValidator {
|
|
|
56
90
|
/**
|
|
57
91
|
* Verify an epoch solution using IPreimage
|
|
58
92
|
*/
|
|
59
|
-
static verifySolution(challenge, log = false) {
|
|
93
|
+
static verifySolution(challenge, log = false, useHashedPreimage = false) {
|
|
60
94
|
try {
|
|
61
95
|
const verification = challenge.verification;
|
|
62
|
-
const calculatedPreimage = this.calculatePreimage(verification.targetChecksum, challenge.publicKey.toBuffer(), challenge.salt);
|
|
96
|
+
const calculatedPreimage = this.calculatePreimage(verification.targetChecksum, challenge.publicKey.toBuffer(), challenge.salt, useHashedPreimage);
|
|
63
97
|
const computedSolution = this.sha1(calculatedPreimage);
|
|
64
98
|
if (!equals(computedSolution, challenge.solution)) {
|
|
65
99
|
return false;
|
|
@@ -92,7 +126,7 @@ export class EpochValidator {
|
|
|
92
126
|
/**
|
|
93
127
|
* Validate epoch winner from raw data
|
|
94
128
|
*/
|
|
95
|
-
static validateEpochWinner(epochData) {
|
|
129
|
+
static validateEpochWinner(epochData, useHashedPreimage = false) {
|
|
96
130
|
try {
|
|
97
131
|
const epochNumber = BigInt(epochData.epochNumber);
|
|
98
132
|
const publicKey = Address.fromString(epochData.mldsaPublicKey, epochData.legacyPublicKey);
|
|
@@ -108,7 +142,7 @@ export class EpochValidator {
|
|
|
108
142
|
endBlock: BigInt(epochData.verification.endBlock),
|
|
109
143
|
proofs: Object.freeze(epochData.verification.proofs.map((p) => stringToBuffer(p))),
|
|
110
144
|
};
|
|
111
|
-
const calculatedPreimage = this.calculatePreimage(verification.targetChecksum, publicKey.toBuffer(), salt);
|
|
145
|
+
const calculatedPreimage = this.calculatePreimage(verification.targetChecksum, publicKey.toBuffer(), salt, useHashedPreimage);
|
|
112
146
|
const computedSolution = this.sha1(calculatedPreimage);
|
|
113
147
|
if (!equals(computedSolution, solution)) {
|
|
114
148
|
return false;
|
|
@@ -129,8 +163,8 @@ export class EpochValidator {
|
|
|
129
163
|
/**
|
|
130
164
|
* Validate epoch winner from Preimage instance
|
|
131
165
|
*/
|
|
132
|
-
static validateChallengeSolution(challenge) {
|
|
133
|
-
return this.verifySolution(challenge);
|
|
166
|
+
static validateChallengeSolution(challenge, useHashedPreimage = false) {
|
|
167
|
+
return this.verifySolution(challenge, false, useHashedPreimage);
|
|
134
168
|
}
|
|
135
169
|
/**
|
|
136
170
|
* Calculate solution hash from preimage components
|
|
@@ -139,8 +173,8 @@ export class EpochValidator {
|
|
|
139
173
|
* @param salt The salt buffer (32 bytes)
|
|
140
174
|
* @returns The SHA-1 hash of the preimage
|
|
141
175
|
*/
|
|
142
|
-
static calculateSolution(targetChecksum, publicKey, salt) {
|
|
143
|
-
const preimage = this.calculatePreimage(targetChecksum, publicKey, salt);
|
|
176
|
+
static calculateSolution(targetChecksum, publicKey, salt, useHashedPreimage = false) {
|
|
177
|
+
const preimage = this.calculatePreimage(targetChecksum, publicKey, salt, useHashedPreimage);
|
|
144
178
|
return this.sha1(preimage);
|
|
145
179
|
}
|
|
146
180
|
/**
|
|
@@ -361,7 +361,7 @@ export class TransactionFactory {
|
|
|
361
361
|
estimatedFees: resp.estimatedFees,
|
|
362
362
|
tx: resp.tx.toHex(),
|
|
363
363
|
nextUTXOs: this.getAllNewUTXOs(resp.original, resp.tx, parameters.from),
|
|
364
|
-
inputUtxos: parameters.utxos,
|
|
364
|
+
inputUtxos: [...parameters.utxos, ...(parameters.feeUtxos ?? [])],
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
367
|
/**
|
|
@@ -22,9 +22,12 @@ export class FundingTransaction extends TransactionBuilder {
|
|
|
22
22
|
throw new Error('Recipient address is required');
|
|
23
23
|
}
|
|
24
24
|
this.addInputsFromUTXO();
|
|
25
|
-
// When autoAdjustAmount is enabled
|
|
26
|
-
//
|
|
27
|
-
|
|
25
|
+
// When autoAdjustAmount is enabled, estimate the fee against the final
|
|
26
|
+
// transaction shape and reduce the output amount whenever amount + fee
|
|
27
|
+
// would exceed the total input. This covers both the send-max case
|
|
28
|
+
// (amount == totalInput) and the near-total case where amount alone fits
|
|
29
|
+
// but leaves no room for the requested feeRate.
|
|
30
|
+
if (this.autoAdjustAmount) {
|
|
28
31
|
// Add temporary outputs matching the ACTUAL final transaction shape
|
|
29
32
|
// so the fee estimate accounts for the real vsize.
|
|
30
33
|
const numOutputs = this.splitInputsInto > 1 ? this.splitInputsInto : 1;
|
|
@@ -48,21 +51,25 @@ export class FundingTransaction extends TransactionBuilder {
|
|
|
48
51
|
});
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
|
-
// If a note is present, add a temporary OP_RETURN since it affects vsize.
|
|
52
54
|
if (this.note) {
|
|
53
55
|
this.addOPReturn(this.note);
|
|
54
56
|
}
|
|
57
|
+
if (this.anchor) {
|
|
58
|
+
this.addAnchor();
|
|
59
|
+
}
|
|
55
60
|
const estimatedFee = await this.estimateTransactionFees();
|
|
56
61
|
// Remove all temporary outputs.
|
|
57
|
-
const tempCount = numOutputs + (this.note ? 1 : 0);
|
|
62
|
+
const tempCount = numOutputs + (this.note ? 1 : 0) + (this.anchor ? 1 : 0);
|
|
58
63
|
for (let i = 0; i < tempCount; i++) {
|
|
59
64
|
this.outputs.pop();
|
|
60
65
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
if (this.amount + estimatedFee > this.totalInputAmount) {
|
|
67
|
+
const adjustedAmount = this.totalInputAmount - estimatedFee;
|
|
68
|
+
if (adjustedAmount < TransactionBuilder.MINIMUM_DUST) {
|
|
69
|
+
throw new Error(`Insufficient funds: after deducting fee of ${estimatedFee} sats, remaining amount ${adjustedAmount} sats is below minimum dust`);
|
|
70
|
+
}
|
|
71
|
+
this.amount = adjustedAmount;
|
|
64
72
|
}
|
|
65
|
-
this.amount = adjustedAmount;
|
|
66
73
|
}
|
|
67
74
|
// Add the primary output(s) first
|
|
68
75
|
if (this.splitInputsInto > 1) {
|
|
@@ -70,10 +77,7 @@ export class FundingTransaction extends TransactionBuilder {
|
|
|
70
77
|
}
|
|
71
78
|
else if (this.isPubKeyDestination) {
|
|
72
79
|
const toHexClean = this.to.startsWith('0x') ? this.to.slice(2) : this.to;
|
|
73
|
-
const pubKeyScript = script.compile([
|
|
74
|
-
fromHex(toHexClean),
|
|
75
|
-
opcodes.OP_CHECKSIG,
|
|
76
|
-
]);
|
|
80
|
+
const pubKeyScript = script.compile([fromHex(toHexClean), opcodes.OP_CHECKSIG]);
|
|
77
81
|
this.addOutput({
|
|
78
82
|
value: toSatoshi(this.amount),
|
|
79
83
|
script: pubKeyScript,
|
|
@@ -12,6 +12,7 @@ import { type Feature, Features } from '../../generators/Features.js';
|
|
|
12
12
|
export declare const MINIMUM_AMOUNT_REWARD: bigint;
|
|
13
13
|
export declare const MINIMUM_AMOUNT_CA: bigint;
|
|
14
14
|
export declare const ANCHOR_SCRIPT: Uint8Array<ArrayBufferLike>;
|
|
15
|
+
export declare const TOLERANCE = 1;
|
|
15
16
|
/**
|
|
16
17
|
* Allows to build a transaction like you would on Ethereum.
|
|
17
18
|
* @description The transaction builder class
|
|
@@ -17,6 +17,10 @@ import { getLevelFromPublicKeyLength } from '../../generators/MLDSAData.js';
|
|
|
17
17
|
export const MINIMUM_AMOUNT_REWARD = 330n; //540n;
|
|
18
18
|
export const MINIMUM_AMOUNT_CA = 297n;
|
|
19
19
|
export const ANCHOR_SCRIPT = fromHex('51024e73');
|
|
20
|
+
// Tolerance for fees
|
|
21
|
+
// Less than 1 is percentage: 0.10 equals +- 10%
|
|
22
|
+
// 1 or more is satoshis, 5 equals +- 5 sat
|
|
23
|
+
export const TOLERANCE = 1;
|
|
20
24
|
/**
|
|
21
25
|
* Allows to build a transaction like you would on Ethereum.
|
|
22
26
|
* @description The transaction builder class
|
|
@@ -284,10 +288,23 @@ export class TransactionBuilder extends TweakedTransaction {
|
|
|
284
288
|
* @throws {Error} - If something went wrong
|
|
285
289
|
*/
|
|
286
290
|
async signPSBT() {
|
|
287
|
-
if (
|
|
288
|
-
|
|
291
|
+
if (!this.utxos.length)
|
|
292
|
+
throw new Error('No UTXOs specified');
|
|
293
|
+
if (this.to &&
|
|
294
|
+
!this.isPubKeyDestination &&
|
|
295
|
+
!EcKeyPair.verifyContractAddress(this.to, this.network)) {
|
|
296
|
+
throw new Error('Invalid contract address. The contract address must be a taproot address.');
|
|
289
297
|
}
|
|
290
|
-
|
|
298
|
+
if (this.signed)
|
|
299
|
+
throw new Error('Transaction is already signed');
|
|
300
|
+
this.signed = true;
|
|
301
|
+
await this.buildTransaction();
|
|
302
|
+
const built = await this.internalBuildTransaction(this.transaction);
|
|
303
|
+
if (!built)
|
|
304
|
+
throw new Error('Could not sign transaction');
|
|
305
|
+
if (this.regenerated)
|
|
306
|
+
throw new Error('Transaction was regenerated');
|
|
307
|
+
return this.transaction;
|
|
291
308
|
}
|
|
292
309
|
/**
|
|
293
310
|
* Add an input to the transaction.
|
|
@@ -619,13 +636,21 @@ export class TransactionBuilder extends TweakedTransaction {
|
|
|
619
636
|
if (this.debugFees) {
|
|
620
637
|
this.warn(`Amount to send back (${sendBackAmount} sat) is less than minimum dust. Fee without change: ${feeWithoutChange} sats`);
|
|
621
638
|
}
|
|
622
|
-
if (this.totalInputAmount <= amountSpent) {
|
|
623
|
-
throw new Error(`Insufficient funds: need ${amountSpent + feeWithoutChange} sats but only have ${this.totalInputAmount} sats`);
|
|
624
|
-
}
|
|
625
639
|
if (expectRefund && sendBackAmount < 0n) {
|
|
626
|
-
throw new Error(`Insufficient funds: need at least ${-sendBackAmount} more sats to cover fees.`);
|
|
640
|
+
throw new Error(`Insufficient funds for fee: need at least ${-sendBackAmount} more sats to cover fees.`);
|
|
627
641
|
}
|
|
628
642
|
}
|
|
643
|
+
const amountNeeded = amountSpent + this.transactionFee;
|
|
644
|
+
const tolerance = TOLERANCE < 1
|
|
645
|
+
? BigInt(Math.ceil(Number(this.transactionFee) * TOLERANCE))
|
|
646
|
+
: BigInt(Math.ceil(TOLERANCE));
|
|
647
|
+
if (this.totalInputAmount < amountSpent) {
|
|
648
|
+
throw new Error(`Insufficient funds: need ${amountNeeded} sats but only have ${this.totalInputAmount} sats`);
|
|
649
|
+
}
|
|
650
|
+
if (this.totalInputAmount < amountNeeded - tolerance) {
|
|
651
|
+
const missingAmount = amountNeeded - this.totalInputAmount - tolerance;
|
|
652
|
+
throw new Error(`Insufficient funds for fee: need at least ${missingAmount} more sats to cover fees.`);
|
|
653
|
+
}
|
|
629
654
|
if (this.debugFees) {
|
|
630
655
|
this.log(`Final fee: ${this.transactionFee} sats, Change output: ${this.feeOutput ? `${this.feeOutput.value} sats` : 'none'}`);
|
|
631
656
|
}
|
|
@@ -5,6 +5,19 @@ import { TransactionBuilder } from '../builders/TransactionBuilder.js';
|
|
|
5
5
|
import type { ISerializableTransactionState, PrecomputedData } from './interfaces/ISerializableState.js';
|
|
6
6
|
import { type ReconstructionOptions } from './TransactionReconstructor.js';
|
|
7
7
|
import type { IDeploymentParameters, IFundingTransactionParameters, IInteractionParameters, ITransactionParameters } from '../interfaces/ITransactionParameters.js';
|
|
8
|
+
/**
|
|
9
|
+
* Per-input CSV multisig signing status.
|
|
10
|
+
*/
|
|
11
|
+
export interface CSVMultisigInputStatus {
|
|
12
|
+
/** PSBT input index */
|
|
13
|
+
readonly inputIndex: number;
|
|
14
|
+
/** Threshold required to spend this input */
|
|
15
|
+
readonly required: number;
|
|
16
|
+
/** Number of distinct cosigner signatures collected so far */
|
|
17
|
+
readonly collected: number;
|
|
18
|
+
/** Hex-encoded x-only pubkeys of the cosigners that have signed */
|
|
19
|
+
readonly signers: string[];
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* Export options for offline transaction signing
|
|
10
23
|
*/
|
|
@@ -246,4 +259,41 @@ export declare class OfflineTransactionManager {
|
|
|
246
259
|
* @returns Updated state
|
|
247
260
|
*/
|
|
248
261
|
static multiSigUpdatePsbt(serializedState: string, psbtBase64: string): string;
|
|
262
|
+
/**
|
|
263
|
+
* Inspect collaborative signing progress on all CSV multisig inputs.
|
|
264
|
+
* Returns an empty array when no partial PSBT has been produced yet.
|
|
265
|
+
*/
|
|
266
|
+
static csvMultisigGetStatus(serializedState: string): CSVMultisigInputStatus[];
|
|
267
|
+
/**
|
|
268
|
+
* Add the given signer's contribution to every CSV multisig input it can sign.
|
|
269
|
+
* No-op for inputs whose tapscript does not contain the signer's x-only pubkey.
|
|
270
|
+
*
|
|
271
|
+
* First call (no partial PSBT yet) builds the PSBT from the funding params
|
|
272
|
+
* using the provided signer as the builder's signer. Subsequent calls load
|
|
273
|
+
* the carried PSBT and add the signer's tapScriptSig where applicable.
|
|
274
|
+
*/
|
|
275
|
+
static addCSVMultisigSignature(serializedState: string, signer: Signer | UniversalSigner): Promise<{
|
|
276
|
+
state: string;
|
|
277
|
+
final: boolean;
|
|
278
|
+
perInput: CSVMultisigInputStatus[];
|
|
279
|
+
}>;
|
|
280
|
+
/**
|
|
281
|
+
* Finalize every CSV multisig input on the partial PSBT and extract the
|
|
282
|
+
* raw transaction hex. Throws if any input is still below its threshold.
|
|
283
|
+
*/
|
|
284
|
+
static csvMultisigFinalize(serializedState: string): string;
|
|
285
|
+
/**
|
|
286
|
+
* Sign every CSV multisig input whose tapscript contains the signer's
|
|
287
|
+
* x-only pubkey and does not yet carry a signature from that pubkey.
|
|
288
|
+
*/
|
|
289
|
+
private static addSignerToCSVInputs;
|
|
290
|
+
/**
|
|
291
|
+
* Build per-input status for every CSV multisig input.
|
|
292
|
+
*
|
|
293
|
+
* Walks state.utxos so we can detect CSV multisig inputs even after the
|
|
294
|
+
* builder has already finalized them (which clears tapLeafScript). For
|
|
295
|
+
* partially-signed inputs the signers list reflects collected tapScriptSig
|
|
296
|
+
* entries; for finalized inputs collected is set to the threshold.
|
|
297
|
+
*/
|
|
298
|
+
private static computeCSVMultisigStatus;
|
|
249
299
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fromHex, Psbt, toHex } from '@btc-vision/bitcoin';
|
|
1
|
+
import { fromHex, Psbt, toHex, toXOnly } from '@btc-vision/bitcoin';
|
|
2
2
|
import {} from '@btc-vision/ecpair';
|
|
3
3
|
import { TransactionType } from '../enums/TransactionType.js';
|
|
4
4
|
import { TransactionBuilder } from '../builders/TransactionBuilder.js';
|
|
@@ -7,6 +7,7 @@ import { TransactionSerializer } from './TransactionSerializer.js';
|
|
|
7
7
|
import { TransactionReconstructor, } from './TransactionReconstructor.js';
|
|
8
8
|
import { TransactionStateCapture } from './TransactionStateCapture.js';
|
|
9
9
|
import { isMultiSigSpecificData } from './interfaces/ITypeSpecificData.js';
|
|
10
|
+
import { CSVMultisigProvider } from '../mineable/CSVMultisigProvider.js';
|
|
10
11
|
/**
|
|
11
12
|
* Main entry point for offline transaction signing workflow.
|
|
12
13
|
*
|
|
@@ -435,4 +436,144 @@ export class OfflineTransactionManager {
|
|
|
435
436
|
};
|
|
436
437
|
return TransactionSerializer.toBase64(newState);
|
|
437
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Inspect collaborative signing progress on all CSV multisig inputs.
|
|
441
|
+
* Returns an empty array when no partial PSBT has been produced yet.
|
|
442
|
+
*/
|
|
443
|
+
static csvMultisigGetStatus(serializedState) {
|
|
444
|
+
const state = TransactionSerializer.fromBase64(serializedState);
|
|
445
|
+
if (!state.partialPsbtBase64)
|
|
446
|
+
return [];
|
|
447
|
+
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
|
|
448
|
+
const psbt = Psbt.fromBase64(state.partialPsbtBase64, { network });
|
|
449
|
+
return this.computeCSVMultisigStatus(state, psbt, network);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Add the given signer's contribution to every CSV multisig input it can sign.
|
|
453
|
+
* No-op for inputs whose tapscript does not contain the signer's x-only pubkey.
|
|
454
|
+
*
|
|
455
|
+
* First call (no partial PSBT yet) builds the PSBT from the funding params
|
|
456
|
+
* using the provided signer as the builder's signer. Subsequent calls load
|
|
457
|
+
* the carried PSBT and add the signer's tapScriptSig where applicable.
|
|
458
|
+
*/
|
|
459
|
+
static async addCSVMultisigSignature(serializedState, signer) {
|
|
460
|
+
const state = TransactionSerializer.fromBase64(serializedState);
|
|
461
|
+
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
|
|
462
|
+
let psbt;
|
|
463
|
+
if (state.partialPsbtBase64) {
|
|
464
|
+
psbt = Psbt.fromBase64(state.partialPsbtBase64, { network });
|
|
465
|
+
this.addSignerToCSVInputs(psbt, signer, network);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const builder = this.importForSigning(serializedState, { signer });
|
|
469
|
+
psbt = await builder.signPSBT();
|
|
470
|
+
// Best-effort: ensure the signer's contribution is recorded on every
|
|
471
|
+
// CSV input where they're a cosigner (the builder already attempts
|
|
472
|
+
// this, but this guards against builds that bailed early).
|
|
473
|
+
this.addSignerToCSVInputs(psbt, signer, network);
|
|
474
|
+
}
|
|
475
|
+
const perInput = this.computeCSVMultisigStatus(state, psbt, network);
|
|
476
|
+
const final = perInput.length > 0 && perInput.every((s) => s.collected >= s.required);
|
|
477
|
+
const newState = {
|
|
478
|
+
...state,
|
|
479
|
+
partialPsbtBase64: psbt.toBase64(),
|
|
480
|
+
};
|
|
481
|
+
return {
|
|
482
|
+
state: TransactionSerializer.toBase64(newState),
|
|
483
|
+
final,
|
|
484
|
+
perInput,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Finalize every CSV multisig input on the partial PSBT and extract the
|
|
489
|
+
* raw transaction hex. Throws if any input is still below its threshold.
|
|
490
|
+
*/
|
|
491
|
+
static csvMultisigFinalize(serializedState) {
|
|
492
|
+
const state = TransactionSerializer.fromBase64(serializedState);
|
|
493
|
+
if (!state.partialPsbtBase64) {
|
|
494
|
+
throw new Error('No partial PSBT in state — call addCSVMultisigSignature first');
|
|
495
|
+
}
|
|
496
|
+
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
|
|
497
|
+
const psbt = Psbt.fromBase64(state.partialPsbtBase64, { network });
|
|
498
|
+
for (let i = 0; i < psbt.data.inputs.length; i++) {
|
|
499
|
+
const input = psbt.data.inputs[i];
|
|
500
|
+
if (!input.tapLeafScript || input.tapLeafScript.length === 0)
|
|
501
|
+
continue;
|
|
502
|
+
const leaf = input.tapLeafScript[0];
|
|
503
|
+
if (!leaf)
|
|
504
|
+
continue;
|
|
505
|
+
const addr = CSVMultisigProvider.deriveAddressFromTapscript(leaf.script, network);
|
|
506
|
+
if (!addr)
|
|
507
|
+
continue;
|
|
508
|
+
CSVMultisigProvider.finalizePsbtInput(psbt, i, network, addr);
|
|
509
|
+
}
|
|
510
|
+
return psbt.extractTransaction(true, true).toHex();
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Sign every CSV multisig input whose tapscript contains the signer's
|
|
514
|
+
* x-only pubkey and does not yet carry a signature from that pubkey.
|
|
515
|
+
*/
|
|
516
|
+
static addSignerToCSVInputs(psbt, signer, network) {
|
|
517
|
+
const signerXOnly = toXOnly(signer.publicKey);
|
|
518
|
+
const signerXOnlyHex = toHex(signerXOnly);
|
|
519
|
+
for (let i = 0; i < psbt.data.inputs.length; i++) {
|
|
520
|
+
const input = psbt.data.inputs[i];
|
|
521
|
+
if (!input.tapLeafScript || input.tapLeafScript.length === 0)
|
|
522
|
+
continue;
|
|
523
|
+
const leaf = input.tapLeafScript[0];
|
|
524
|
+
if (!leaf)
|
|
525
|
+
continue;
|
|
526
|
+
const addr = CSVMultisigProvider.deriveAddressFromTapscript(leaf.script, network);
|
|
527
|
+
if (!addr)
|
|
528
|
+
continue;
|
|
529
|
+
const isCosigner = addr.config.pubkeys.some((pk) => toHex(pk) === signerXOnlyHex);
|
|
530
|
+
if (!isCosigner)
|
|
531
|
+
continue;
|
|
532
|
+
const alreadySigned = (input.tapScriptSig ?? []).some((s) => toHex(s.pubkey) === signerXOnlyHex);
|
|
533
|
+
if (alreadySigned)
|
|
534
|
+
continue;
|
|
535
|
+
psbt.signTaprootInput(i, signer);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Build per-input status for every CSV multisig input.
|
|
540
|
+
*
|
|
541
|
+
* Walks state.utxos so we can detect CSV multisig inputs even after the
|
|
542
|
+
* builder has already finalized them (which clears tapLeafScript). For
|
|
543
|
+
* partially-signed inputs the signers list reflects collected tapScriptSig
|
|
544
|
+
* entries; for finalized inputs collected is set to the threshold.
|
|
545
|
+
*/
|
|
546
|
+
static computeCSVMultisigStatus(state, psbt, network) {
|
|
547
|
+
const result = [];
|
|
548
|
+
for (let i = 0; i < state.utxos.length; i++) {
|
|
549
|
+
const utxo = state.utxos[i];
|
|
550
|
+
if (!utxo?.witnessScript)
|
|
551
|
+
continue;
|
|
552
|
+
const tapscript = fromHex(utxo.witnessScript);
|
|
553
|
+
const addr = CSVMultisigProvider.deriveAddressFromTapscript(tapscript, network);
|
|
554
|
+
if (!addr)
|
|
555
|
+
continue;
|
|
556
|
+
const input = psbt.data.inputs[i];
|
|
557
|
+
if (!input)
|
|
558
|
+
continue;
|
|
559
|
+
if (input.finalScriptWitness) {
|
|
560
|
+
// Already finalized — by construction it carried >= threshold sigs.
|
|
561
|
+
result.push({
|
|
562
|
+
inputIndex: i,
|
|
563
|
+
required: addr.config.threshold,
|
|
564
|
+
collected: addr.config.threshold,
|
|
565
|
+
signers: [],
|
|
566
|
+
});
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const tapSigs = input.tapScriptSig ?? [];
|
|
570
|
+
result.push({
|
|
571
|
+
inputIndex: i,
|
|
572
|
+
required: addr.config.threshold,
|
|
573
|
+
collected: tapSigs.length,
|
|
574
|
+
signers: tapSigs.map((s) => toHex(s.pubkey)),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
438
579
|
}
|
|
@@ -35,6 +35,8 @@ export class TransactionSerializer {
|
|
|
35
35
|
this.writeTypeSpecificData(writer, state.typeSpecificData);
|
|
36
36
|
// Write precomputed data
|
|
37
37
|
this.writePrecomputedData(writer, state.precomputedData);
|
|
38
|
+
// v2: partial PSBT (empty string when absent)
|
|
39
|
+
writer.writeStringWithLength(state.partialPsbtBase64 ?? '');
|
|
38
40
|
// Get buffer and calculate checksum
|
|
39
41
|
const dataBuffer = writer.getBuffer();
|
|
40
42
|
const checksum = this.calculateChecksum(dataBuffer);
|
|
@@ -82,6 +84,12 @@ export class TransactionSerializer {
|
|
|
82
84
|
const typeSpecificData = this.readTypeSpecificData(reader, header.transactionType);
|
|
83
85
|
// Read precomputed data
|
|
84
86
|
const precomputedData = this.readPrecomputedData(reader);
|
|
87
|
+
// v2+: partial PSBT (collaborative signing carryover)
|
|
88
|
+
let partialPsbtBase64;
|
|
89
|
+
if (header.formatVersion >= 2) {
|
|
90
|
+
const stored = reader.readStringWithLength();
|
|
91
|
+
partialPsbtBase64 = stored.length > 0 ? stored : undefined;
|
|
92
|
+
}
|
|
85
93
|
return {
|
|
86
94
|
header,
|
|
87
95
|
baseParams,
|
|
@@ -92,6 +100,7 @@ export class TransactionSerializer {
|
|
|
92
100
|
signerMappings,
|
|
93
101
|
typeSpecificData,
|
|
94
102
|
precomputedData,
|
|
103
|
+
...(partialPsbtBase64 !== undefined ? { partialPsbtBase64 } : {}),
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
106
|
/**
|
|
@@ -2,9 +2,10 @@ import { TransactionType } from '../../enums/TransactionType.js';
|
|
|
2
2
|
import { ChainId } from '../../../network/ChainId.js';
|
|
3
3
|
import type { TypeSpecificData } from './ITypeSpecificData.js';
|
|
4
4
|
/**
|
|
5
|
-
* Format version for serialization compatibility
|
|
5
|
+
* Format version for serialization compatibility.
|
|
6
|
+
* v2 adds the optional `partialPsbtBase64` field on the top-level state.
|
|
6
7
|
*/
|
|
7
|
-
export declare const SERIALIZATION_FORMAT_VERSION =
|
|
8
|
+
export declare const SERIALIZATION_FORMAT_VERSION = 2;
|
|
8
9
|
/**
|
|
9
10
|
* Magic byte for identifying serialized transaction state
|
|
10
11
|
*/
|
|
@@ -129,4 +130,11 @@ export interface ISerializableTransactionState {
|
|
|
129
130
|
readonly typeSpecificData: TypeSpecificData;
|
|
130
131
|
/** Pre-computed data for deterministic rebuild */
|
|
131
132
|
readonly precomputedData: PrecomputedData;
|
|
133
|
+
/**
|
|
134
|
+
* Partial PSBT (base64) carrying signatures collected so far.
|
|
135
|
+
* Used by collaborative flows like CSV multisig where multiple cosigners
|
|
136
|
+
* accumulate signatures across hops before finalization.
|
|
137
|
+
* Only present once at least one signing hop has run.
|
|
138
|
+
*/
|
|
139
|
+
readonly partialPsbtBase64?: string;
|
|
132
140
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { TransactionType } from '../../enums/TransactionType.js';
|
|
2
2
|
import { ChainId } from '../../../network/ChainId.js';
|
|
3
3
|
/**
|
|
4
|
-
* Format version for serialization compatibility
|
|
4
|
+
* Format version for serialization compatibility.
|
|
5
|
+
* v2 adds the optional `partialPsbtBase64` field on the top-level state.
|
|
5
6
|
*/
|
|
6
|
-
export const SERIALIZATION_FORMAT_VERSION =
|
|
7
|
+
export const SERIALIZATION_FORMAT_VERSION = 2;
|
|
7
8
|
/**
|
|
8
9
|
* Magic byte for identifying serialized transaction state
|
|
9
10
|
*/
|
|
@@ -580,10 +580,23 @@ export class TweakedTransaction extends Logger {
|
|
|
580
580
|
await this.signInputsSequential(transaction);
|
|
581
581
|
}
|
|
582
582
|
}
|
|
583
|
+
let allFinalized = true;
|
|
583
584
|
for (let i = 0; i < transaction.data.inputs.length; i++) {
|
|
584
|
-
|
|
585
|
+
try {
|
|
586
|
+
transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this));
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
// CSV multisig inputs may legitimately be below threshold during
|
|
590
|
+
// a collaborative signing hop. Leave them unfinalized so the
|
|
591
|
+
// partial PSBT can travel to the next cosigner.
|
|
592
|
+
if (this.csvMultisigInputs.has(i)) {
|
|
593
|
+
allFinalized = false;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
throw e;
|
|
597
|
+
}
|
|
585
598
|
}
|
|
586
|
-
this.finalized =
|
|
599
|
+
this.finalized = allFinalized;
|
|
587
600
|
}
|
|
588
601
|
/**
|
|
589
602
|
* Signs all inputs sequentially in batches of 20.
|
|
@@ -1155,11 +1168,20 @@ export class TweakedTransaction extends Logger {
|
|
|
1155
1168
|
const signer = this.signer;
|
|
1156
1169
|
// then, we sign all the remaining inputs with the wallet signer.
|
|
1157
1170
|
await signer.multiSignPsbt([transaction]);
|
|
1158
|
-
|
|
1171
|
+
let allFinalized = true;
|
|
1159
1172
|
for (let i = 0; i < transaction.data.inputs.length; i++) {
|
|
1160
|
-
|
|
1173
|
+
try {
|
|
1174
|
+
transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this));
|
|
1175
|
+
}
|
|
1176
|
+
catch (e) {
|
|
1177
|
+
if (this.csvMultisigInputs.has(i)) {
|
|
1178
|
+
allFinalized = false;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
throw e;
|
|
1182
|
+
}
|
|
1161
1183
|
}
|
|
1162
|
-
this.finalized =
|
|
1184
|
+
this.finalized = allFinalized;
|
|
1163
1185
|
}
|
|
1164
1186
|
isCSVScript(decompiled) {
|
|
1165
1187
|
return decompiled.some((op) => op === opcodes.OP_CHECKSEQUENCEVERIFY);
|