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..f9f9871de 100644 --- a/packages/cashscript/src/constants.ts +++ b/packages/cashscript/src/constants.ts @@ -1,3 +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; +// 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 fe9b9922e..bb81be5b7 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 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 exactly 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).toBe(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);