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
Copy link
Copy Markdown

@owl352 owl352 commented May 12, 2025

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

Copy link
Copy Markdown

@thepastaclaw thepastaclaw left a comment

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']

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