Skip to content

Feat/fix fee calculations - #312

Open
owl352 wants to merge 5 commits into
dashpay:masterfrom
owl352:feat/fix-fee-calculations
Open

Feat/fix fee calculations#312
owl352 wants to merge 5 commits into
dashpay:masterfrom
owl352:feat/fix-fee-calculations

Conversation

@owl352

@owl352 owl352 commented May 12, 2025

Copy link
Copy Markdown

Issue being fixed or feature implemented

In this PR was removed incorrect calculations for TX Fee.

This is important because the current dashcore-lib release can't properly auto-calculate fees for transactions with many inputs.

What was done?

  • Added MINIMUM_FEE constant which contains minimum fee size in duffs (255)
  • Added setter for MINIMUM_FEE
  • New _estimateSize alghoritm
  • Update calculation of estimateFee

At this moment alghoritm for fee calculation works randomly, and with long steps. Updated code allows to calculate fee based on serialized transaction length

How Has This Been Tested?

  • Implemented new tests for new calculations
  • Updated old tests

Breaking Changes

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

All three blocking defects identified by both reviewers are real and confirmed in lib/transaction/transaction.js. The new _estimateFee produces NaN in the default surcharge branch, returns fractional duffs for non-1000-multiple feePerKb, and the new _estimateSize hard-codes P2PKH script size for every unsigned input (regressing multisig/P2SH fee estimation). Additional suggestions and a JSDoc bug are also valid.

🔴 3 blocking | 🟡 4 suggestion(s) | 💬 2 nitpick(s)

Out-of-scope follow-up suggestions (4)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Transaction.prototype.isFullySigned semantically means "isPartiallySigned" — Pre-existing quirk: isFullySigned uses _.some, returning true if at least one input is signed. The new _estimateSize is gated on this and is the proximate cause of the multi-input under-estimation flagged above, but renaming or changing the semantics has broad API impact and is out of scope for this PR. The in-PR fix should iterate per input instead of relying on the global flag.
    • Follow-up: Open a separate issue to either rename to isPartiallySigned or switch to _.every after a deprecation cycle.
  • New minimumFee() is missing from TypeScript declarationstypings/transaction/Transaction.d.ts exposes feePerKb() then jumps straight to change(); the new minimumFee setter is not declared. TS consumers will not be able to call it without augmentation. This is an API-surface follow-up, separate from the fee-calculation defects above.
    • Follow-up: Follow-up PR to add minimumFee(amount: number): Transaction to typings and cover with npm run test:types.
  • Pre-existing GovObject/Proposal Invalid Timespan baseline failures — Per the project review skill, npm test on master can fail in GovObject/Proposal tests unrelated to this PR. CI should distinguish baseline failures from PR-introduced regressions.
    • Follow-up: Track baseline GovObject test repair in a dedicated issue.
  • Karma client mocha timeout raised to 60skarma.conf.js adds client.mocha.timeout = 60000. This is unrelated to fee logic. If needed because the new 30-input fee tests are slow in the browser, the PR description should call that out; otherwise it belongs in a separate change.
    • Follow-up: If unrelated to the fee tests, split into a separate PR with justification.

Comment on lines +1085 to 1087
if (amountAvailable > fee + minimumFee) {
fee += (minimumFee || Transaction.MINIMUM_FEE);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: fee + minimumFee evaluates to NaN in the default path — surcharge branch never fires

Transaction._estimateFee receives minimumFee as the raw this._minimumFee (line 1066), which is undefined for any transaction that has not explicitly called .minimumFee(...). At line 1085, amountAvailable > fee + minimumFee becomes amountAvailable > NaN, which is always false, so the fee += (minimumFee || MINIMUM_FEE) surcharge is silently skipped in the default code path. The branch only works for callers that explicitly call the new setter, producing different fees depending on whether the setter was used. Apply the (minimumFee || Transaction.MINIMUM_FEE) fallback consistently to both the comparison and the assignment (or hoist it into a local).

Suggested change
if (amountAvailable > fee + minimumFee) {
fee += (minimumFee || Transaction.MINIMUM_FEE);
}
Transaction._estimateFee = function (size, amountAvailable, feePerKb, minimumFee) {
var minFee = minimumFee || Transaction.MINIMUM_FEE;
var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000;
var fee = Math.ceil(size * feePerByte);
if (fee < minFee) {
return minFee;
}
if (amountAvailable > fee + minFee) {
fee += minFee;
}
return fee;
};

source: ['claude', 'codex']

Comment on lines +1080 to +1088
var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000;
var fee = size * feePerByte;
if(fee<(minimumFee || Transaction.MINIMUM_FEE)){
return (minimumFee || Transaction.MINIMUM_FEE)
}
if (amountAvailable > fee + minimumFee) {
fee += (minimumFee || Transaction.MINIMUM_FEE);
}
return Math.ceil(size / 1000) * (feePerKb || Transaction.FEE_PER_KB);
return fee;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Per-byte formula returns fractional duffs and will throw downstream

feePerByte = (feePerKb || FEE_PER_KB) / 1000; fee = size * feePerByte is fractional whenever feePerKb is not a multiple of 1000 (e.g., feePerKb(1234) or feePerKb(1500)). The result is passed to _updateChangeOutput (line 1015) which builds new Output({ satoshis: changeAmount = available - fee }). The Output setter calls $.checkArgument(JSUtil.isNaturalNumber(num)) (lib/transaction/output.js) and throws 'Output satoshis is not a natural number'. The previous algorithm always returned an integer via Math.ceil(size/1000) * feePerKb. Wrap with Math.ceil (or Math.round) to preserve the integer-satoshi contract.

Suggested change
var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000;
var fee = size * feePerByte;
if(fee<(minimumFee || Transaction.MINIMUM_FEE)){
return (minimumFee || Transaction.MINIMUM_FEE)
}
if (amountAvailable > fee + minimumFee) {
fee += (minimumFee || Transaction.MINIMUM_FEE);
}
return Math.ceil(size / 1000) * (feePerKb || Transaction.FEE_PER_KB);
return fee;
Transaction._estimateFee = function (size, amountAvailable, feePerKb, minimumFee) {
var minFee = minimumFee || Transaction.MINIMUM_FEE;
var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000;
var fee = Math.ceil(size * feePerByte);
if (fee < minFee) {
return minFee;
}
if (amountAvailable > fee + minFee) {
fee += minFee;
}
return fee;
};

source: ['claude', 'codex']

Comment on lines 1091 to 1103
Transaction.prototype._estimateSize = function () {
var result = Transaction.MAXIMUM_EXTRA_SIZE;
_.each(this.inputs, function (input) {
result += input._estimateSize();
});
_.each(this.outputs, function (output) {
result += output.script.toBuffer().length + 9;
});
return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;
}
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}

return serializedSize;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Hard-coded PublicKeyHashInput.SCRIPT_MAX_SIZE undersizes non-P2PKH unsigned inputs

_estimateSize now adds this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE (107 bytes) for every unsigned input. The previous implementation summed polymorphic input._estimateSize(). PublicKeyInput.SCRIPT_MAX_SIZE is 73 (lib/transaction/input/publickey.js:103), MultiSigInput._estimateSize at lib/transaction/input/multisig.js:252 and MultiSigScriptHashInput._estimateSize at lib/transaction/input/multisigscripthash.js:206 return larger threshold-dependent values (a 2-of-3 P2SH input is ~256+ bytes). For unsigned multisig/P2SH transactions, fees are now significantly under-estimated, which produces under-funded transactions after signing. Restore the per-input polymorphic dispatch.

Suggested change
Transaction.prototype._estimateSize = function () {
var result = Transaction.MAXIMUM_EXTRA_SIZE;
_.each(this.inputs, function (input) {
result += input._estimateSize();
});
_.each(this.outputs, function (output) {
result += output.script.toBuffer().length + 9;
});
return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;
}
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}
return serializedSize;
};
Transaction.prototype._estimateSize = function () {
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if (!this.isFullySigned()) {
_.each(this.inputs, function (input) {
serializedSize += input._estimateSize();
});
}
if (this._missingChange()) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}
return serializedSize;
};

source: ['claude', 'codex']

Comment on lines 1091 to 1103
Transaction.prototype._estimateSize = function () {
var result = Transaction.MAXIMUM_EXTRA_SIZE;
_.each(this.inputs, function (input) {
result += input._estimateSize();
});
_.each(this.outputs, function (output) {
result += output.script.toBuffer().length + 9;
});
return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;
}
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}

return serializedSize;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: isFullySigned() is semantically partial-signed; gate misfires for mixed inputs

Transaction.prototype.isFullySigned (line 1286) uses _.some(...), returning true as soon as ANY input is signed. The new _estimateSize gates the per-input script-size buffer on !isFullySigned(). A transaction with one signed input and N-1 unsigned inputs will skip the buffer entirely, under-estimating fees. Iterate per input and add the buffer only for inputs where input.isFullySigned() is false (this naturally also fixes the polymorphic-size issue above). The underlying isFullySigned semantics quirk is pre-existing and out of scope to rename here.

source: ['claude']

Comment on lines 1091 to 1103
Transaction.prototype._estimateSize = function () {
var result = Transaction.MAXIMUM_EXTRA_SIZE;
_.each(this.inputs, function (input) {
result += input._estimateSize();
});
_.each(this.outputs, function (output) {
result += output.script.toBuffer().length + 9;
});
return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;
}
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}

return serializedSize;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: _missingChange() doesn't reflect actual-output state inside _updateChangeOutput

_missingChange() returns !this._changeScript. Inside _updateChangeOutput (line 1005), the change output is removed via _removeOutput(this._changeIndex) at line 1011 while _changeScript is still set, then getFee() is called. _estimateSize reads _missingChange() === false and skips CHANGE_OUTPUT_MAX_SIZE, even though toString() does not currently contain the change output. The estimate is short by ~62 bytes. The gate likely wants to check whether the change output is currently in this.outputs (e.g., _.isUndefined(this._changeIndex)), not whether a change script has been configured.

source: ['claude']

Comment on lines 1091 to 1103
Transaction.prototype._estimateSize = function () {
var result = Transaction.MAXIMUM_EXTRA_SIZE;
_.each(this.inputs, function (input) {
result += input._estimateSize();
});
_.each(this.outputs, function (output) {
result += output.script.toBuffer().length + 9;
});
return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;
}
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}

return serializedSize;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: _estimateSize triggers full toString() on every mutator, creating O(N²) work

Each from/to/fee/feePerKb/change/minimumFee call invokes _updateChangeOutputgetFee_estimateFee_estimateSize, which now fully serializes the transaction via this.toString(). Building a tx with N inputs and M outputs incrementally becomes O((N+M)²). The previous implementation summed per-component max sizes in O(N+M). The 30-input tests added here pass, but larger downstream callers will feel this. Prefer summing component sizes (matching the polymorphic-input fix above) or cache toString() per dirty-state.

source: ['claude']

Comment on lines +837 to +850
/**
* Manually set the minimum fee's for this transaction. Beware that this resets all the signatures
* for inputs (in further versions, SIGHASH_SINGLE or SIGHASH_NONE signatures will not
* be reset).
*
* @param {number} amount satoshis per KB to be sent
* @return {Transaction} this, for chaining
*/
Transaction.prototype.minimumFee = function (amount) {
$.checkArgument(_.isNumber(amount), 'amount must be a number');
this._minimumFee = amount;
this._updateChangeOutput();
return this;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: New public Transaction.prototype.minimumFee setter has no direct test coverage

The PR adds a new public setter minimumFee(amount) but no new test exercises it — all rely on the default Transaction.MINIMUM_FEE = 255. Combined with the NaN bug in _estimateFee flagged above, this means the setter's two intended branches (floor and surcharge) are completely unverified. Add at least one test that calls .minimumFee(custom) and asserts both (a) the floor (fee falls below custom → returns custom) and (b) the surcharge branch (fee ≥ custom with sufficient available).

source: ['claude']

Comment on lines +837 to +848
/**
* Manually set the minimum fee's for this transaction. Beware that this resets all the signatures
* for inputs (in further versions, SIGHASH_SINGLE or SIGHASH_NONE signatures will not
* be reset).
*
* @param {number} amount satoshis per KB to be sent
* @return {Transaction} this, for chaining
*/
Transaction.prototype.minimumFee = function (amount) {
$.checkArgument(_.isNumber(amount), 'amount must be a number');
this._minimumFee = amount;
this._updateChangeOutput();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: JSDoc for minimumFee is wrong (copy-pasted from feePerKb)

The JSDoc says @param {number} amount satoshis per KB to be sent and the prose says Manually set the minimum fee's (stray apostrophe). The minimum fee is a flat duff amount, not satoshis-per-KB.

Suggested change
/**
* Manually set the minimum fee's for this transaction. Beware that this resets all the signatures
* for inputs (in further versions, SIGHASH_SINGLE or SIGHASH_NONE signatures will not
* be reset).
*
* @param {number} amount satoshis per KB to be sent
* @return {Transaction} this, for chaining
*/
Transaction.prototype.minimumFee = function (amount) {
$.checkArgument(_.isNumber(amount), 'amount must be a number');
this._minimumFee = amount;
this._updateChangeOutput();
/**
* Manually set the minimum fee for this transaction (in duffs). Beware that this
* resets all the signatures for inputs (in further versions, SIGHASH_SINGLE or
* SIGHASH_NONE signatures will not be reset).
*
* @param {number} amount minimum fee in duffs
* @return {Transaction} this, for chaining
*/
Transaction.prototype.minimumFee = function (amount) {
$.checkArgument(_.isNumber(amount), 'amount must be a number');
this._minimumFee = amount;
this._updateChangeOutput();
return this;
};

source: ['claude']

Comment on lines +1079 to 1103
Transaction._estimateFee = function (size, amountAvailable, feePerKb, minimumFee) {
var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000;
var fee = size * feePerByte;
if(fee<(minimumFee || Transaction.MINIMUM_FEE)){
return (minimumFee || Transaction.MINIMUM_FEE)
}
if (amountAvailable > fee + minimumFee) {
fee += (minimumFee || Transaction.MINIMUM_FEE);
}
return Math.ceil(size / 1000) * (feePerKb || Transaction.FEE_PER_KB);
return fee;
};

Transaction.prototype._estimateSize = function () {
var result = Transaction.MAXIMUM_EXTRA_SIZE;
_.each(this.inputs, function (input) {
result += input._estimateSize();
});
_.each(this.outputs, function (output) {
result += output.script.toBuffer().length + 9;
});
return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;
}
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}

return serializedSize;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Inconsistent formatting in new _estimateFee / _estimateSize

if(fee<(minimumFee || Transaction.MINIMUM_FEE)){ lacks spaces around the operator and after if/{; the early-return line lacks a trailing semicolon. _estimateSize uses if ( ... ) with extra inner spaces. Both diverge from the surrounding style used throughout the file. Run npm run lint and apply fixes.

source: ['claude']

@thepastaclaw

thepastaclaw commented Jun 7, 2026

Copy link
Copy Markdown

⛔ Final review complete — 6 blocking finding(s) (commit 3376576) · triage: critical · Phase 2 only (queue backlog)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Phase 2 only (queue backlog)

Verified all six Phase-2 findings against head 3376576 and reproduced the reported runtime failures and missing TypeScript declarations. The fee changes can underfund transactions, reject valid integer fee rates, break serialization with signature checks disabled, and apply the minimum inconsistently. All 1,002 focused transaction tests and the existing type tests pass, but they do not catch these regressions.

Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

Review provenance

  • Triage: critical by gpt-6-astra (effort low) — Changing transaction size estimation and automatic fee calculation directly affects funds spent and transaction acceptance, with potential underpayment or overpayment across input counts and change-output handling.
  • Phase 1 reviewers: not run (skipped for throughput: 56 PRs queued, above the 10 limit)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer

🔴 6 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `lib/transaction/transaction.js`:
- [BLOCKING] lib/transaction/transaction.js:1098-1100: Include the pending change output before allocating change
  _missingChange() checks whether a change address is configured, not whether the transaction currently contains its change output. During _updateChangeOutput(), the change script is already configured and the previous change output has been removed, so this condition skips budgeting for the forthcoming output. A reproduced two-input P2PKH transaction pays 340 duffs but serializes to 373 bytes after signing, below the default 1,000 duffs/kB rate. Its subsequent getFee() call returns 373 rather than the actual 340, so the new getFee() assertions do not detect the underpayment. Include the prospective change output before allocating change and test the actual fee using inputAmount - outputAmount.
- [BLOCKING] lib/transaction/transaction.js:1081: Round the calculated fee to whole duffs
  Multiplying the byte count by feePerKb / 1000 can produce fractional duffs for an integer fee rate. A three-input P2PKH transaction using .feePerKb(1200) has a 488-byte estimate before change, producing a fee of 585.6 duffs. Calling .change(address) then throws "Output satoshis is not a natural number" because _updateChangeOutput() subtracts that fractional fee from the available integer amount. Round the calculated fee upward so output amounts remain integral and the requested rate is preserved.
- [BLOCKING] lib/transaction/transaction.js:1094-1096: Estimate signature growth using each input's script type
  The new estimator replaces input-specific size estimates with a fixed 107-byte P2PKH allowance for every unsigned input. Supported multisig inputs can require more signature bytes: after clearSignatures() installs a 2-of-3 P2SH redeem script, the input still needs two signatures, whose combined size exceeds this allowance. Replaying the updated final fixture in test/data/tx_creation.json produces exactly its expected serialization, but the transaction pays only 586 duffs for 696 bytes at the default 1,000 duffs/kB rate. This signature-growth underestimate is separate from the omitted change output. Use script-type-aware estimates that account for bytes already serialized and any script-length varint growth.
- [BLOCKING] lib/transaction/transaction.js:1094: Do not require signature verification to estimate transaction size
  Transaction.isFullySigned() throws UnableToVerifySignature for generic Input instances, including custom scripts and inputs parsed from serialized transactions. Calling it from _estimateSize() introduces that requirement into the fee checks performed by serialize(), even when disableIsFullySigned is explicitly true. A reproduced transaction spending an OP_TRUE UTXO with previous-output information throws here when serialized with that option; the same transaction serializes successfully when using the base revision's fee and size estimators. Estimate size without requiring signature-verification support so the existing serialization option remains effective.
- [BLOCKING] lib/transaction/transaction.js:1085-1086: Apply minimumFee consistently as a lower bound
  This branch adds the minimum fee to a size-based fee that already meets the minimum. It also treats an omitted minimum differently from an explicitly supplied default because fee + undefined is NaN. For example, _estimateFee(500, 100000, 1000) returns 500, while passing 255 as the fourth argument returns 755. In a reproduced two-input P2PKH transaction, explicitly setting .minimumFee(Transaction.MINIMUM_FEE) changes the actual fee from 340 to 595 duffs even though the floor remains 255 and was already satisfied. Normalize the optional minimum once and apply it as a lower bound, not an additional balance-dependent charge.
- [BLOCKING] lib/transaction/transaction.js:845-850: Declare the new minimum-fee API for TypeScript consumers
  The PR introduces the public minimumFee() method and MINIMUM_FEE constant without adding them to typings/transaction/Transaction.d.ts. Type-checking new Transaction(undefined).minimumFee(500) and Transaction.MINIMUM_FEE produces TS2339 for both, preventing TypeScript consumers from using the new feature through the package declarations. Add the chainable method and static constant declarations, together with test-d coverage. The existing type tests pass because they do not exercise either addition.

Comment on lines +1098 to +1100
if ( this._missingChange() ) {
serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Include the pending change output before allocating change

_missingChange() checks whether a change address is configured, not whether the transaction currently contains its change output. During _updateChangeOutput(), the change script is already configured and the previous change output has been removed, so this condition skips budgeting for the forthcoming output. A reproduced two-input P2PKH transaction pays 340 duffs but serializes to 373 bytes after signing, below the default 1,000 duffs/kB rate. Its subsequent getFee() call returns 373 rather than the actual 340, so the new getFee() assertions do not detect the underpayment. Include the prospective change output before allocating change and test the actual fee using inputAmount - outputAmount.

source: ['claude']

size += Transaction.CHANGE_OUTPUT_MAX_SIZE;
Transaction._estimateFee = function (size, amountAvailable, feePerKb, minimumFee) {
var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000;
var fee = size * feePerByte;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Round the calculated fee to whole duffs

Multiplying the byte count by feePerKb / 1000 can produce fractional duffs for an integer fee rate. A three-input P2PKH transaction using .feePerKb(1200) has a 488-byte estimate before change, producing a fee of 585.6 duffs. Calling .change(address) then throws "Output satoshis is not a natural number" because _updateChangeOutput() subtracts that fractional fee from the available integer amount. Round the calculated fee upward so output amounts remain integral and the requested rate is preserved.

Suggested change
var fee = size * feePerByte;
var fee = Math.ceil(size * feePerByte);

source: ['claude']

Comment on lines +1094 to +1096
if ( !this.isFullySigned() ) {
// every signed input contains some additional bytes
serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Estimate signature growth using each input's script type

The new estimator replaces input-specific size estimates with a fixed 107-byte P2PKH allowance for every unsigned input. Supported multisig inputs can require more signature bytes: after clearSignatures() installs a 2-of-3 P2SH redeem script, the input still needs two signatures, whose combined size exceeds this allowance. Replaying the updated final fixture in test/data/tx_creation.json produces exactly its expected serialization, but the transaction pays only 586 duffs for 696 bytes at the default 1,000 duffs/kB rate. This signature-growth underestimate is separate from the omitted change output. Use script-type-aware estimates that account for bytes already serialized and any script-length varint growth.

source: ['claude']

return result;
// string format HEX -> 2 symbols == 1 byte
var serializedSize = this.toString().length / 2;
if ( !this.isFullySigned() ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Do not require signature verification to estimate transaction size

Transaction.isFullySigned() throws UnableToVerifySignature for generic Input instances, including custom scripts and inputs parsed from serialized transactions. Calling it from _estimateSize() introduces that requirement into the fee checks performed by serialize(), even when disableIsFullySigned is explicitly true. A reproduced transaction spending an OP_TRUE UTXO with previous-output information throws here when serialized with that option; the same transaction serializes successfully when using the base revision's fee and size estimators. Estimate size without requiring signature-verification support so the existing serialization option remains effective.

source: ['claude']

Comment on lines +1085 to +1086
if (amountAvailable > fee + minimumFee) {
fee += (minimumFee || Transaction.MINIMUM_FEE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Apply minimumFee consistently as a lower bound

This branch adds the minimum fee to a size-based fee that already meets the minimum. It also treats an omitted minimum differently from an explicitly supplied default because fee + undefined is NaN. For example, _estimateFee(500, 100000, 1000) returns 500, while passing 255 as the fourth argument returns 755. In a reproduced two-input P2PKH transaction, explicitly setting .minimumFee(Transaction.MINIMUM_FEE) changes the actual fee from 340 to 595 duffs even though the floor remains 255 and was already satisfied. Normalize the optional minimum once and apply it as a lower bound, not an additional balance-dependent charge.

source: ['claude']

Comment on lines +845 to +850
Transaction.prototype.minimumFee = function (amount) {
$.checkArgument(_.isNumber(amount), 'amount must be a number');
this._minimumFee = amount;
this._updateChangeOutput();
return this;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Declare the new minimum-fee API for TypeScript consumers

The PR introduces the public minimumFee() method and MINIMUM_FEE constant without adding them to typings/transaction/Transaction.d.ts. Type-checking new Transaction(undefined).minimumFee(500) and Transaction.MINIMUM_FEE produces TS2339 for both, preventing TypeScript consumers from using the new feature through the package declarations. Add the chainable method and static constant declarations, together with test-d coverage. The existing type tests pass because they do not exercise either addition.

source: ['claude']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants