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

client: bond reserves #2103

Merged
merged 10 commits into from Feb 24, 2023
Merged

client: bond reserves #2103

merged 10 commits into from Feb 24, 2023

Conversation

chappjc
Copy link
Member

@chappjc chappjc commented Feb 3, 2023

Requires #2036. In draft until that's settled.

This PR is the commits starting with client/{asset,core}: RefundBond broadcasts too.

Commit messages (but there are a ton of "fixup" commits already pushed that will be squashed before this is merged):

client/{asset,core}: RefundBond broadcasts too

The asset.Bonder.RefundBond method now broadcasts, as it was supposed
to from the beginning. Only MakeBondTx required broadcasting in the
consumer.

client/{asset,core,webserver}: BondLocked balance category

This adds the client.WalletBalance.BondLocked field, which parallels
the ContractLocked field. These are used for core to supplement the
wallet's reported balance with values that the wallet does not
include in the balance itself because they are not spendable by the
wallet alone (core authors the spending tx).

This also updates the web UI's TypeScript to include bondLocked
when appropriate.  This also fixes a bug where contractLocked was
not included on the wallets page's "Locked" balance category.

This also adds to DCR's implementation fields for reserves tracking.
The values are included in the reported asset.Balance. Subsequent
commits will add the ability modify the reserves, and for funding
methods to respect the reserves.

client/asset/dcr: respect reserves in funding

This updates the DCR wallet's transaction funding method and helpers to
respect the reserves added in the previous commit.

To do so without creating sizing transactions to actively isolate the
reserved value, we add new coin selection functions that we use exclude
UTXOs from transaction funding. The objective of these coin selection
functions is different from order funding coin selection.

The main helper, leastOverFund, attempts to pick a subset of the provided
UTXOs to reach the required amount with the objective of minimizing the
total amount of the selected UTXOs. This is different from the objective
used when funding orders, which is to minimize the number of UTXOs (to
minimize fees).

NOTE: The provided UTXO set MUST be sorted in ascending order (smallest
first, largest last)!

leastOverFund begins by partitioning the UTXO slice before the smallest
single UTXO that is large enough to fully fund the requested amount, if
it exists. If the smaller set is insufficient, the single largest UTXO
is returned. If instead the set of smaller UTXOs has enough total
value, it will search for a subset that reaches the amount with least
over-funding (see subsetSmallBias and subsetLargeBias). If that subset
has less combined value than the single sufficiently-large UTXO (if it
exists), the subset will be returned, otherwise the single UTXO will be
returned.

We also modify the "enough" functions and their constructors with
excess (change) reporting in a new return from the enough function.
The constructor also accepts a flag indicating if this returned value
should be set to zero. The tryFund helper now returns the final excess
value when it completes UTXO selection. This is used in the
(*ExchangeWallet).fund method, and in other callers, to discern if
there would be sufficient remaining in the wallet (including this
change) to satisfy a reserved amount. This amount is an input to fund
called "keep". If change should not be considered "kept" (e.g. no
preceding split txn, or mixing sends change to umixed account where it
is unusable for reserves), caller should return 0 extra from the enough
func (via the enough func's constructor flag).

client/{asset,core}: reserves track as bonds are spend/refunded

This updates DCR's backend to begin modifying the reserves fields
as bonds are created (locked UTXOs) and spend (unlock UTXOs into
balance). Subsequent commits add the new exported wallet
interface methods for a consumer (core) to prepare and adjust the
reserves for the client's needs.

This also modifies the wallet's MakeBondTx method signature with
an additional return, an "abandon" function" to be used if the
consumer decides not to broadcast the generated bond transaction.
This function both unlocks the bond's funding utxos, and reverts
the reserves updates that were made when the transaction was
created.

client/{asset,core}: ReserveBondFunds and dexAccount.tierChange

This adds the new Bonder methods, RegisterUnspent and ReserveBondFunds:

RegisterUnspent informs the wallet of a certain amount already locked in
unspent bonds that will eventually be refunded with RefundBond. This
should be used prior to ReserveBondFunds. This alone does not enable
reserves enforcement, and it should be called on bring-up when existing
bonds that may refund to this wallet are known. Once ReserveBondFunds is
called, even with 0 for future bonds, these live bond amounts will
become enforced reserves when they are refunded via RefundBond.

ReserveBondFunds (un)reserves funds for creation of future bonds.
MakeBondTx will create transactions that decrement these reserves, while
RefundBond will replenish the reserves. If the wallet's available
balance should be respected when adding reserves, the boolean argument
may be set to indicate this, in which case the return value indicates if
it was able to reserve the funds. In this manner, funds may be
pre-reserved so that when the wallet receives funds (from either
external deposits or refunding of live bonds), they will go directly
into locked balance. When the reserves are decremented to zero (by the
amount that they were incremented), all enforcement including any fee
buffering is disabled. Previously registered unspent/active bonds are
not forgotten however; they total amount of these are tracked in case
the wallet is used for reserves again in the future.

In core.Core, the new wallet reserves methods are used to maintain
adequate reserves based on the account's target tier and observed tier
change. The dexAccount type has new fields to support this:
- tierChange int64, is the total tier change that needs to be actuated
  with wallet reserves. Facilitates maintenance with changing tier.
- totalReserved int64, helps track and debug the total amount reserved
  with the wallet's ReserveBondFunds method *only* for this dexAccount.

Use of ReserveBondFunds and RegisterUnspent, facilitated by the new
dexAccount fields, happens in:
- connectWallets() via initialize(), after connectDEX(). Here it
  calls RegisterUnspent for all known bonds across all dexConnections.
- authDEX. On reconnect, any observed tier change is acctuated with
  wallet reserves. Initial auth on login is handled slightly
  differently, (re)reserving the full required amount given the known
  unspent bonds.
- handleTier change, which is called on penalization only, updates
  the tierChange field of the dexAccount.
- rotateBonds. Any tierChange value is actuated with reserves.
- PostBond. When making the initial bond for a new account, with
  maintenance enabled, ReserveBondFunds is used to pre-reseve. Here
  the boolean input is true, and the return is checked to ensure the
  wallet is sufficiently funded to increase reserves by the requested
  amount for the new bonds.
- UpdateBondOptions. When modifying target tier or changing the bond
  asset. The boolean input and output are also used here.

client/core/bond.go Outdated Show resolved Hide resolved
@chappjc chappjc added this to the 0.6 milestone Feb 6, 2023
return nil
}
if i == len(utxos)-1 {
return utxos[i:]
Copy link
Contributor

Choose a reason for hiding this comment

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

This should never happen if as in the comment, Each utxo in the input must be smaller than amt

client/asset/dcr/coin_selection.go Outdated Show resolved Hide resolved
@@ -3555,10 +3618,16 @@ func (dcr *ExchangeWallet) RefundBond(ctx context.Context, ver uint16, coinID, s
if err != nil {
return nil, err
}
return dcr.makeBondRefundTxV0(txHash, vout, amt, script, privKey, feeRate)

newReserves, unspent := dcr.bondSpent(amt) // nominal, not refundAmt
Copy link
Contributor

Choose a reason for hiding this comment

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

You could call bondSpent after the SendRawTransaction, instead of re-locking in the error block.

client/asset/dcr/dcr.go Outdated Show resolved Hide resolved
@chappjc
Copy link
Member Author

chappjc commented Feb 9, 2023

Just a rebase, minimal review revisions

@chappjc
Copy link
Member Author

chappjc commented Feb 9, 2023

@chappjc chappjc marked this pull request as ready for review February 9, 2023 22:34
Copy link
Contributor

@martonp martonp left a comment

Choose a reason for hiding this comment

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

I think I found an issue while testing on simnet. I created a fresh account, posted 1 bonds worth, and at first the Locked amount showed 20+fees as I expected, but when I mined one extra block it jumped to 30 + fees.

Screen Shot 2023-02-13 at 2 02 09 PM

@@ -916,10 +1099,16 @@ func (c *Core) PostBond(form *PostBondForm) (*PostBondResult, error) {
"Consider using UpdateBondOptions instead.",
targetTier, autoBondAsset, wallet.amtString(maxBondedAmt))
} else if maintain { // new account with tier maintenance enabled
Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated to review, but I think there should be a "force" flag for someone to call this when the account already exists.

if tierOffset := int64(bondedTier()) - dc.acct.tier; tierOffset > 0 {
mod += tierOffset * int64(bondAssetAmt)
}
// Wallet already has live unspent bonds (inBonds) register.
Copy link
Contributor

Choose a reason for hiding this comment

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

registered*

if tierChanged {
dc.acct.targetTier = targetTier
acct.TargetTier = targetTier
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Could return early if !tierChanged && !assetChanged

Copy link
Member Author

Choose a reason for hiding this comment

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

It's permitted to change max bonded amt only.

enforcedDelta := future + int64(feeBuffer)

// How much of that is covered by the available balance
if respectBalance {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe only check this if future > dcr.bondReservesNominal? If someone's decreasing the reserves and their balance isn't high enough it would mean that something went wrong somewhere else, but I don't think it should fail here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think future > 0 would be the condition to indicate if reserves are increasing.

@@ -4803,7 +4836,7 @@ func (c *Core) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, erro
}

// initializeDEXConnections connects to the DEX servers in the conns map and
// authenticates the connection. This is done on Login.
// authenticates the connection. This should be done only ONCE on Login.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is also called in AccountImport.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah right, that's an atrocity. Going to change that. I'm also going to remove connectAccount now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh heck, the dc.acct.authed() check in here makes this safe, so I'll remove the comment for now, but I figure we should factor out the loop contents for stuff like AccountImport.

// unspent bonds, which are in bondReservesUsed. When bonds are created,
// bondReservesEnforced is decremented and bondReservesUsed are incremented;
// when bonds are refunded, the reverse. bondReservesEnforced may become
// negative: during the unbonding process, if there is insufficient balance
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't insufficient balance due to penalties and pre-reserving more than available just make bondReservesEnforced be greater than the balance, not become negative?

// after excess is unbonded, offsetting the negative enforced amount. This
// is the relatively small fee buffer.
if int64(dcr.bondReservesUsed) == dcr.bondReservesNominal {
return uint64(-dcr.bondReservesEnforced)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the fee buffer be updated on reconfigure, if the feeRateLimit is changed?

// Find a subset of the small UTXO set with smallest combined amount.
var set []*compositeUTXO
if sumUTXOs(small) >= amt {
set = subsetLargeBias(amt, small)
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you consider a dynamic programming technique to find the optimal solution?

Copy link
Member Author

@chappjc chappjc Feb 13, 2023

Choose a reason for hiding this comment

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

Was on my todo list. 😊 Just threw together a few heuristics, but you have a point that it can be solved optimally. Seems akin to the fractional knapsack problem (reversed). Will give it a shot.

@chappjc
Copy link
Member Author

chappjc commented Feb 13, 2023

I think I found an issue while testing on simnet. I created a fresh account, posted 1 bonds worth, and at first the Locked amount showed 20+fees as I expected, but when I mined one extra block it jumped to 30 + fees.

Screen Shot 2023-02-13 at 2 02 09 PM

OK, will have to go back to test fresh accounts. I did some revisions to the system that I validated with existing accounts, but must have regressed with account creation.

@chappjc
Copy link
Member Author

chappjc commented Feb 14, 2023

I think I found an issue while testing on simnet. I created a fresh account, posted 1 bonds worth, and at first the Locked amount showed 20+fees as I expected, but when I mined one extra block it jumped to 30 + fees.
Screen Shot 2023-02-13 at 2 02 09 PM

OK, will have to go back to test fresh accounts. I did some revisions to the system that I validated with existing accounts, but must have regressed with account creation.

Oopsie fixed in 638214d

Copy link
Member

@buck54321 buck54321 left a comment

Choose a reason for hiding this comment

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

I need to review core parts still, but I gotta run for a few hours at least. I think I get it all now. Thanks for all of the descriptive docs.

Comment on lines 1921 to 1926
// Try again with some utxos taken out of the mix for keep. Select these
// with the objective of being as close to the amount as possible,
// unlike tryFund that minimizes the number of UTXOs chosen.
kept := leastOverFund(keep, utxos)
utxos = utxoSetDiff(utxos, kept)
sum, _, sz, coins, spents, redeemScripts, err = tryFund(utxos, enough)
Copy link
Member

Choose a reason for hiding this comment

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

It's not clear to me exactly how this might work. avail-sum+extra will be all that remains after the tx is funded, including the change transaction. Is the hope that if we reserve the right utxos bond reserves, tryFund will somehow find a more favorable set of utxos?

Copy link
Member Author

@chappjc chappjc Feb 14, 2023

Choose a reason for hiding this comment

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

If we get here, it is because the enough func is very likely not granting any spendable change i.e. no split tx. Note the if extra != 0 { check above. The logic in this commit is to try again but fist pick out utxos in the amount of at least keep before even calling tryFund -- that guarantees keep will be met but it can then fail if the pruned utxo set is insufficient for the order's enough func.

However (and I apologize for the moving target), after we chatted on matrix this morning, I switched the order of these attempts so that it first sets aside utxos (using just the pruned set with tryFund), and if that fails to leave enough funding, it will then try with the full set and check if extra allowed it to satisfy keep: 9f6ad97

// if sum >= amt { return utxos } // would have been i>=0 after break above
return nil
}
// if i == len(utxos)-1 { return utxos[i:] } // shouldn't happen if each in set are < amt
Copy link
Member

Choose a reason for hiding this comment

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

Hmm. if i == len(utxos)-1 you wouldn't return the last one though.

Copy link
Member Author

@chappjc chappjc Feb 14, 2023

Choose a reason for hiding this comment

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

It's commented out here of course, but I'm not sure why I had written this as utxos[i:], because that's the same as utxos[i] when i == len(utxos)-1 (the last one). As @martonp pointed out, it's not going to happen that just the one UTXO is sufficient because: Each utxo in the input must be smaller than amt - use via leastOverFund only! so this is commented. Might as well remove.

Comment on lines +147 to +148
// large enough to fully fund the requested amount, if it exists. If the smaller
// set is insufficient, the single largest UTXO is returned. If instead the set
Copy link
Member

Choose a reason for hiding this comment

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

Does "smaller set" mean the set of lower valued utxos?

}

func bondsFeeBuffer(highFeeRate uint64) uint64 {
const inputCount uint64 = 20 // plan for lots of inputs
Copy link
Member

Choose a reason for hiding this comment

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

20 seems like a lot. Won't matter much for low-fee assets, but might be prohibitive for btc and eth at times.

Copy link
Member Author

Choose a reason for hiding this comment

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

Probably true in most cases, esp. with the high fee rate and the "tracks" multiplier below, but this helper is in client/asset/dcr so other assets would do differently. I expect eth would be quite a bit simpler in this regard (no UTXO business to worry about), although this could be a lot of value for btc.


func bondsFeeBuffer(highFeeRate uint64) uint64 {
const inputCount uint64 = 20 // plan for lots of inputs
highBondFee := dexdcr.MsgTxOverhead + dexdcr.P2PKHOutputSize*2 + inputCount*dexdcr.P2PKHInputSize
Copy link
Member

Choose a reason for hiding this comment

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

Output size could be more accurate. 1 p2sh, 1 OP_RETURN + push data length, and 1 p2pkh

const BondPushDataSize = 1 + 2 + 32 + 4 + 20
highBondFee := dexdcr.MsgTxOverhead + dexdcr.P2SHOutputSize + BondPushDataSize + dexdcr.P2PKHOutputSize + inputCount*dexdcr.P2PKHInputSize


func bondsFeeBuffer(highFeeRate uint64) uint64 {
const inputCount uint64 = 20 // plan for lots of inputs
highBondFee := dexdcr.MsgTxOverhead + dexdcr.P2PKHOutputSize*2 + inputCount*dexdcr.P2PKHInputSize
Copy link
Member

Choose a reason for hiding this comment

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

Variable name could indicate size.

Comment on lines +1384 to +1401
// NOTE: Split tx is an order-time option. The max order is generally
// attainable when split is used, regardless of whether they choose it on
// the order form. Allow the split for max order purposes.
trySplitTx := true
Copy link
Member

Choose a reason for hiding this comment

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

The max order is generally attainable when split is used, regardless of whether they choose it on the order form.

Really? I don't see it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, it's more nuanced than I let on, but this is referring to when there are reserves to respect, because the split maximizes the usable balance by allowing the split tx change to offset reserves.

However, the split actually eats a bit extra in fees because of the split tx baggage, but because dcr.estimateSwap falls back to skipping the split if needed, we can be sure that maxOrder calling estimateSwap with trySplitTx=true will give the highest estimate one way or another. The comments in the revised tests in 57db9ad demonstrate this I think.

estHighFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots,
dexdcr.InitTxSizeBase, dexdcr.InitTxSize, bumpedNetRate)
estHighFees := estHighFunds - val
digestInputs := func() (reqFunds, maxFees, estHighFees, estLowFees uint64) {
Copy link
Member

Choose a reason for hiding this comment

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

Making inputsSize an argument might be more descriptive.

}
// Always offer the split option, even for non-standing orders since
// immediately spendable change many be desirable regardless.
opts := []*asset.OrderOption{dcr.splitOption(req, utxos, bump)}
Copy link
Member

Choose a reason for hiding this comment

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

Ah. Nice. I had commented on this on a different commit but hadn't submitted yet.
It looks like we're still disabling the split in FundOrder for Immediate orders though.

Comment on lines +2158 to +2161
// loss to fees in a split. This trivial amount is of no concern because
// the reserves should be buffered for amounts much larger than the fees
// on a single transaction.
Copy link
Member

Choose a reason for hiding this comment

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

I think you're probably right that the fee buffer will cover it, but I don't see any harm in adding a dexdcr.P2PKHOutputSize to the splitTxBaggage above if extraOutput > 0.

@chappjc chappjc force-pushed the bond-reserves branch 2 times, most recently from fd53f71 to 9a16a76 Compare February 14, 2023 23:42
Copy link
Member

@buck54321 buck54321 left a comment

Choose a reason for hiding this comment

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

Just got through the core parts. A lot to digest, so I'm gonna swing back in a bit for a more thorough review. Just a couple of comments for now.

// balance. Amounts may be reserved beyond the available balance, but only the
// amount that is offset by the available balance is reflected in the locked
// balance category. Like funds locked in swap contracts, the caller must
// supplement balance reporting with know bond amounts. However, via
Copy link
Member

Choose a reason for hiding this comment

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

known

// the reserves are decremented to zero (by the amount that they were
// incremented), all enforcement including any fee buffering is disabled.
ReserveBondFunds(future int64, respectBalance bool) bool

Copy link
Member

Choose a reason for hiding this comment

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

One thing I think is going to be needed is a way to check that reserves are sufficient without ordering or sending. We need to be able to provide warnings to users when there's insufficient funds, which could be caused by e.g. user doing dumb stuff with an RPC wallet, or a fee rate spike.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point, this also happens if you sweep the max out of your wallet (leaving 0 "available" and just the reserves in "locked"), then on the next bond tx, it starts eating into the desired fee buffer. Of course, that's the fee buffer doing it's job and you can go on for quite a long time before it is unable to post/renew, but you can observe it running below target reserves.

e.g.
[WRN] CORE[dcr]: Available balance is below configured reserves: 0.013529 < 0.013628
and
[WRN] CORE[dcr]: Available balance is below configured reserves: 10.013479 < 10.013628

But like you said the user can meddle with their wallet to really eat into the reserves way past the fee buffer.

I think we can add a method to report reserves health.

Copy link
Member Author

@chappjc chappjc Feb 21, 2023

Choose a reason for hiding this comment

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

We have that unused asset.Balance.Other field.

I expect frontend can work with the following or similar?

@@ -1061,7 +1062,9 @@ func (dcr *ExchangeWallet) Balance() (*asset.Balance, error) {
        if reserves > bal.Available { // unmixed (immature) probably needs to trickle in
                dcr.log.Warnf("Available balance is below configured reserves: %f < %f",
                        toDCR(bal.Available), toDCR(reserves))
+               bal.Other["Reserves Deficit"] = reserves - bal.Available
                reserves = bal.Available
        }
+       bal.Other["Reserves"] = reserves
        bal.Available -= reserves
        bal.Locked += reserves

Or keys can be prefixed like bal.Other["warning:Reserves Deficit"] = reserves - bal.Available so it can be shown in red or something?

Copy link
Member Author

Choose a reason for hiding this comment

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

I this could work out

image

I'd update that with Bonds (locked) too for completeness

Copy link
Member Author

@chappjc chappjc Feb 22, 2023

Choose a reason for hiding this comment

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

I kinda like this, in the last commit

image

later, while not swapping and with bond overlap:

image

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe Bonded and Bond Reserves. Someone might not know what the reserves are for.

Comment on lines +1078 to +1082
const parallelTracks uint64 = 4
return parallelTracks * largeBondTxSize * highFeeRate
Copy link
Member

Choose a reason for hiding this comment

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

I guess there'd be no way to "merge" parallel tracks without going up or down in tier. That's unfortunate.

Copy link
Member Author

Choose a reason for hiding this comment

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

I need to really research this, but I observed them naturally merging sometimes. Perhaps only likely with the high overlap ratio in simnet and testnet though.

Comment on lines 546 to 552
// bondReservesEnforced is used to reserve unspent amounts for upcoming bond
// transactions, and does not include amounts that are currently locked in
// unspent bonds, which are in bondReservesUsed. When bonds are created,
// bondReservesEnforced is decremented and bondReservesUsed are incremented;
// when bonds are refunded, the reverse. bondReservesEnforced may become
// negative during the unbonding process.
bondReservesEnforced int64 // set by ReserveBondFunds, modified by bondSpent and bondLocked
Copy link
Member

Choose a reason for hiding this comment

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

I'd mention that bondReservesEnforced includes projected fees.

continue
}
refundCoinStr, _ = asset.DecodeCoinID(bond.AssetID, refundCoinID)
} else { // normal pat
Copy link
Member

Choose a reason for hiding this comment

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

Who's pat?

}
c.log.Infof("Updating bond reserves by %s (automatic tier change adjustments)",
wallet.amtStringSigned(reserveDelta))
wallet.ReserveBondFunds(reserveDelta, false)
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible for rotateBonds to run concurrently?

Copy link
Member Author

@chappjc chappjc Feb 15, 2023

Choose a reason for hiding this comment

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

Should only be run from the loop in watchBonds (sequentially). Will document that it's not to be run willy nilly.

@chappjc
Copy link
Member Author

chappjc commented Feb 15, 2023

Just got through the core parts. A lot to digest, so I'm gonna swing back in a bit for a more thorough review. Just a couple of comments for now.

Much appreciated. It is a lot of work to digest.

@chappjc chappjc force-pushed the bond-reserves branch 2 times, most recently from 26b5999 to 0a65957 Compare February 18, 2023 21:53
@chappjc
Copy link
Member Author

chappjc commented Feb 18, 2023

Rebased and squashed away fixup commits, not retested yet, but there might be breakage with view-only merged. Compared with original PR, there are three additional commits with qualitative changes beyond fixups to the other commits. Minimal testing of the forced split with the extra output.

Comment on lines 1936 to 1958
// First take some UTXOs out of the mix for any keep amount. Select these
// with the objective of being as close to the amount as possible, unlike
// tryFund that minimizes the number of UTXOs chosen. By doing this first,
// we may be making the order spend a larger number of UTXOs, but we
// mitigate subsequent order funding failure due to reserves because we know
// this order will leave behind sufficient UTXOs without relying on change.
if keep > 0 {
kept := leastOverFund(keep, utxos)
utxos = utxoSetDiff(utxos, kept)
sum, _, sz, coins, spents, redeemScripts, err = tryFund(utxos, enough)
if err != nil { // no joy with the reduced set
dcr.log.Debugf("Setting aside %v DCR in %d UTXOs to respect the %v DCR reserved amount",
toDCR(sumUTXOs(kept)), len(kept), toDCR(keep))
utxosPruned := utxoSetDiff(utxos, kept)
sum, _, sz, coins, spents, redeemScripts, err = tryFund(utxosPruned, enough)
if err != nil { // try with the full set
dcr.log.Debug("Unable to fund order with UTXOs set aside (%v), trying again with full UTXO set.", err)
} // else spents is populated
}
if len(spents) == 0 { // either keep is zero or it failed with utxosPruned
// Without utxos set aside for keep, we have to consider any spendable
// change (extra) that the enough func grants us.
var extra uint64
sum, extra, sz, coins, spents, redeemScripts, err = tryFund(utxos, enough)
Copy link
Member Author

Choose a reason for hiding this comment

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

This commit also reverses the order of funding attempts vs the initial commit that updating fund/tryFund. It tries straight away to set aside UTXOs for the reserves, and falls back to the standard funding approach, hoping that the enough func grants usable change in the extra output.

@chappjc
Copy link
Member Author

chappjc commented Feb 19, 2023

Tested forced split with extra output. Worked as intended.

@chappjc
Copy link
Member Author

chappjc commented Feb 20, 2023

Just a rebase. Nothing else revised.

Copy link
Contributor

@martonp martonp left a comment

Choose a reason for hiding this comment

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

Working well while testing.

useSplit = true
coins, redeemScripts, sum, inputsSize, err = dcr.fund(reserves,
orderEnough(ord.Value, ord.MaxSwapCount, bumpedMaxRate, true))
// And make an extra output for the reserves amount plus additional
Copy link
Contributor

Choose a reason for hiding this comment

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

You could do reserves + (bumpedMaxRate * dexdcr.P2PKHInputSize) as the keep argument to fund so it will fail here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Suggestion applied and squashed in.

useSplit := cfg.useSplitTx
if customCfg.Split != nil {
useSplit = *customCfg.Split
}

// Send a split, if preferred.
if useSplit && !ord.Immediate {
changeForReserves := useSplit && cfg.unmixedAccount == ""
Copy link
Contributor

Choose a reason for hiding this comment

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

Why only if cfg.unmixedAccount == "" ? The splitting outputs go into the tradingAccount.

Copy link
Member Author

Choose a reason for hiding this comment

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

Just the non-change outputs. The change output goes into the unmixed account, according to signTxAndAddChange+depositAccount.
Mixing epochs are unfortunately kinda long so I don't think we can consider such change as available

Copy link
Contributor

Choose a reason for hiding this comment

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

I see now.. but why does that change need to go into the unmixed account when we are doing a split? Couldn't it go right back into the trading account?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's debatable, but all the input have been linked with each other. They're also now linked with a dex order.

Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't the second output created for the reserves have the same issue then?

Copy link
Member Author

@chappjc chappjc Feb 20, 2023

Choose a reason for hiding this comment

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

Yeah, and same as when an order that's funded by a split output is cancelled with no fill, that output stays in trading account.

But if we send nothing into unmixed, a wallet will quickly end up with nothing in the mixed account, I think. Meaning the entire previously mixed set gets linked. It would be down to deposits, redeems, and change on matched orders. And the last category would not exist with splits in use.

@chappjc
Copy link
Member Author

chappjc commented Feb 22, 2023

Ugh, the browser goes back to /register after restarting even if you've been bonded. Something with view-only logic.

@chappjc
Copy link
Member Author

chappjc commented Feb 22, 2023

Oh nevermind I just deleted my dexc.db while it was running! bad chappjc

@chappjc
Copy link
Member Author

chappjc commented Feb 22, 2023

I'm done with reviews on this, I think.

Only thing I have to check is what Wisdom discovered with legacy accounts, which is by no means a showstopper, but should be fixed:

an account that paid legacy fee would have maxBondedAmt = 0
if the user enables bond maintenance without setting a maxBondedAmt, the max value remains 0
is this accounted for? so that the default max bonded amt is used?

@chappjc
Copy link
Member Author

chappjc commented Feb 22, 2023

Only thing I have to check is what Wisdom discovered with legacy accounts, which is by no means a showstopper, but should be fixed:

an account that paid legacy fee would have maxBondedAmt = 0
if the user enables bond maintenance without setting a maxBondedAmt, the max value remains 0
is this accounted for? so that the default max bonded amt is used?

Seems the missing condition was this 479667e

Will test it out though.

Copy link
Member

@JoeGruffins JoeGruffins left a comment

Choose a reason for hiding this comment

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

Tested quite a bit on testnet and simnet. Looks fine. Only problem was it seems max send and max orders don't always work.

Comment on lines 1180 to 1181
// How much of that is covered by the available balance, when increasing
// reserves via
Copy link
Member

Choose a reason for hiding this comment

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

Is this a complete thought?

// else we got lucky with the legacy funding approach and there was
// either available unspent or the enough func granted spendable change.
if keep > 0 && extra > 0 {
dcr.log.Debugf("Funding succeeded with %s DCR in spendable change.", toDCR(extra))
Copy link
Member

Choose a reason for hiding this comment

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

prints 2023-02-22 09:40:16.084 [DBG] CORE[dcr]: Funding succeeded with %!s(float64=42.00919958) DCR in spendable change.


// Calculate the extra fees associated with the additional inputs, outputs,
// and transaction overhead, and compare to the excess that would be locked.
baggageFees := bumpedMaxRate * splitTxBaggage
if extraOutput > 0 {
baggageFees += dexdcr.P2PKHOutputSize
Copy link
Member

Choose a reason for hiding this comment

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

dexdcr.P2PKHOutputSize * bumpedMaxRate?

Comment on lines +4171 to +4178
func (dcr *ExchangeWallet) sendCoins(coins asset.Coins, addr, addr2 stdaddr.Address, val, val2, feeRate uint64,
subtract bool) (*wire.MsgTx, uint64, error) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you add to the comment the purpose of the second addr and val?

if bond.KeyIndex == math.MaxUint32 { // invalid/unknown key index fallback (v0 db.Bond, which was never released), also will skirt reserves :/
refundCoinID, err := wallet.SendTransaction(bond.RefundTx)
if err != nil {
c.log.Errorf("Failed to broadcast bond refund txn %v: %v", bond.RefundTx, err)
Copy link
Member

Choose a reason for hiding this comment

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

is %x better for []byte?

}

// RegisterUnspent informs the wallet of existing bond amounts that will be
// refunded with RefundBond. If reserves are subsequently been enable with
Copy link
Member

Choose a reason for hiding this comment

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

are -> have

@@ -138,7 +138,7 @@ var (
"mining fees are paid. Used only for standing-type orders, e.g. " +
"limit orders without immediate time-in-force.",
IsBoolean: true,
DefaultValue: false,
DefaultValue: true, // cheap fees, helpful for bond reserves, and adjustable at order-time
Copy link
Contributor

@martonp martonp Feb 23, 2023

Choose a reason for hiding this comment

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

Option description needs to be updated to not say it is only for standing-type orders.

client/asset/dcr/dcr.go Outdated Show resolved Hide resolved
The asset.Bonder.RefundBond method now broadcasts, as it was supposed
to from the beginning. Only MakeBondTx required broadcasting in the
consumer.
This adds the client.WalletBalance.BondLocked field, which parallels
the ContractLocked field. These are used for core to supplement the
wallet's reported balance with values that the wallet does not
include in the balance itself because they are not spendable by the
wallet alone (core authors the spending tx).

This also updates the web UI's TypeScript to include bondLocked
when appropriate.  This also fixes a bug where contractLocked was
not included on the wallets page's "Locked" balance category.

This also adds to DCR's implementation fields for reserves tracking.
The values are included in the reported asset.Balance. Subsequent
commits will add the ability modify the reserves, and for funding
methods to respect the reserves.
This updates the DCR wallet's transaction funding method and helpers to
respect the reserves added in the previous commit.

To do so without creating sizing transactions to actively isolate the
reserved value, we add new coin selection functions that we use exclude
UTXOs from transaction funding. The objective of these coin selection
functions is different from order funding coin selection.

The main helper, leastOverFund, attempts to pick a subset of the provided
UTXOs to reach the required amount with the objective of minimizing the
total amount of the selected UTXOs. This is different from the objective
used when funding orders, which is to minimize the number of UTXOs (to
minimize fees).

NOTE: The provided UTXO set MUST be sorted in ascending order (smallest
first, largest last)!

leastOverFund begins by partitioning the UTXO slice before the smallest
single UTXO that is large enough to fully fund the requested amount, if
it exists. If the smaller set is insufficient, the single largest UTXO
is returned. If instead the set of smaller UTXOs has enough total
value, it will search for a subset that reaches the amount with least
over-funding (see subsetSmallBias and subsetLargeBias). If that subset
has less combined value than the single sufficiently-large UTXO (if it
exists), the subset will be returned, otherwise the single UTXO will be
returned.

We also modify the "enough" functions and their constructors with
excess (change) reporting in a new return from the enough function.
The constructor also accepts a flag indicating if this returned value
should be set to zero. The tryFund helper now returns the final excess
value when it completes UTXO selection. This is used in the
(*ExchangeWallet).fund method, and in other callers, to discern if
there would be sufficient remaining in the wallet (including this
change) to satisfy a reserved amount. This amount is an input to fund
called "keep". If change should not be considered "kept" (e.g. no
preceding split txn, or mixing sends change to umixed account where it
is unusable for reserves), caller should return 0 extra from the enough
func (via the enough func's constructor flag).
This updates DCR's backend to begin modifying the reserves fields
as bonds are created (locked UTXOs) and spend (unlock UTXOs into
balance). Subsequent commits add the new exported wallet
interface methods for a consumer (core) to prepare and adjust the
reserves for the client's needs.

This also modifies the wallet's MakeBondTx method signature with
an additional return, an "abandon" function" to be used if the
consumer decides not to broadcast the generated bond transaction.
This function both unlocks the bond's funding utxos, and reverts
the reserves updates that were made when the transaction was
created.
This adds the new Bonder methods, RegisterUnspent and ReserveBondFunds:

RegisterUnspent informs the wallet of a certain amount already locked in
unspent bonds that will eventually be refunded with RefundBond. This
should be used prior to ReserveBondFunds. This alone does not enable
reserves enforcement, and it should be called on bring-up when existing
bonds that may refund to this wallet are known. Once ReserveBondFunds is
called, even with 0 for future bonds, these live bond amounts will
become enforced reserves when they are refunded via RefundBond.

ReserveBondFunds (un)reserves funds for creation of future bonds.
MakeBondTx will create transactions that decrement these reserves, while
RefundBond will replenish the reserves. If the wallet's available
balance should be respected when adding reserves, the boolean argument
may be set to indicate this, in which case the return value indicates if
it was able to reserve the funds. In this manner, funds may be
pre-reserved so that when the wallet receives funds (from either
external deposits or refunding of live bonds), they will go directly
into locked balance. When the reserves are decremented to zero (by the
amount that they were incremented), all enforcement including any fee
buffering is disabled. Previously registered unspent/active bonds are
not forgotten however; they total amount of these are tracked in case
the wallet is used for reserves again in the future.

In core.Core, the new wallet reserves methods are used to maintain
adequate reserves based on the account's target tier and observed tier
change. The dexAccount type has new fields to support this:
- tierChange int64, is the total tier change that needs to be actuated
  with wallet reserves. Facilitates maintenance with changing tier.
- totalReserved int64, helps track and debug the total amount reserved
  with the wallet's ReserveBondFunds method *only* for this dexAccount.

Use of ReserveBondFunds and RegisterUnspent, facilitated by the new
dexAccount fields, happens in:
- connectWallets() via initialize(), after connectDEX(). Here it
  calls RegisterUnspent for all known bonds across all dexConnections.
- authDEX. On reconnect, any observed tier change is acctuated with
  wallet reserves. Initial auth on login is handled slightly
  differently, (re)reserving the full required amount given the known
  unspent bonds.
- handleTier change, which is called on penalization only, updates
  the tierChange field of the dexAccount.
- rotateBonds. Any tierChange value is actuated with reserves.
- PostBond. When making the initial bond for a new account, with
  maintenance enabled, ReserveBondFunds is used to pre-reseve. Here
  the boolean input is true, and the return is checked to ensure the
  wallet is sufficiently funded to increase reserves by the requested
  amount for the new bonds.
- UpdateBondOptions. When modifying target tier or changing the bond
  asset. The boolean input and output are also used here.

Also, remove the (*Core).connectAccount helper that should only be used
on Core startup.
@chappjc
Copy link
Member Author

chappjc commented Feb 24, 2023

Thanks to everyone who reviewed this PR.

@chappjc chappjc merged commit 441bbc5 into decred:master Feb 24, 2023
@chappjc chappjc deleted the bond-reserves branch February 24, 2023 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bonds fidelity bonds
Projects
Development

Successfully merging this pull request may close these issues.

None yet

5 participants