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

services/horizon: Add an endpoint that allows querying for which liquidity pools an account is participating in #4043

Merged
merged 20 commits into from
Nov 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions services/horizon/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to this project will be documented in this
file. This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Changes

* Add an endpoint that allows querying for which liquidity pools an account is participating in

## v2.10.0

This is a minor release with no DB Schema migrations nor explicit state rebuild.
Expand Down
6 changes: 4 additions & 2 deletions services/horizon/internal/actions/liquidity_pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,14 @@ func (handler GetLiquidityPoolByIDHandler) GetResource(w HeaderWriter, r *http.R
// LiquidityPoolsQuery query struct for liquidity_pools end-point
type LiquidityPoolsQuery struct {
Reserves string `schema:"reserves" valid:"optional"`
Account string `schema:"account" valid:"optional"`

reserves []xdr.Asset
}

// URITemplate returns a rfc6570 URI template the query struct
func (q LiquidityPoolsQuery) URITemplate() string {
return "/liquidity_pools?{?reserves}"
return "/liquidity_pools?{?reserves,account}"
}

// Validate validates and parses the query
Expand Down Expand Up @@ -105,7 +106,7 @@ type GetLiquidityPoolsHandler struct {
LedgerState *ledger.State
}

// GetResourcePage returns a page of claimable balances.
// GetResourcePage returns a page of liquidity pools.
func (handler GetLiquidityPoolsHandler) GetResourcePage(w HeaderWriter, r *http.Request) ([]hal.Pageable, error) {
ctx := r.Context()
qp := LiquidityPoolsQuery{}
Expand All @@ -121,6 +122,7 @@ func (handler GetLiquidityPoolsHandler) GetResourcePage(w HeaderWriter, r *http.

query := history.LiquidityPoolsQuery{
PageQuery: pq,
Account: qp.Account,
Assets: qp.reserves,
}

Expand Down
155 changes: 68 additions & 87 deletions services/horizon/internal/actions/liquidity_pool_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package actions

import (
"fmt"
"net/http/httptest"
"testing"

"github.com/stellar/go/keypair"
protocol "github.com/stellar/go/protocols/horizon"
"github.com/stellar/go/services/horizon/internal/db2/history"
"github.com/stellar/go/services/horizon/internal/test"
"github.com/stellar/go/support/errors"
"github.com/stellar/go/support/render/problem"
"github.com/stellar/go/xdr"
"github.com/stretchr/testify/assert"
)

func TestGetLiquidityPoolByID(t *testing.T) {
Expand All @@ -18,25 +21,7 @@ func TestGetLiquidityPoolByID(t *testing.T) {
test.ResetHorizonDB(t, tt.HorizonDB)
q := &history.Q{tt.HorizonSession()}

lp := history.LiquidityPool{
PoolID: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
Type: xdr.LiquidityPoolTypeLiquidityPoolConstantProduct,
Fee: 30,
TrustlineCount: 100,
ShareCount: 2000000000,
AssetReserves: history.LiquidityPoolAssetReserves{
{
Asset: xdr.MustNewNativeAsset(),
Reserve: 100,
},
{
Asset: xdr.MustNewCreditAsset("USD", "GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML"),
Reserve: 200,
},
},
LastModifiedLedger: 100,
}

lp := history.MakeTestPool(xdr.MustNewNativeAsset(), 100, usdAsset, 200)
err := q.UpsertLiquidityPools(tt.Ctx, []history.LiquidityPool{lp})
tt.Assert.NoError(err)

Expand All @@ -53,13 +38,12 @@ func TestGetLiquidityPoolByID(t *testing.T) {
tt.Assert.Equal(lp.PoolID, resource.ID)
tt.Assert.Equal("constant_product", resource.Type)
tt.Assert.Equal(uint32(30), resource.FeeBP)
tt.Assert.Equal(uint64(100), resource.TotalTrustlines)
tt.Assert.Equal("200.0000000", resource.TotalShares)

tt.Assert.Equal(uint64(12345), resource.TotalTrustlines)
tt.Assert.Equal("0.0067890", resource.TotalShares)
tt.Assert.Equal("native", resource.Reserves[0].Asset)
tt.Assert.Equal("0.0000100", resource.Reserves[0].Amount)

tt.Assert.Equal("USD:GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML", resource.Reserves[1].Asset)
tt.Assert.Equal(usdAsset.StringCanonical(), resource.Reserves[1].Asset)
tt.Assert.Equal("0.0000200", resource.Reserves[1].Amount)

// try to fetch pool which does not exist
Expand Down Expand Up @@ -92,42 +76,8 @@ func TestGetLiquidityPools(t *testing.T) {
test.ResetHorizonDB(t, tt.HorizonDB)
q := &history.Q{tt.HorizonSession()}

lp1 := history.LiquidityPool{
PoolID: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
Type: xdr.LiquidityPoolTypeLiquidityPoolConstantProduct,
Fee: 30,
TrustlineCount: 100,
ShareCount: 2000000000,
AssetReserves: history.LiquidityPoolAssetReserves{
{
Asset: xdr.MustNewNativeAsset(),
Reserve: 100,
},
{
Asset: xdr.MustNewCreditAsset("USD", "GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML"),
Reserve: 200,
},
},
LastModifiedLedger: 100,
}
lp2 := history.LiquidityPool{
PoolID: "d827bf10a721d217de3cd9ab3f10198a54de558c093a511ec426028618df2633",
Type: xdr.LiquidityPoolTypeLiquidityPoolConstantProduct,
Fee: 30,
TrustlineCount: 300,
ShareCount: 4000000000,
AssetReserves: history.LiquidityPoolAssetReserves{
{
Asset: xdr.MustNewCreditAsset("EUR", "GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML"),
Reserve: 300,
},
{
Asset: xdr.MustNewCreditAsset("USD", "GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML"),
Reserve: 400,
},
},
LastModifiedLedger: 100,
}
lp1 := history.MakeTestPool(nativeAsset, 100, usdAsset, 200)
lp2 := history.MakeTestPool(eurAsset, 300, usdAsset, 400)
err := q.UpsertLiquidityPools(tt.Ctx, []history.LiquidityPool{lp1, lp2})
tt.Assert.NoError(err)

Expand All @@ -142,48 +92,79 @@ func TestGetLiquidityPools(t *testing.T) {
tt.Assert.Len(response, 2)

resource := response[0].(protocol.LiquidityPool)
tt.Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", resource.ID)
tt.Assert.Equal(lp1.PoolID, resource.ID)
tt.Assert.Equal("constant_product", resource.Type)
tt.Assert.Equal(uint32(30), resource.FeeBP)
tt.Assert.Equal(uint64(100), resource.TotalTrustlines)
tt.Assert.Equal("200.0000000", resource.TotalShares)
tt.Assert.Equal(uint64(12345), resource.TotalTrustlines)
tt.Assert.Equal("0.0067890", resource.TotalShares)

tt.Assert.Equal("native", resource.Reserves[0].Asset)
tt.Assert.Equal("0.0000100", resource.Reserves[0].Amount)

tt.Assert.Equal("USD:GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML", resource.Reserves[1].Asset)
tt.Assert.Equal(usdAsset.StringCanonical(), resource.Reserves[1].Asset)
tt.Assert.Equal("0.0000200", resource.Reserves[1].Amount)

resource = response[1].(protocol.LiquidityPool)
tt.Assert.Equal("d827bf10a721d217de3cd9ab3f10198a54de558c093a511ec426028618df2633", resource.ID)
tt.Assert.Equal(lp2.PoolID, resource.ID)
tt.Assert.Equal("constant_product", resource.Type)
tt.Assert.Equal(uint32(30), resource.FeeBP)
tt.Assert.Equal(uint64(300), resource.TotalTrustlines)
tt.Assert.Equal("400.0000000", resource.TotalShares)
tt.Assert.Equal(uint64(12345), resource.TotalTrustlines)
tt.Assert.Equal("0.0067890", resource.TotalShares)

tt.Assert.Equal("EUR:GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML", resource.Reserves[0].Asset)
tt.Assert.Equal(eurAsset.StringCanonical(), resource.Reserves[0].Asset)
tt.Assert.Equal("0.0000300", resource.Reserves[0].Amount)

tt.Assert.Equal("USD:GC3C4AKRBQLHOJ45U4XG35ESVWRDECWO5XLDGYADO6DPR3L7KIDVUMML", resource.Reserves[1].Asset)
tt.Assert.Equal(usdAsset.StringCanonical(), resource.Reserves[1].Asset)
tt.Assert.Equal("0.0000400", resource.Reserves[1].Amount)

response, err = handler.GetResourcePage(httptest.NewRecorder(), makeRequest(
t,
map[string]string{"reserves": "native"},
map[string]string{},
q,
))
tt.Assert.NoError(err)
tt.Assert.Len(response, 1)

response, err = handler.GetResourcePage(httptest.NewRecorder(), makeRequest(
t,
map[string]string{"cursor": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"},
map[string]string{},
q,
))
tt.Assert.NoError(err)
tt.Assert.Len(response, 1)
resource = response[0].(protocol.LiquidityPool)
tt.Assert.Equal("d827bf10a721d217de3cd9ab3f10198a54de558c093a511ec426028618df2633", resource.ID)
t.Run("filtering by reserves", func(t *testing.T) {
response, err = handler.GetResourcePage(httptest.NewRecorder(), makeRequest(
t,
map[string]string{"reserves": "native"},
map[string]string{},
q,
))
assert.NoError(t, err)
assert.Len(t, response, 1)
})

t.Run("paging via cursor", func(t *testing.T) {
response, err = handler.GetResourcePage(httptest.NewRecorder(), makeRequest(
t,
map[string]string{"cursor": lp1.PoolID},
map[string]string{},
q,
))
assert.NoError(t, err)
assert.Len(t, response, 1)
resource = response[0].(protocol.LiquidityPool)
assert.Equal(t, lp2.PoolID, resource.ID)
})

t.Run("filtering by participating account", func(t *testing.T) {
// we need to add trustlines to filter by account
accountId := keypair.MustRandom().Address()
assert.NoError(t, q.UpsertTrustLines(tt.Ctx, []history.TrustLine{
history.MakeTestTrustline(accountId, nativeAsset, ""),
history.MakeTestTrustline(accountId, eurAsset, ""),
history.MakeTestTrustline(accountId, xdr.Asset{}, lp1.PoolID),
}))

request := makeRequest(
t,
map[string]string{"account": accountId},
map[string]string{},
q,
)
assert.Contains(t, request.URL.String(), fmt.Sprintf("account=%s", accountId))

handler := GetLiquidityPoolsHandler{}
response, err := handler.GetResourcePage(httptest.NewRecorder(), request)
assert.NoError(t, err)
assert.Len(t, response, 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

Worth checking the actual response if the LP is the one we expect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This check has been added.


assert.IsType(t, protocol.LiquidityPool{}, response[0])
resource = response[0].(protocol.LiquidityPool)
assert.Equal(t, lp1.PoolID, resource.ID)
})
}
94 changes: 85 additions & 9 deletions services/horizon/internal/db2/history/liquidity_pools.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"strings"

sq "github.com/Masterminds/squirrel"
"github.com/guregu/null"
"github.com/stellar/go/services/horizon/internal/db2"
"github.com/stellar/go/support/errors"
"github.com/stellar/go/xdr"
Expand All @@ -15,6 +18,7 @@ import (
type LiquidityPoolsQuery struct {
PageQuery db2.PageQuery
Assets []xdr.Asset
Account string
}

// LiquidityPool is a row of data from the `liquidity_pools`.
Expand Down Expand Up @@ -150,22 +154,29 @@ func (q *Q) FindLiquidityPoolByID(ctx context.Context, liquidityPoolID string) (
return lp, err
}

// GetLiquidityPools finds all liquidity pools where accountID is one of the claimants
// GetLiquidityPools finds all liquidity pools where accountID owns assets
func (q *Q) GetLiquidityPools(ctx context.Context, query LiquidityPoolsQuery) ([]LiquidityPool, error) {
if len(query.Account) > 0 && len(query.Assets) > 0 {
return nil, fmt.Errorf("this endpoint does not support filtering by both accountID and reserve assets.")
}

sql, err := query.PageQuery.ApplyRawTo(selectLiquidityPools, "lp.id")
if err != nil {
return nil, errors.Wrap(err, "could not apply query to page")
}
sql = sql.Where("deleted = ?", false)

for _, asset := range query.Assets {
assetB64, err := xdr.MarshalBase64(asset)
if err != nil {
return nil, err
if len(query.Account) > 0 {
sql = sql.LeftJoin("trust_lines ON id = liquidity_pool_id").Where("trust_lines.account_id = ?", query.Account)
} else if len(query.Assets) > 0 {
for _, asset := range query.Assets {
assetB64, err := xdr.MarshalBase64(asset)
if err != nil {
return nil, err
}
sql = sql.
Where(`lp.asset_reserves @> '[{"asset": "` + assetB64 + `"}]'`)
}
sql = sql.
Where(`lp.asset_reserves @> '[{"asset": "` + assetB64 + `"}]'`)
}
sql = sql.Where("lp.deleted = ?", false)

var results []LiquidityPool
if err := q.Select(ctx, &results, sql); err != nil {
Expand Down Expand Up @@ -219,3 +230,68 @@ var liquidityPoolsSelectStatement = "lp.id, " +
"lp.last_modified_ledger"

var selectLiquidityPools = sq.Select(liquidityPoolsSelectStatement).From("liquidity_pools lp")

// MakeTestPool is a helper to make liquidity pools for testing purposes. It's
// public because it's used in other test suites.
func MakeTestPool(A xdr.Asset, a uint64, B xdr.Asset, b uint64) LiquidityPool {
Copy link
Contributor

Choose a reason for hiding this comment

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

note for reviewers: these are intentionally public as they're used both in these tests and in the actions' tests.

if !A.LessThan(B) {
B, A = A, B
b, a = a, b
}

poolId, _ := xdr.NewPoolId(A, B, xdr.LiquidityPoolFeeV18)
hexPoolId, _ := xdr.MarshalHex(poolId)
return LiquidityPool{
PoolID: hexPoolId,
Type: xdr.LiquidityPoolTypeLiquidityPoolConstantProduct,
Fee: xdr.LiquidityPoolFeeV18,
TrustlineCount: 12345,
ShareCount: 67890,
AssetReserves: []LiquidityPoolAssetReserve{
{Asset: A, Reserve: a},
{Asset: B, Reserve: b},
},
LastModifiedLedger: 123,
}
}

func MakeTestTrustline(account string, asset xdr.Asset, poolId string) TrustLine {
trustline := TrustLine{
AccountID: account,
Balance: 1000,
AssetCode: "",
AssetIssuer: "",
LedgerKey: account + asset.StringCanonical() + poolId, // irrelevant, just needs to be unique
LiquidityPoolID: poolId,
Flags: 0,
LastModifiedLedger: 1234,
Sponsor: null.String{},
}

if poolId == "" {
trustline.AssetType = asset.Type
switch asset.Type {
case xdr.AssetTypeAssetTypeNative:
trustline.AssetCode = "native"

case xdr.AssetTypeAssetTypeCreditAlphanum4:
fallthrough
case xdr.AssetTypeAssetTypeCreditAlphanum12:
trustline.AssetCode = strings.TrimRight(asset.GetCode(), "\x00") // no nulls in db string
trustline.AssetIssuer = asset.GetIssuer()
trustline.BuyingLiabilities = 1
trustline.SellingLiabilities = 1

default:
panic("invalid asset type")
}

trustline.Limit = trustline.Balance * 10
trustline.BuyingLiabilities = 1
trustline.SellingLiabilities = 2
} else {
trustline.AssetType = xdr.AssetTypeAssetTypePoolShare
}

return trustline
}
Loading