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

Implement Schutz's algorithm for calculating bid/ask spreads #62

Closed
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
70 changes: 70 additions & 0 deletions mercury/v3/aggregate_functions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package v3

import (
"fmt"
"sort"

"github.com/shopspring/decimal"
)

func GetConsensusPrices(paos []PAO, f int) (Prices, error) {
var validPrices []Prices
for _, pao := range paos {
prices, valid := pao.GetPrices()
if valid {
validPrices = append(validPrices, prices)
}
}
// FIXME: This should actually check for < 2*f+1, but we can't do that in
// this mercury plugin because it doesn't support ValidateObservation
if len(validPrices) < f+1 {
return Prices{}, fmt.Errorf("fewer than f+1 observations have a valid price (got: %d/%d)", len(validPrices), len(paos))
}

bidSpreads := make([]decimal.Decimal, len(validPrices))
askSpreads := make([]decimal.Decimal, len(validPrices))
for i, p := range validPrices {
bid := decimal.NewFromBigInt(p.Bid, 0)
ask := decimal.NewFromBigInt(p.Ask, 0)
benchmark := decimal.NewFromBigInt(p.Benchmark, 0)

bidSpreads[i] = bid.Div(benchmark)
askSpreads[i] = ask.Div(benchmark)
}

prices := Prices{}

sort.Slice(validPrices, func(i, j int) bool {
return validPrices[i].Benchmark.Cmp(validPrices[j].Benchmark) < 0
})
prices.Benchmark = validPrices[len(validPrices)/2].Benchmark
benchmarkDecimal := decimal.NewFromBigInt(prices.Benchmark, 0)

sort.Slice(bidSpreads, func(i, j int) bool {
return bidSpreads[i].Cmp(bidSpreads[j]) < 0
})
// We started with at least 2f+1 observations. There are at most f
// dishonest participants. Suppose with threw out m observations for
// disordered prices. Then we are left with 2f+1-m observations, f-m of
// which could still have come from dishonest participants. But
// 2f+1-m=2(f-m)+(m+1), so the median must come from an honest participant.
medianBidSpread := bidSpreads[len(bidSpreads)/2]

Choose a reason for hiding this comment

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

I recommend adding a comment here, justifying the use of the median. Something along the lines of

We started with at least 2f+1 observations. There are at most f dishonest participants. Suppose with threw out m observations for disordered prices. Then we are left with 2f+1-m observations, f-m of which could still have come from dishonest participants. But 2f+1-m=2(f-m)+(m+1), so the median must come from an honest participant.


prices.Bid = benchmarkDecimal.Mul(medianBidSpread).BigInt()
samsondav marked this conversation as resolved.
Show resolved Hide resolved
if prices.Bid.Cmp(prices.Benchmark) > 0 {
// Cannot happen unless > f nodes are inverted which is by assumption undefined behavior
return Prices{}, fmt.Errorf("invariant violation: bid price is greater than benchmark price (bid: %s, benchmark: %s)", prices.Bid.String(), benchmarkDecimal.String())
}

sort.Slice(askSpreads, func(i, j int) bool {
return askSpreads[i].Cmp(askSpreads[j]) < 0
})
medianAskSpread := askSpreads[len(askSpreads)/2]
prices.Ask = benchmarkDecimal.Mul(medianAskSpread).BigInt()
samsondav marked this conversation as resolved.
Show resolved Hide resolved
if prices.Ask.Cmp(prices.Benchmark) < 0 {
// Cannot happen unless > f nodes are inverted which is by assumption undefined behavior
return Prices{}, fmt.Errorf("invariant violation: ask price is less than benchmark price (ask: %s, benchmark: %s)", prices.Ask.String(), benchmarkDecimal.String())
}

return prices, nil
}
232 changes: 232 additions & 0 deletions mercury/v3/aggregate_functions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package v3

import (
"math/big"
"testing"

"github.com/smartcontractkit/libocr/commontypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var _ PAO = testPAO{}

type testPAO struct {
Bid *big.Int
Benchmark *big.Int
Ask *big.Int
PricesValid bool
}

func (t testPAO) GetPrices() (prices Prices, valid bool) {
return Prices{
Benchmark: t.Benchmark,
Bid: t.Bid,
Ask: t.Ask,
}, t.PricesValid
}

func (t testPAO) GetObserver() commontypes.OracleID { return 0 }
func (t testPAO) GetTimestamp() uint32 { return 0 }
func (t testPAO) GetBenchmarkPrice() (*big.Int, bool) { return nil, false }
func (t testPAO) GetMaxFinalizedTimestamp() (int64, bool) { return 0, false }
func (t testPAO) GetLinkFee() (*big.Int, bool) { return nil, false }
func (t testPAO) GetNativeFee() (*big.Int, bool) { return nil, false }

func Test_GetConsensusPrices(t *testing.T) {
tests := []struct {
name string
paos []PAO
f int
want Prices
err string
}{
{
name: "not enough valid observations",
paos: []PAO{
testPAO{
PricesValid: false,
},
testPAO{
PricesValid: false,
},
testPAO{
Bid: big.NewInt(20),
Benchmark: big.NewInt(21),
Ask: big.NewInt(23),
PricesValid: true,
},
},
f: 1,
want: Prices{},
err: "fewer than f+1 observations have a valid price (got: 1/3)",
},
{
name: "handles simple case",
paos: []PAO{
testPAO{
Bid: big.NewInt(1),
Benchmark: big.NewInt(2),
Ask: big.NewInt(3),
PricesValid: true,
},
testPAO{
Bid: big.NewInt(9),
Benchmark: big.NewInt(9),
Ask: big.NewInt(9),
PricesValid: true,
},
testPAO{
Bid: big.NewInt(20),
Benchmark: big.NewInt(21),
Ask: big.NewInt(23),
PricesValid: true,
},
},
f: 1,
want: Prices{
Bid: big.NewInt(8),
Benchmark: big.NewInt(9),
Ask: big.NewInt(9),
},
err: "",
},
{
name: "handles simple inverted case",
paos: []PAO{
testPAO{
Bid: big.NewInt(1),
Benchmark: big.NewInt(2),
Ask: big.NewInt(3),
PricesValid: true,
},
testPAO{
Bid: big.NewInt(10),
Benchmark: big.NewInt(9),
Ask: big.NewInt(8),
PricesValid: true,
},
testPAO{
Bid: big.NewInt(20),
Benchmark: big.NewInt(21),
Ask: big.NewInt(23),
PricesValid: true,
},
},
f: 1,
want: Prices{
Bid: big.NewInt(8),
Benchmark: big.NewInt(9),
Ask: big.NewInt(9),
},
err: "",
},
{
name: "handles complex inverted case (real world example)",
paos: []PAO{
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask > benchmark
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315243916), big.NewInt(1315661031), big.NewInt(1316078096), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
},
f: 5,
want: Prices{Bid: big.NewInt(1315773517), Benchmark: big.NewInt(1316190800), Ask: big.NewInt(1316526999)},
err: "",
},
{
name: "handles complex inverted case (f failures of various types)",
paos: []PAO{
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315131262), big.NewInt(1314633333), big.NewInt(1315629191), true}, // inverted, bid > benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(2000000000), big.NewInt(1316190800), big.NewInt(1316527000), true}, // inverted, bid >>>> ask
testPAO{big.NewInt(2000000000), big.NewInt(1315131262), big.NewInt(2001000000), true}, // inverted, bid >>>> benchmark
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1000000000), true}, // inverted, ask <<<< benchmark
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315243916), big.NewInt(1315661031), big.NewInt(1316078096), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
},
f: 5,
want: Prices{Bid: big.NewInt(1315854499), Benchmark: big.NewInt(1316190800), Ask: big.NewInt(1316526999)},
err: "",
},
{
name: "handles complex inverted case (f failures skewed the same way)",
paos: []PAO{
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1315661031), big.NewInt(1316078096), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
},
f: 5,
want: Prices{Bid: big.NewInt(1315692469), Benchmark: big.NewInt(1316190800), Ask: big.NewInt(1316526999)},
err: "",
},
{
name: "errors output in complex inverted case with f+1 failures such that ask > benchmark",
paos: []PAO{
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1315243916), big.NewInt(1316190800), big.NewInt(1316078096), true}, // inverted, ask < benchmark
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
},
f: 5,
want: Prices{},
err: "invariant violation: ask price is less than benchmark price (ask: 1316078096, benchmark: 1316190800)",
},
{
name: "errors output in complex inverted case with f+1 failures such that bid < benchmark",
paos: []PAO{
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1315854500), big.NewInt(1316190800), big.NewInt(1316527000), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1314633333), big.NewInt(1315131262), big.NewInt(1315629191), true},
testPAO{big.NewInt(1315131263), big.NewInt(1315131262), big.NewInt(1315629191), true}, // inverted, bid > benchmark
testPAO{big.NewInt(1315131263), big.NewInt(1315131262), big.NewInt(1315629191), true}, // inverted, bid > benchmark
testPAO{big.NewInt(1315131263), big.NewInt(1315131262), big.NewInt(1315629191), true}, // inverted, bid > benchmark
testPAO{big.NewInt(1315131263), big.NewInt(1315131262), big.NewInt(1315629191), true}, // inverted, bid > benchmark
testPAO{big.NewInt(1315131263), big.NewInt(1315131262), big.NewInt(1315629191), true}, // inverted, bid > benchmark
testPAO{big.NewInt(1315131263), big.NewInt(1315131262), big.NewInt(1315629191), true}, // inverted, bid > benchmark
},
f: 5,
want: Prices{},
err: "invariant violation: bid price is greater than benchmark price (bid: 1315131263, benchmark: 1315131262)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prices, err := GetConsensusPrices(tt.paos, tt.f)
if tt.err != "" {
require.Error(t, err)
assert.Equal(t, tt.err, err.Error())
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.want, prices)
})
}
}
48 changes: 4 additions & 44 deletions mercury/v3/mercury.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,7 @@ func (rp *reportingPlugin) Observation(ctx context.Context, repts types.ReportTi
}

if bpErr == nil && bidErr == nil && askErr == nil {
if err := validatePrices(obs.Bid.Val, obs.BenchmarkPrice.Val, obs.Ask.Val); err != nil {
rp.logger.Errorw("Cannot generate price observation: invalid bid/mid/ask", "err", err)
p.PricesValid = false
} else {
p.PricesValid = true
}
p.PricesValid = true
}

var maxFinalizedTimestampErr error
Expand Down Expand Up @@ -230,13 +225,6 @@ func (rp *reportingPlugin) Observation(ctx context.Context, repts types.ReportTi
return proto.Marshal(&p)
}

func validatePrices(bid, benchmarkPrice, ask *big.Int) error {
if bid.Cmp(benchmarkPrice) > 0 || benchmarkPrice.Cmp(ask) > 0 {
return fmt.Errorf("invariant violated: expected bid<=mid<=ask, got bid: %s, mid: %s, ask: %s", bid, benchmarkPrice, ask)
}
return nil
}

func parseAttributedObservation(ao types.AttributedObservation) (PAO, error) {
var pao parsedAttributedObservation
var obs MercuryObservationProto
Expand All @@ -261,13 +249,6 @@ func parseAttributedObservation(ao types.AttributedObservation) (PAO, error) {
if err != nil {
return parsedAttributedObservation{}, fmt.Errorf("ask cannot be converted to big.Int: %s", err)
}
if err := validatePrices(pao.Bid, pao.BenchmarkPrice, pao.Ask); err != nil {
// NOTE: since nodes themselves are not supposed to set
// PricesValid=true if this invariant is violated, this indicates a
// faulty/misbehaving node and the entire observation should be
// ignored
return parsedAttributedObservation{}, fmt.Errorf("observation claimed to be valid, but contains invalid prices: %w", err)
}
pao.PricesValid = true
}

Expand Down Expand Up @@ -383,20 +364,11 @@ func (rp *reportingPlugin) buildReportFields(previousReport types.Report, paos [
}
}

rf.BenchmarkPrice, err = mercury.GetConsensusBenchmarkPrice(mPaos, rp.f)
if err != nil {
merr = errors.Join(merr, fmt.Errorf("GetConsensusBenchmarkPrice failed: %w", err))
}

rf.Bid, err = mercury.GetConsensusBid(convertBid(paos), rp.f)
prices, err := GetConsensusPrices(paos, rp.f)
if err != nil {
merr = errors.Join(merr, fmt.Errorf("GetConsensusBid failed: %w", err))
}

rf.Ask, err = mercury.GetConsensusAsk(convertAsk(paos), rp.f)
if err != nil {
merr = errors.Join(merr, fmt.Errorf("GetConsensusAsk failed: %w", err))
merr = errors.Join(merr, fmt.Errorf("GetConsensusPrices failed: %w", err))
}
rf.Bid, rf.BenchmarkPrice, rf.Ask = prices.Bid, prices.Benchmark, prices.Ask

rf.LinkFee, err = mercury.GetConsensusLinkFee(convertLinkFee(paos), rp.f)
if err != nil {
Expand Down Expand Up @@ -455,18 +427,6 @@ func convertMaxFinalizedTimestamp(pao []PAO) (ret []mercury.PAOMaxFinalizedTimes
}
return ret
}
func convertBid(pao []PAO) (ret []mercury.PAOBid) {
for _, v := range pao {
ret = append(ret, v)
}
return ret
}
func convertAsk(pao []PAO) (ret []mercury.PAOAsk) {
for _, v := range pao {
ret = append(ret, v)
}
return ret
}
func convertLinkFee(pao []PAO) (ret []mercury.PAOLinkFee) {
for _, v := range pao {
ret = append(ret, v)
Expand Down
Loading