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

multi: Add fee estimate oracle for tatanka #2769

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions dex/txfee/oracle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package txfee

import (
"context"
"errors"
"fmt"
"sync"
"time"

"decred.org/dcrdex/dex"
)

// Oracle provides transaction fees for all configured assets from external
// sources. Fee estimate values are in atoms for dcr, gwei for ethereum,
// satoshis for bitcoin and bitcoin clone blockchains (per byte sat), or the
// lowest non-divisible unit in other non-Bitcoin blockchains.
Comment on lines +14 to +16
Copy link
Contributor

Choose a reason for hiding this comment

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

They are all in the DEX AtomicUnit of the asset, right?

Copy link
Member

Choose a reason for hiding this comment

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

@ukane-philemon I though wei for eth was fine. It looks like it is returning wei.

Copy link
Contributor Author

@ukane-philemon ukane-philemon May 17, 2024

Choose a reason for hiding this comment

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

They are all in the DEX AtomicUnit of the asset, right?

Not sure what you mean, but they are all in their smallest denom, except eth (gwei). But a review from @JoeGruffins suggests wei so yes.

Copy link
Member

@buck54321 buck54321 May 19, 2024

Choose a reason for hiding this comment

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

We use "atomic units" to refer to the integer units used with asset.Wallet and asset.Backend. How asset.Wallet and asset.Backend scale the "atomic units" internally is of no concern outside of the respective wallet packages, really. Well, and here I guess. That said, we already know that there are some problems with this system. Specifically, Polygon and other evm blockchains can have fee rates smaller than 1 gwei per gas. This means that for swaps, we have to lock at least 1 gwei per gas, even if the actual network rate is substantially lower. The rate actually assessed is limited by the block's base fee rate, of course, but fee rates can determine minimum lot sizes and mm bot spreads etc. Luckily, 1 gwei / gas is very, very small even for ethereum, so the effects are not really noticeable. I do think it's worth discussing alternative ways to encode fee rates and maybe values.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Specifically, Polygon and other evm blockchains can have fee rates smaller than 1 gwei per gas. This means that for swaps, we have to lock at least 1 gwei per gas, even if the actual network rate is substantially lower. The rate actually assessed is limited by the block's base fee rate, of course, but fee rates can determine minimum lot sizes and mm bot spreads etc. Luckily, 1 gwei / gas is very, very small even for ethereum, so the effects are not really noticeable

I think we should stick to gwei for eth for now.

type Oracle struct {
chainIDs []uint32
sources []*feeEstimateSource

feeMtx sync.RWMutex
ukane-philemon marked this conversation as resolved.
Show resolved Hide resolved
txFeeEstimates map[uint32]*Estimate

listener chan<- map[uint32]*Estimate
}

// NewOracle returns a new instance of *Oracle.
func NewOracle(net dex.Network, cfg Config, chainsIDs []uint32, listener chan<- map[uint32]*Estimate) (*Oracle, error) {
if len(chainsIDs) == 0 {
return nil, errors.New("provide chainIDs to fetch fee estimate for")
}

if net != dex.Mainnet && net != dex.Testnet {
return nil, errors.New("fee estimate oracle is available for only mainnet and testnet")
}

o := &Oracle{
chainIDs: chainsIDs,
sources: feeEstimateSources(net, cfg),
txFeeEstimates: make(map[uint32]*Estimate),
listener: listener,
}

for _, chainID := range chainsIDs {
if sym := dex.BipIDSymbol(chainID); sym == "" {
return nil, fmt.Errorf("chainID %d is invalid", chainID)
}

// Init chain.
o.txFeeEstimates[chainID] = new(Estimate)
}

return o, nil
}

// FeeEstimates retrieves the current fee estimates.
func (o *Oracle) FeeEstimates() map[uint32]*Estimate {
o.feeMtx.RLock()
defer o.feeMtx.RUnlock()
feeEstimates := make(map[uint32]*Estimate, len(o.txFeeEstimates))
for chainID, feeEstimate := range o.txFeeEstimates {
if feeEstimate.Value > 0 && time.Since(feeEstimate.LastUpdated) < FeeEstimateExpiry {
fe := *feeEstimate
feeEstimates[chainID] = &fe
}
}
return feeEstimates
}

// calculateAverage calculates the average fee estimates and distributes the
// result to all listeners. Returns indexes of newly reactivated sources that we
// need to fetch fee estimate from.
func (o *Oracle) calculateAverage() []int {
var reActivatedSourceIndexes []int
totalFeeEstimates := make(map[uint32]*feeSourceCount)
for i := range o.sources {
source := o.sources[i]
if source.isDisabled() {
if source.checkIfSourceCanReactivate() {
reActivatedSourceIndexes = append(reActivatedSourceIndexes, i)
}
continue
}

source.mtx.Lock()
estimates := source.feeEstimates
source.mtx.Unlock()

for chainID, feeEstimate := range estimates {
if feeEstimate == 0 {
continue
}

if _, ok := totalFeeEstimates[chainID]; !ok {
totalFeeEstimates[chainID] = new(feeSourceCount)
}

totalFeeEstimates[chainID].totalSource++
totalFeeEstimates[chainID].totalFee += feeEstimate
}
}

now := time.Now()
o.feeMtx.Lock()
broadCastTxFees := make(map[uint32]*Estimate, len(o.txFeeEstimates))
for chainID := range o.txFeeEstimates {
if rateInfo := totalFeeEstimates[chainID]; rateInfo != nil {
fee := rateInfo.totalFee / uint64(rateInfo.totalSource)
if fee > 0 {
o.txFeeEstimates[chainID].Value = fee
o.txFeeEstimates[chainID].LastUpdated = now
estimate := *o.txFeeEstimates[chainID]
broadCastTxFees[chainID] = &estimate
}
}
}
o.feeMtx.Unlock()

// Notify all listeners if we have rates to broadcast.
if len(broadCastTxFees) > 0 {
o.listener <- broadCastTxFees
}

fmt.Println(broadCastTxFees)

return reActivatedSourceIndexes
}

// Run starts the tx fee oracle and blocks until the provided context is
// canceled.
func (o *Oracle) Run(ctx context.Context, log dex.Logger) {
nSuccessfulSources := o.fetchFromAllSource(ctx, log)
if nSuccessfulSources == 0 {
log.Errorf("Failed to retrieve fee estimate, exiting fee estimate oracle...")
return
}
o.calculateAverage()

ticker := time.NewTicker(defaultFeeRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return

case <-ticker.C:
o.fetchFromAllSource(ctx, log)
o.calculateAverage()
}
}
}

// fetchFromAllSource retrieves fee estimates from all fee estimate sources and
// returns the number of sources that successfully returned a fee estimate.
func (o *Oracle) fetchFromAllSource(ctx context.Context, log dex.Logger) int {
var nSuccessfulSources int
for i := range o.sources {
source := o.sources[i]
if source.isDisabled() {
continue
}

if source.hasFeeEstimates() && source.isExpired() {
source.deactivate(true)
log.Errorf("Fee estimate source %q has been disabled due to lack of fresh data. It will be re-enabled after %0.f hours.",
source.name, reactivationDuration.Hours())
continue
}

estimates, err := source.getFeeEstimate(ctx, log, o.chainIDs)
ukane-philemon marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
if isAuthError(err) {
source.deactivate(false)
log.Errorf("%s has been deactivated and cannot be auto reactivated due to %v", source.name, err)
} else if isRateLimitError(err) {
source.deactivate(true)
log.Errorf("Fee estimate source %q has been disabled (Reason: %v). It will be re-enabled after %0.f hours.",
source.name, err, reactivationDuration.Hours())
} else {
log.Errorf("%s.getFeeEstimate error: %v", source.name, err)
}
continue
}

nSuccessfulSources++
source.mtx.Lock()
source.feeEstimates = estimates
source.lastRefresh = time.Now()
source.mtx.Unlock()
}

return nSuccessfulSources
}
Loading
Loading