From 7a00c1b86f96fbcda2a83007bd9932332d108f81 Mon Sep 17 00:00:00 2001 From: Mathieu Geukens Date: Mon, 14 Sep 2026 10:45:49 +0200 Subject: [PATCH 1/2] fix: size placeholder P2PKH inputs at their signed size Transactions built for WalletConnect signing came out below the 1 sat/byte relay floor. placeholderP2PKHUnlocker() emits an empty unlocking script, so every placeholder input was counted at 41 bytes instead of the ~141 bytes it occupies once the wallet signs it. getTransactionSize() now adds the eventual unlocking script size per placeholder input, which fixes the fee underpayment in addBchChangeOutputIfNeeded() and the inflated rate reported by calculateTransactionFee(). checkFee() and checkTransactionSize() measure the transaction themselves rather than going through getTransactionSize(), so they use the same helper. Placeholders are sized for the 73-byte DER upper bound so ECDSA-signing wallets are covered; Schnorr signatures are 8 bytes smaller. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BGbLwCE6LmmLX5r6itABRx --- packages/cashscript/src/TransactionBuilder.ts | 15 ++++++-- packages/cashscript/src/constants.ts | 3 ++ .../test/TransactionBuilder.test.ts | 35 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/cashscript/src/TransactionBuilder.ts b/packages/cashscript/src/TransactionBuilder.ts index bddbb2985..7e0e54368 100644 --- a/packages/cashscript/src/TransactionBuilder.ts +++ b/packages/cashscript/src/TransactionBuilder.ts @@ -19,9 +19,11 @@ import { StandardUnlockableUtxo, VmResourceUsage, isContractUnlocker, + isPlaceholderUnlocker, BchChangeOutputOptions, TokenChangeOutputOptions, } from './interfaces.js'; +import { PLACEHOLDER_P2PKH_UNLOCKING_SIZE } from './constants.js'; import { NetworkProvider } from './network/index.js'; import { calculateDust, @@ -278,12 +280,19 @@ export class TransactionBuilder { /** * Build the transaction (skipping fee and burn checks) and return its encoded byte length. + * Inputs with a placeholder unlocker are counted at the size they take up once the wallet signs them. * * @returns The size of the transaction in bytes. */ getTransactionSize(): bigint { const transaction = this.buildLibauthTransaction(true); - return BigInt(encodeTransaction(transaction).byteLength); + return BigInt(this.getEncodedTransactionSize(transaction)); + } + + // Placeholder unlockers serialise as an empty unlocking script, so their eventual signed size is added here + private getEncodedTransactionSize(transaction: LibauthTransaction): number { + const placeholderInputCount = this.inputs.filter((input) => isPlaceholderUnlocker(input.unlocker)).length; + return encodeTransaction(transaction).byteLength + placeholderInputCount * PLACEHOLDER_P2PKH_UNLOCKING_SIZE; } /** @@ -561,7 +570,7 @@ export class TransactionBuilder { private checkFee(transaction: LibauthTransaction): void { const totalInputAmount = this.inputs.reduce((total, input) => total + input.satoshis, 0n); const totalOutputAmount = this.outputs.reduce((total, output) => total + output.amount, 0n); - const transactionSize = encodeTransaction(transaction).byteLength; + const transactionSize = this.getEncodedTransactionSize(transaction); const fee = totalInputAmount - totalOutputAmount; const feePerByte = Number((Number(fee) / transactionSize).toFixed(2)); @@ -623,7 +632,7 @@ export class TransactionBuilder { } private checkTransactionSize(transaction: LibauthTransaction): void { - const transactionSize = encodeTransaction(transaction).byteLength; + const transactionSize = this.getEncodedTransactionSize(transaction); const TX_MAX_STANDARD_SIZE = 100_000; if (transactionSize > TX_MAX_STANDARD_SIZE) { diff --git a/packages/cashscript/src/constants.ts b/packages/cashscript/src/constants.ts index 3093f1d2e..f9cd91078 100644 --- a/packages/cashscript/src/constants.ts +++ b/packages/cashscript/src/constants.ts @@ -1,3 +1,6 @@ export const VERSION_SIZE = 4; export const LOCKTIME_SIZE = 4; export const P2PKH_INPUT_SIZE = 32 + 4 + 1 + 1 + 65 + 1 + 33 + 4; +// Unlocking script a wallet produces for a P2PKH input: push(signature) + push(public key). +// Sized for the 73-byte DER upper bound so ECDSA-signing wallets are covered, Schnorr is 8 bytes smaller. +export const PLACEHOLDER_P2PKH_UNLOCKING_SIZE = 1 + 73 + 1 + 33; diff --git a/packages/cashscript/test/TransactionBuilder.test.ts b/packages/cashscript/test/TransactionBuilder.test.ts index fe9b9922e..64fe20992 100644 --- a/packages/cashscript/test/TransactionBuilder.test.ts +++ b/packages/cashscript/test/TransactionBuilder.test.ts @@ -250,6 +250,41 @@ describe('Transaction Builder', () => { }); }); + describe('test TransactionBuilder.getTransactionSize', () => { + it('should size placeholder P2PKH inputs as signed inputs', async () => { + const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo); + + const placeholderSize = new TransactionBuilder({ provider }) + .addInput(aliceUtxos[0], placeholderP2PKHUnlocker(aliceAddress)) + .addOutput({ to: aliceAddress, amount: 1000n }) + .getTransactionSize(); + + const signedSize = new TransactionBuilder({ provider }) + .addInput(aliceUtxos[0], new SignatureTemplate(alicePriv).unlockP2PKH()) + .addOutput({ to: aliceAddress, amount: 1000n }) + .getTransactionSize(); + + // the signed size carries a 65-byte Schnorr signature, the placeholder is sized for the 73-byte DER bound + expect(placeholderSize - signedSize).toBe(8n); + }); + + it('should pay at least the fee rate once a placeholder input is signed', async () => { + const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo); + + const builder = new TransactionBuilder({ provider }) + .addInput(aliceUtxos[0], placeholderP2PKHUnlocker(aliceAddress)) + .addOutput({ to: aliceAddress, amount: 1000n }) + .addBchChangeOutputIfNeeded({ to: aliceAddress, feeRate: 1 }); + + const signedSize = new TransactionBuilder({ provider }) + .addInput(aliceUtxos[0], new SignatureTemplate(alicePriv).unlockP2PKH()) + .addOutputs(builder.outputs) + .getTransactionSize(); + + expect(builder.calculateTransactionFee().feeSats).toBeGreaterThanOrEqual(signedSize); + }); + }); + it('should not fail when validly spending from only P2PKH inputs', async () => { const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo); const sigTemplate = new SignatureTemplate(alicePriv); From 1d374ccbbe0259fcb6288eb696c38766feaca12a Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 15 Sep 2026 10:45:13 +0200 Subject: [PATCH 2/2] fix: assume Schnorr --- packages/cashscript/src/constants.ts | 6 ++---- packages/cashscript/test/TransactionBuilder.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/cashscript/src/constants.ts b/packages/cashscript/src/constants.ts index f9cd91078..f9f9871de 100644 --- a/packages/cashscript/src/constants.ts +++ b/packages/cashscript/src/constants.ts @@ -1,6 +1,4 @@ export const VERSION_SIZE = 4; export const LOCKTIME_SIZE = 4; -export const P2PKH_INPUT_SIZE = 32 + 4 + 1 + 1 + 65 + 1 + 33 + 4; -// Unlocking script a wallet produces for a P2PKH input: push(signature) + push(public key). -// Sized for the 73-byte DER upper bound so ECDSA-signing wallets are covered, Schnorr is 8 bytes smaller. -export const PLACEHOLDER_P2PKH_UNLOCKING_SIZE = 1 + 73 + 1 + 33; +// Size of a placeholder P2PKH unlocking script using Schnorr signatures: push(signature) push(pk). +export const PLACEHOLDER_P2PKH_UNLOCKING_SIZE = 1 + 65 + 1 + 33; diff --git a/packages/cashscript/test/TransactionBuilder.test.ts b/packages/cashscript/test/TransactionBuilder.test.ts index 64fe20992..bb81be5b7 100644 --- a/packages/cashscript/test/TransactionBuilder.test.ts +++ b/packages/cashscript/test/TransactionBuilder.test.ts @@ -264,11 +264,11 @@ describe('Transaction Builder', () => { .addOutput({ to: aliceAddress, amount: 1000n }) .getTransactionSize(); - // the signed size carries a 65-byte Schnorr signature, the placeholder is sized for the 73-byte DER bound - expect(placeholderSize - signedSize).toBe(8n); + // the wallet signs with a 65-byte Schnorr signature, so the placeholder is sized exactly like the signed input + expect(placeholderSize).toBe(signedSize); }); - it('should pay at least the fee rate once a placeholder input is signed', async () => { + it('should pay exactly the fee rate once a placeholder input is signed', async () => { const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo); const builder = new TransactionBuilder({ provider }) @@ -281,7 +281,7 @@ describe('Transaction Builder', () => { .addOutputs(builder.outputs) .getTransactionSize(); - expect(builder.calculateTransactionFee().feeSats).toBeGreaterThanOrEqual(signedSize); + expect(builder.calculateTransactionFee().feeSats).toBe(signedSize); }); });