Skip to content

Commit f05ef0d

Browse files
mr-zwetsrkalis
andauthored
fix: size placeholder P2PKH inputs at their signed size (#444)
Co-authored-by: Rosco Kalis <roscokalis@gmail.com>
1 parent 53f286b commit f05ef0d

3 files changed

Lines changed: 49 additions & 4 deletions

File tree

‎packages/cashscript/src/TransactionBuilder.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import {
1919
StandardUnlockableUtxo,
2020
VmResourceUsage,
2121
isContractUnlocker,
22+
isPlaceholderUnlocker,
2223
BchChangeOutputOptions,
2324
TokenChangeOutputOptions,
2425
} from './interfaces.js';
26+
import { PLACEHOLDER_P2PKH_UNLOCKING_SIZE } from './constants.js';
2527
import { NetworkProvider } from './network/index.js';
2628
import {
2729
calculateDust,
@@ -278,12 +280,19 @@ export class TransactionBuilder {
278280

279281
/**
280282
* Build the transaction (skipping fee and burn checks) and return its encoded byte length.
283+
* Inputs with a placeholder unlocker are counted at the size they take up once the wallet signs them.
281284
*
282285
* @returns The size of the transaction in bytes.
283286
*/
284287
getTransactionSize(): bigint {
285288
const transaction = this.buildLibauthTransaction(true);
286-
return BigInt(encodeTransaction(transaction).byteLength);
289+
return BigInt(this.getEncodedTransactionSize(transaction));
290+
}
291+
292+
// Placeholder unlockers serialise as an empty unlocking script, so their eventual signed size is added here
293+
private getEncodedTransactionSize(transaction: LibauthTransaction): number {
294+
const placeholderInputCount = this.inputs.filter((input) => isPlaceholderUnlocker(input.unlocker)).length;
295+
return encodeTransaction(transaction).byteLength + placeholderInputCount * PLACEHOLDER_P2PKH_UNLOCKING_SIZE;
287296
}
288297

289298
/**
@@ -561,7 +570,7 @@ export class TransactionBuilder {
561570
private checkFee(transaction: LibauthTransaction): void {
562571
const totalInputAmount = this.inputs.reduce((total, input) => total + input.satoshis, 0n);
563572
const totalOutputAmount = this.outputs.reduce((total, output) => total + output.amount, 0n);
564-
const transactionSize = encodeTransaction(transaction).byteLength;
573+
const transactionSize = this.getEncodedTransactionSize(transaction);
565574

566575
const fee = totalInputAmount - totalOutputAmount;
567576
const feePerByte = Number((Number(fee) / transactionSize).toFixed(2));
@@ -623,7 +632,7 @@ export class TransactionBuilder {
623632
}
624633

625634
private checkTransactionSize(transaction: LibauthTransaction): void {
626-
const transactionSize = encodeTransaction(transaction).byteLength;
635+
const transactionSize = this.getEncodedTransactionSize(transaction);
627636

628637
const TX_MAX_STANDARD_SIZE = 100_000;
629638
if (transactionSize > TX_MAX_STANDARD_SIZE) {
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const VERSION_SIZE = 4;
22
export const LOCKTIME_SIZE = 4;
3-
export const P2PKH_INPUT_SIZE = 32 + 4 + 1 + 1 + 65 + 1 + 33 + 4;
3+
// Size of a placeholder P2PKH unlocking script using Schnorr signatures: push(signature) push(pk).
4+
export const PLACEHOLDER_P2PKH_UNLOCKING_SIZE = 1 + 65 + 1 + 33;

‎packages/cashscript/test/TransactionBuilder.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,41 @@ describe('Transaction Builder', () => {
250250
});
251251
});
252252

253+
describe('test TransactionBuilder.getTransactionSize', () => {
254+
it('should size placeholder P2PKH inputs as signed inputs', async () => {
255+
const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo);
256+
257+
const placeholderSize = new TransactionBuilder({ provider })
258+
.addInput(aliceUtxos[0], placeholderP2PKHUnlocker(aliceAddress))
259+
.addOutput({ to: aliceAddress, amount: 1000n })
260+
.getTransactionSize();
261+
262+
const signedSize = new TransactionBuilder({ provider })
263+
.addInput(aliceUtxos[0], new SignatureTemplate(alicePriv).unlockP2PKH())
264+
.addOutput({ to: aliceAddress, amount: 1000n })
265+
.getTransactionSize();
266+
267+
// the wallet signs with a 65-byte Schnorr signature, so the placeholder is sized exactly like the signed input
268+
expect(placeholderSize).toBe(signedSize);
269+
});
270+
271+
it('should pay exactly the fee rate once a placeholder input is signed', async () => {
272+
const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo);
273+
274+
const builder = new TransactionBuilder({ provider })
275+
.addInput(aliceUtxos[0], placeholderP2PKHUnlocker(aliceAddress))
276+
.addOutput({ to: aliceAddress, amount: 1000n })
277+
.addBchChangeOutputIfNeeded({ to: aliceAddress, feeRate: 1 });
278+
279+
const signedSize = new TransactionBuilder({ provider })
280+
.addInput(aliceUtxos[0], new SignatureTemplate(alicePriv).unlockP2PKH())
281+
.addOutputs(builder.outputs)
282+
.getTransactionSize();
283+
284+
expect(builder.calculateTransactionFee().feeSats).toBe(signedSize);
285+
});
286+
});
287+
253288
it('should not fail when validly spending from only P2PKH inputs', async () => {
254289
const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo);
255290
const sigTemplate = new SignatureTemplate(alicePriv);

0 commit comments

Comments
 (0)