Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix reward payment tx fee calculation #427

Merged
merged 6 commits into from
Nov 20, 2023

Conversation

jholdstock
Copy link
Member

@jholdstock jholdstock commented Nov 16, 2023

User @sebitt27 reported that his pool reward payments were paying extremely large fees (~0.074 DCR/kB). The cause was several bugs in the tx construction code, specifically in the function applyTxFees.

This PR starts by refactoring the tests for applyTxFees such that the existing behaviour can be characterized, and then goes on to fix the issues. The impact of the fixes (in the last two commits) can be observed by following the changes to the expected output amounts in the tests.

Using this new code the fee rate of the transaction shared by @sebitt27 is reduced to 0.000106216 DCR/kB, very close to the ideal value of 0.0001 DCR/kB.

This breaks up the monolithic testPaymentMgrPayment, extracting the bits
specific to testing the applyTxFees func. The goal is to make updating
the tests less cumbersome.

Two new tests are introduced, one for the core applyTxFees
functionality, and one for its error conditions.
The applyTxFees func previously took in a set of outputs as one param,
and the total value of those outputs as a separate param. This
introduced an opportunity for the function to be misued in that it could
be passed a set of outputs and a total which do not match.

An instance of this mistake actually existed in the tests for
applyTxFees. The value of the inputs and outputs was 100 DCR, but the
provided total was 5 DCR.
The test can now be run multiple times with different values so that
applyFee can be tested more thoroughly. This also enables characterizing
the existing behaviour of applyTxFees. Using absolute values in the
tests means that assertions are more precise, and any issues are more
clearly visible.
This extracts fee estimation into a func which can be reused later, as
well as fixing an issue which caused inSizes and outSizes to be twice as
long as intended, leading to fees being overestimated.
@@ -456,20 +456,24 @@ func (pm *PaymentMgr) applyTxFees(inputs []chainjson.TransactionInput, outputs m
}

var tOut dcrutil.Amount
for _, v := range outputs {
var poolFee dcrutil.Amount
Copy link
Member

Choose a reason for hiding this comment

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

This section is a significant improvement as it fixes the primary issue, but it's still not handling extra atoms leftover as the result of non-even divisions properly. Moreover, the rounding behavior of MulF64 could lead to underpayment of the transaction fee because it might round down which would lead to one less atom being subtracted than is necessary thereby effectively reducing the fee by 1.

To illustrate this, consider a total fee of 41 atoms, but with a proportional split of 37.5%, 32.5%, and 30%. Using the existing code here would produce:

Output 1: round(41 * .375) = 15
Output 2: round(41 * .325) = 13
Output 3: round(41 * .3) = 12

Now, if you add those up, 15+13+12 = 40, which is not the required 41 atoms. In other words, the tx would be rejected for not paying enough fees.

That is why the rounding behavior should be avoided completely in favor of taking the floor at all times and making up for the difference as necessary via applying the remaining additional atoms to random outputs. Alternatively, it could always take the ceiling, but that would result in overpayment in some scenarios, so I don't recommend it.

I believe the following rewritten code should handle everything properly, however, do note that the tests will need to be updated to ensure that the expected amount can either be the existing values or one less (because the fee might have been increased by one thus reducing the output amount by one):

func (pm *PaymentMgr) applyTxFees(inputs []chainjson.TransactionInput, outputs map[string]dcrutil.Amount,
	feeAddr stdaddr.Address) (dcrutil.Amount, error) {
	const funcName = "applyTxFees"
	if len(inputs) == 0 {
		desc := fmt.Sprintf("%s: cannot create a payout transaction "+
			"without a tx input", funcName)
		return 0, errs.PoolError(errs.TxIn, desc)
	}
	if len(outputs) == 0 {
		desc := fmt.Sprintf("%s: cannot create a payout transaction "+
			"without a tx output", funcName)
		return 0, errs.PoolError(errs.TxOut, desc)
	}

	estTxFee := estimateTxFee(len(inputs), len(outputs))

	// Determine the total output amount without the pool fee.
	var outputAmountSansPoolFee dcrutil.Amount
	for addr, v := range outputs {
		if addr == feeAddr.String() {
			continue
		}
		outputAmountSansPoolFee += v
	}

	// Determine the proportional transaction fees for each output other than
	// the pool fee.  Note that in order to avoid overpayment of the fee, each
	// proportional fee is calculated via a floor.  This means there will still
	// be a few atoms leftover to account for when the fee is not evenly
	// divisible by the number of outputs.
	var txFeesPaid dcrutil.Amount
	proportionalTxFees := make(map[string]dcrutil.Amount)
	for addr, v := range outputs {
		// Pool fee payments are excluded from tx fee deductions.
		if addr == feeAddr.String() {
			continue
		}

		ratio := float64(v) / float64(outputAmountSansPoolFee)
		outFee := dcrutil.Amount(math.Floor(float64(estTxFee) * ratio))
		proportionalTxFees[addr] = outFee
		txFeesPaid += outFee
	}

	// Apply the fees to the outputs while accounting for any extra atoms needed
	// to reach the required transaction fee by reducing random outputs
	// accordingly.
	remainingTxFeeAtoms := estTxFee - txFeesPaid
	for addr, fee := range proportionalTxFees {
		outputs[addr] -= fee
		if remainingTxFeeAtoms > 0 {
			outputs[addr]--
			remainingTxFeeAtoms--
		}
	}

	return estTxFee, nil

Copy link
Member

Choose a reason for hiding this comment

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

Here is a diff that updates the tests per my comments and code:

diff --git a/pool/paymentmgr_test.go b/pool/paymentmgr_test.go
index f07f846..9a1706f 100644
--- a/pool/paymentmgr_test.go
+++ b/pool/paymentmgr_test.go
@@ -512,7 +512,7 @@ func testPaymentMgrApplyTxFees(t *testing.T) {
                        },
                        expectedOutputs: map[string]dcrutil.Amount{
                                feeAddr: dcrutil.Amount(50000000),
-                               "user1": dcrutil.Amount(299997853),
+                               "user1": dcrutil.Amount(299997854),
                                "user2": dcrutil.Amount(149998927),
                        },
                },
@@ -587,9 +587,11 @@ func testPaymentMgrApplyTxFees(t *testing.T) {
 
                        // Validate outputs have been modified as expected.
                        for addr, amt := range test.outputs {
-                               if amt != test.expectedOutputs[addr] {
-                                       t.Fatalf("expected payment for %s to be %v, got %v",
-                                               addr, test.expectedOutputs[addr], amt)
+                               wantAmt1 := test.expectedOutputs[addr]
+                               wantAmt2 := wantAmt1 - 1
+                               if amt != wantAmt1 && amt != wantAmt2 {
+                                       t.Fatalf("expected payment for %s to be %v or %v, got %v",
+                                               addr, wantAmt1, wantAmt2, amt)
                                }
                        }

Copy link
Member Author

Choose a reason for hiding this comment

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

Amazing, thanks Dave. I've replaced the last commit in the PR to include this code.

There was a couple of problems in applyTxFees which lead to fees being
greatly overestimated.

- The division to find the ratio of a single output to the total output
  amount was inverted.
- The pool fee should not be subject to paying fees, however the txfee
  was being subtracted from the total output amount rather than the
  pool fee.

A new assertion in the applyTxFees test ensures the deductions from
output amounts matches matches the expected fee to be paid.
@jholdstock jholdstock merged commit 2d11bfb into decred:master Nov 20, 2023
2 checks passed
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