Skip to content

Commit

Permalink
Merge pull request #4043 from erika-sdf/liquidity
Browse files Browse the repository at this point in the history
services/horizon: Add an endpoint that allows querying for which liquidity pools an account is participating in
  • Loading branch information
erika-sdf authored Nov 4, 2021
2 parents 1c3c192 + 5d3ebf8 commit fa20a49
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 232 deletions.
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)

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 {
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

0 comments on commit fa20a49

Please sign in to comment.