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
server/{swap,market}: add funding checks for account-based assets #1293
Conversation
All looks sensible based on a quick scan through, although I didn't anticipate a diff this large for the task. :) Not really complaints so much as a few things to note that jumped out at me:
Full review Mon/Tues. Let's try to get #1262 in asap. |
More than 700 lines of the the net LOC gain is tests, if that makes it any better.
I see that now. Will do.
I feel similarly. I got here because this is where the solution naturally evolved, and I think it's the right solution. It does touch some low-level, critical areas of code, but in fairly uncomplicated and auditable ways. For example,
I didn't remember any specific conclusions on the matter, so sorry if this work somehow doesn't reflect a previous discussion. I do think it's in-line with the validation that the server already performs for utxo-based assets. We do our best to make sure that the user can complete their order when it's placed. An order redeeming to an account-based asset cannot be completed without funds to pay the redemption tx fees, and the taker would be left hanging if the maker can't redeem. IMO, the reason we check any of this is ensure a smooth experience for the other party, and that still applies here.
The same could be said for funding coins for utxo-based assets. We could totally make it a free-for-all without any funding checks, and nobody's funds would be at risk. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method looks good to me, but did you consider just keeping an integer record of how much funds are currently in use by each account, and then incrementing/decrementing as needed? The code would definitely be much simpler. I guess the downside is if the record somehow gets out of sync, it's not really possible to recover.
@@ -14,12 +14,16 @@ import ( | |||
// number of lots in the order. For the quote asset, maxSwaps is not swapVal / | |||
// lotSize, so it must be a separate parameter. The chained swap txns will be | |||
// the standard size as they will spend a previous swap's change output. | |||
// For account-based assets, inputsSize will be zero, and nfo.SwapSize = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've created an issue regarding this: #1259
This is fine too, but I think it would be cleaner to have a new backend method.
As a side note, this difference isn't really due to ETH being account based, but because the fees are calculated based on gas instead of tx size.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine with having custom functions for dealing with account-based asset required funds. Outside of the asset backends, OrderRouter
is the only user of RequiredOrderFunds
and RequiredOrderFundsAlt
, and OrderRouter
is already account-aware (in this work) so the functions are only used in a context where we know already know what kind of asset we're dealing with. So we should be free to diverge these calc
functions with little ripple-effect. I'm not 100% certain what you mean by "new backend method". Do you mean to calculate the input size? If so, there's really nothing to do. Since OrderRouter
already knows it's account-based, it never collects the asset.FundingCoin
(only returned from asset.OutputTracker
anyway), which is where the input size comes from.
log.Errorf("rejecting account-based-base-asset order %s that has no coins ¯\\_(ツ)_/¯", lo.ID()) | ||
continue ordersLoop | ||
} | ||
addr = string(lo.Coins[0]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be an encoded AmountCoinID
, not just an address.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As we wiggle everything into place, one of the things we need to do is to "minimize" the API by eliminating any extraneous information. The server doesn't need the value encoded in the coin ID. It's knows how much to lock for the order.
Just looking forward, I would expect a number of changes to the existing server-side coin ID system. I'll comment in more detail on #1154 today so we can plan it out together. I sort of feel bad for not being more involved with the details of the coin IDs earlier, but it was so early in the process and I hadn't quite wrapped my head around everything. I'm getting there now.
What we discussed was that the server just needs to check things that if incorrectly done by one user would negatively affect another user. In this case, not having enough funds to redeem a swap would only negatively affect the user making the mistake, so this check could just be handled by the client code. |
If the maker fails to redeem, the taker has stuck funds. |
Just some additional shower thoughts on this... So technically, we don't need to check it if it's a market order or limit order with time-in-force = immediate, since the taker cannot inconvenience the maker and can only shoot themselves in the foot. This means that nobody can really point at DEX when things go wrong, BUT they will anyway. And while I agree it's not the server's job to write the client software, that won't stop some noob using a crappy or misconfigured client from running around screaming "DEX locked my money in a contract and wants me to pay to get it out". Screw that guy and his crappy client, obviously, but my point is that we maintain an interest in providing a smooth user experience and loss of funds is not the only bad thing that can happen to slow DEX adoption. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tests fine for me for UTXO coins. No real complaints.
server/asset/eth/eth.go
Outdated
// need to be per transaction. Setting this to zero produces the expected | ||
// result in fee calculations. | ||
func (eth *Backend) InitTxSizeBase() uint32 { | ||
return 0 | ||
return InitGas | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doc still says set to zero.
server/market/balancer.go
Outdated
matchNegotiator MatchNegotiator | ||
} | ||
|
||
// NewDEXBalancer is a constructor for a BackedBalancer. Provided assets will |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
constructor for a DEXBalancer?
assetInfo := backedAsset.assetInfo | ||
redeemCosts := uint64(redeems) * assetInfo.RedeemSize * assetInfo.MaxFeeRate | ||
reqFunds := calc.RequiredOrderFunds(qty, 0, lots, assetInfo) + redeemCosts | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe add a trace log with some info about the account and balances for now?
server/market/market.go
Outdated
log.Debugf("Checking %d base asset (%d) balances.", len(baseAcctStats), base) | ||
for acctAddr, stats := range baseAcctStats { | ||
if !cfg.Balancer.CheckBalance(acctAddr, mktInfo.Base, stats.qty, stats.lots, stats.redeems) { | ||
log.Errorf("%s base asset account failed the startup balance check on the %s market", acctAddr, mktInfo.Name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this error level? The account's balance could be too low through no fault of the server. I might even say info.
server/market/orderrouter.go
Outdated
@@ -474,21 +366,10 @@ func (r *OrderRouter) handleMarket(user account.AccountID, msg *msgjson.Message) | |||
var commit order.Commitment | |||
copy(commit[:], market.Commit) | |||
|
|||
fundingAsset := assets.funding | |||
// fundingAsset := assets.funding |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove?
server/market/orderrouter.go
Outdated
receivingBalancer, isToAccount := assets.receiving.Backend.(asset.AccountBalancer) | ||
if isToAccount { | ||
if redeemSig == nil { | ||
log.Errorf("user %s did not include a RedeemSig for received asset %s", user, assets.receiving.Symbol) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are all of the new logs here really error level? I suggest warn, or info, as they are not really a server problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm even thinking DBG for these, but we can keep INF for now I guess. Anything that a user can do at will with little cost shouldn't be too chatty in the logs.
fb2609e
to
eae5215
Compare
eae5215
to
bc8d9f1
Compare
3981af9
to
5cf469a
Compare
server/market/balancer.go
Outdated
} | ||
|
||
// NewDEXBalancer is a constructor for a DEXBalancer. Provided assets will | ||
// be filtered for those that are account-based. The matchNegotitator is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
matchNegotiator
// AccountPending sums the orders quantities that pay to or from the specified | ||
// account address. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could stand to describe redeems int
as well.
return msgjson.NewError(msgjson.OrderParameterError, "account-type asset funding requires exactly one coin ID") | ||
} | ||
acctProof := coins[0] | ||
if len(acctProof.PubKeys) != 1 || len(acctProof.Sigs) != 1 || len(acctProof.Redeem) > 0 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Working through things here:
- The "coin ID" in both
acctProof.ID
andtrade.Coins[0]
are equal, as evident in the callers,handleMarket
andhandleLimit
who populate theorder.Trade.Coins
from eachmsgjson.{Market,Limit}Order.Trade.Coins[i].ID
- These coin ID encodings deviate from the new transaction id/hash convention for server-side ETH coin IDs being established in server/asset/eth: update server coin ID parsing #1307. Hence
ValidateCoinID
cannot be used on them. - They are also the same as
order.(*Trade).FromAccount
before hex string encoding. order.(*Trade).ToAccount
is the same asorder.Trade.Address
and thusorder.(*Trade).SwapAddress
Mostly I want to be sure this is all the intended design and I'm not missing anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That mostly seems right. The term "coin ID" is so general now as to be confusing. The definition of coin ID need to be evaluated separately for each message and asset.
ValidateCoinID
is in a weird place right now, I guess. It's used for funding/swap/redeem coins for utxo-based assets, but only for swap/redeem coins for acct-based. I wouldn't mind breaking that method into as many as 3 separate methods, ValidateSwapCoin
, ValidateRedeemCoin
, and ValidateFundingCoin
(OutputTracker
-only). We could also combine the swap and redeem methods into something like ValidateTradeCoin
for now. Or we could just leave it. It's all terrible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's solve with docs for now rather than end up with a ton of new methods and wrappers and stuff
5cf469a
to
adc6809
Compare
server/asset/common.go
Outdated
// ValidateSignature checks that the pubkey is correct for the address and | ||
// that the signature shows ownership of the associated private key. | ||
ValidateSignature(addr string, pubkey, msg, sig []byte) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking of updating the docs here to direct enforcement of an address standard.
// ValidateSignature checks that the pubkey is correct for the address and
// that the signature shows ownership of the associated private key. The
// address string will be validated against a strict standard format. For
// example, ethereum address should have the "0x" prefix and be lower-case.
// Any other format should return an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ValidateSignature
, being an ETH backend method, shouldn't have trouble with any of the normal string encodings.
common.HexToAddress
handles all valid variants regardless of case or 0x prefix.
https://pkg.go.dev/github.com/ethereum/go-ethereum/common#HexToAddress
In the recent update to OwnsAddress
I followed the example from https://github.com/ethereum/go-ethereum/blob/7a0c19f813e285516f4b525305fd73b625d2dec8/common/types.go#L383 where it first validates with IsHexAddress
, then essentially does HexToAddress
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But this string encoding is used as a unique ID throughout dcrdex.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, it certainly needs to be standardized for all the other uses, yes, I just don't think ValidateSignature
is the place where it matters and hence not the best place to doc the standard we pick.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ValidateSignature
is run on all incoming orders. It seems like a great place to validate the address, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure we're on the same page. I'm trying to instruct asset backend developers to validate the address string against a strict standard. I'm just using ethereum as an example.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is under the assumption that we use the scheme defined in this PR, which is that the address is either the msgjson.Trade.Address
or string(msgjson.Trade.Coins[0])
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, well since order router and ValidateSignature are the initial validation points for the address string before it ends up playing a role as a map key in both book
and swap
, I suppose have it be strict about the encoding, more strict than it itself would require?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's what I'm trying to get at. Can improve the language, it seems.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another approach would be for ValidateSignature
to return a canonicalized address string after processing whatever string came in via the msgjson.Trade
fields. Would likely be unconverted for dcr or btc, but eth could accept any of the various encodings and spit out maybe just 0x{lowercasehex}?
The fact that msgjson.Trade.Address
has always been a string and that's hard to change now kind means we have to have stick with a string for the other address, but @davecgh did have a nice suggestion if we had bytes that could be put in a type like type Address interface { fmt.Stringer; Bytes() []byte }
, but alas the issue is upstream of that with the communications type committing us to a string. So I think we have to work with it as-is.
server/asset/common.go
Outdated
// ValidateSignature checks that the pubkey is correct for the address and | ||
// that the signature shows ownership of the associated private key. | ||
ValidateSignature(addr string, pubkey, msg, sig []byte) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another approach would be for ValidateSignature
to return a canonicalized address string after processing whatever string came in via the msgjson.Trade
fields. Would likely be unconverted for dcr or btc, but eth could accept any of the various encodings and spit out maybe just 0x{lowercasehex}?
The fact that msgjson.Trade.Address
has always been a string and that's hard to change now kind means we have to have stick with a string for the other address, but @davecgh did have a nice suggestion if we had bytes that could be put in a type like type Address interface { fmt.Stringer; Bytes() []byte }
, but alas the issue is upstream of that with the communications type committing us to a string. So I think we have to work with it as-is.
Adds funding validation for account-based assets i.e. eth. dex: Add RedeemSize field to Asset. msgjson: Add RedeemSig field to Trade. RedeemSig should be populated when the redemption is to an account-based asset. This PR also indirectly proposes a protocol for specifying the funding account. The account address should be utf-8 encoded and passed as the coin ID of the only coin, along with the pubkey and signature. The data signed will be the serialized msgjson.Limit or msgjson.Market, with server stamp set to 0. Same signature input for the RedeemSig. dex/order: The Trade type has methods for accessing the account address. server/asset: AccountBalancer gets a ValidateSignature method. general scheme: Orders and matches involving account-based assets are indexed by Swapper and OrderPQ in the same way they are indexed for users (account.AccountID). When an order comes into the order router, stats for existing orders, including outstanding redemptions, is fetched from all markets and the swapper. These stats are used with the new order info to validate the users balance. The user must have sufficient balance to cover all outstanding orders and redemptions. server/book: All OrderPQ are given an AccountTracker set up for any account-based assets, or none. The AccountTracker is essentially the same as the userOrders index, but indexed by account address instead. This is important because more than one user could be using the same account. AccountTracker has methods for iterating booked orders, exposed via Book and Market methods used by Market as part of the MarketTunnel interface method, PendingAccount, described below. server/swap: Swapper gets an account index to mirror its account index. The new AccountStats method returns information about in-process matches that aren't yet swapped/redeemed for a particular asset. server/market: OrderRouter is refactored for improved re-use between handleLimit and handleMarket. For account based assets, OrderRouter checks the MarketTunnels and MatchNegotiator for outstanding order and match info, and then queries the backend (via DEXBalancer) to see if the account has sufficient balance. NewMarket handles balance checks on startup. This is accomplished by additional tracking through the orders loop, and then a balance check for account-based assets immediately before adding to the order book. utxo-based asset handling is unchanged except that the lot size compliance check it moved earlier in the loop.
10a9466
to
ae1af1a
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Almost certainly my last review. Mainly nits at this point.
@chappjc I started moving |
Thanks for looking into it. What led me to suggest the change was because it looks like the returns from the Sorry if I'm still missing a detail, but roughly here's what I figure would work to keep the func (b *Book) Insert(o *order.LimitOrder) bool {
if o.Quantity%b.lotSize != 0 {
log.Warnf("(*Book).Insert: Refusing to insert an order with a quantity that is not a multiple of lot size.")
return false
}
b.mtx.Lock()
defer b.mtx.Unlock()
if o.Sell {
if b.sells.Insert(o) {
b.acctTracker.add(o)
return true
}
return false
}
if b.buys.Insert(o) {
b.acctTracker.add(o)
return true
}
return false
}
func (b *Book) Remove(oid order.OrderID) (*order.LimitOrder, bool) {
b.mtx.Lock()
defer b.mtx.Unlock()
if removed, ok := b.sells.RemoveOrderID(oid); ok {
b.acctTracker.remove(removed)
return removed, true
}
if removed, ok := b.buys.RemoveOrderID(oid); ok {
b.acctTracker.remove(removed)
return removed, true
}
return nil, false
}
func (b *Book) RemoveUserOrders(user account.AccountID) (removedBuys, removedSells []*order.LimitOrder) {
b.mtx.Lock()
defer b.mtx.Unlock()
removedBuys = b.buys.RemoveUserOrders(user)
for _, lo := range removedBuys {
b.acctTracker.remove(lo)
}
removedSells = b.sells.RemoveUserOrders(user)
for _, lo := range removedSells {
b.acctTracker.remove(lo)
}
return
}
func (b *Book) IterateBaseAccount(acctAddr string, f func(lo *order.LimitOrder)) {
b.mtx.RLock()
defer b.mtx.RUnlock()
b.acctTracker.iterateBaseAccount(acctAddr, f)
}
func (b *Book) IterateQuoteAccount(acctAddr string, f func(lo *order.LimitOrder)) {
b.mtx.RLock()
defer b.mtx.RUnlock()
b.acctTracker.iterateQuoteAccount(acctAddr, f)
} Perhaps I'm incorrectly lumping the two accountTracker's together? |
I made a mess of the tests, but it seems to work: 4264adf |
a367380
to
db76046
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good. Will run some interactive tests and wait for any possible other review follow-ups
I said I was gonna let that sit a while longer, but we've been over it plenty and we have a backlog of ETH PRs stacking up. |
Adds funding validation for account-based assets i.e. eth.
dex: Add
RedeemSize
field toAsset
.msgjson: Add
RedeemSig
field toTrade
.RedeemSig
should bepopulated when the redemption is to an account-based asset.
This PR also indirectly proposes a protocol for specifying
the funding account. The account address should be utf-8 encoded
and passed as the coin ID of the only coin, along with the pubkey
and signature. The data signed will be the serialized
msgjson.Limit
ormsgjson.Market
, with server stamp set to 0. Same signature inputfor the
RedeemSig
.*dex/order: The
Trade
type has methods for accessing the account address.server/asset:
AccountBalancer
gets aValidateSignature
method.general scheme: Orders and matches involving account-based assets are
indexed by
Swapper
andOrderPQ
in the same way they are indexed forusers (
account.AccountID
). When an order comes into the order router,stats for existing orders, including outstanding redemptions, is
fetched from all markets and the swapper. These stats are used with
the new order info to validate the users balance. The user must have
sufficient balance to cover all outstanding orders and redemptions.
server/book: All
OrderPQ
are given anAccountTracker
set up forany account-based assets, or none. The
AccountTracker
is essentiallythe same as the
userOrders
index, but indexed by account addressinstead. This is important because more than one user could be
using the same account.
AccountTracker
has methods for iterating booked orders, exposed viaBook
andMarket
methods used byMarket
as part of theMarketTunnel
interface method,
PendingAccount
, described below.server/swap:
Swapper
gets an account index to mirror its user index.The new
AccountStats
method returns information about in-process matchesthat aren't yet swapped/redeemed for a particular asset.
server/market:
OrderRouter
is refactored for improved re-use betweenhandleLimit
andhandleMarket
. For account based assets,OrderRouter
checks the
MarketTunnels
andMatchNegotiator
for outstandingorder and match info, and then queries the backend (via
DEXBalancer
)to see if the account has sufficient balance.
NewMarket
handles balance checks on startup. This is accomplishedby additional tracking through the orders loop, and then a balance
check for account-based assets immediately before adding to the
order book. utxo-based asset handling is unchanged except that the
lot size compliance check it moved earlier in the loop.