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

feat: Integrate Bitget for Rate Fetching & Median Calculation Across Multiple Providers #425

Open
wants to merge 5 commits into
base: main
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
18 changes: 9 additions & 9 deletions config/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ func ServerConfig() *ServerConfiguration {
viper.SetDefault("RATE_LIMIT_AUTHENTICATED", 50)

return &ServerConfiguration{
Debug: viper.GetBool("DEBUG"),
Host: viper.GetString("SERVER_HOST"),
Port: viper.GetString("SERVER_PORT"),
Timezone: viper.GetString("SERVER_TIMEZONE"),
AllowedHosts: viper.GetString("ALLOWED_HOSTS"),
Environment: viper.GetString("ENVIRONMENT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
HostDomain: viper.GetString("HOST_DOMAIN"),
Debug: viper.GetBool("DEBUG"),
Host: viper.GetString("SERVER_HOST"),
Port: viper.GetString("SERVER_PORT"),
Timezone: viper.GetString("SERVER_TIMEZONE"),
AllowedHosts: viper.GetString("ALLOWED_HOSTS"),
Environment: viper.GetString("ENVIRONMENT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
HostDomain: viper.GetString("HOST_DOMAIN"),
RateLimitUnauthenticated: viper.GetInt("RATE_LIMIT_UNAUTHENTICATED"),
RateLimitAuthenticated: viper.GetInt("RATE_LIMIT_AUTHENTICATED"),
RateLimitAuthenticated: viper.GetInt("RATE_LIMIT_AUTHENTICATED"),
}
}

Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -562,8 +562,6 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
Expand Down
90 changes: 1 addition & 89 deletions tasks/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1077,94 +1077,6 @@ func SubscribeToRedisKeyspaceEvents() {
go ReassignStaleOrderRequest(ctx, orderRequestChan)
}

// fetchExternalRate fetches the external rate for a fiat currency
func fetchExternalRate(currency string) (decimal.Decimal, error) {
currency = strings.ToUpper(currency)
supportedCurrencies := []string{"KES", "NGN", "GHS", "TZS", "UGX", "XOF"}
isSupported := false
for _, supported := range supportedCurrencies {
if currency == supported {
isSupported = true
break
}
}
if !isSupported {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: currency not supported")
}

// Fetch rates from third-party APIs
var price decimal.Decimal
if currency == "NGN" {
res, err := fastshot.NewClient("https://www.quidax.com").
Config().SetTimeout(30*time.Second).
Build().GET(fmt.Sprintf("/api/v1/markets/tickers/usdt%s", strings.ToLower(currency))).
Retry().Set(3, 5*time.Second).
Send()
if err != nil {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err)
}

data, err := utils.ParseJSONResponse(res.RawResponse)
if err != nil {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w %v", err, data)
}

price, err = decimal.NewFromString(data["data"].(map[string]interface{})["ticker"].(map[string]interface{})["buy"].(string))
if err != nil {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err)
}
} else {
res, err := fastshot.NewClient("https://p2p.binance.com").
Config().SetTimeout(30*time.Second).
Header().Add("Content-Type", "application/json").
Build().POST("/bapi/c2c/v2/friendly/c2c/adv/search").
Retry().Set(3, 5*time.Second).
Body().AsJSON(map[string]interface{}{
"asset": "USDT",
"fiat": currency,
"tradeType": "SELL",
"page": 1,
"rows": 20,
}).
Send()
if err != nil {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err)
}

resData, err := utils.ParseJSONResponse(res.RawResponse)
if err != nil {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err)
}

// Access the data array
data, ok := resData["data"].([]interface{})
if !ok || len(data) == 0 {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: No data in the response")
}

// Loop through the data array and extract prices
var prices []decimal.Decimal
for _, item := range data {
adv, ok := item.(map[string]interface{})["adv"].(map[string]interface{})
if !ok {
continue
}

price, err := decimal.NewFromString(adv["price"].(string))
if err != nil {
continue
}

prices = append(prices, price)
}

// Calculate and return the median
price = utils.Median(prices)
}

return price, nil
}

// ComputeMarketRate computes the market price for fiat currencies
func ComputeMarketRate() error {
ctx := context.Background()
Expand All @@ -1180,7 +1092,7 @@ func ComputeMarketRate() error {

for _, currency := range currencies {
// Fetch external rate
externalRate, err := fetchExternalRate(currency.Code)
externalRate, err := utils.FetchQuidaxRate(currency.Code)
if err != nil {
continue
}
Expand Down
7 changes: 0 additions & 7 deletions tasks/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"github.com/paycrest/aggregator/types"
"github.com/paycrest/aggregator/utils"
"github.com/paycrest/aggregator/utils/test"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -169,10 +168,4 @@ func TestTasks(t *testing.T) {

assert.Equal(t, hook.Status, webhookretryattempt.StatusExpired)
})

t.Run("fetchExternalRate", func(t *testing.T) {
value, err := fetchExternalRate("KSH")
assert.Error(t, err)
assert.Equal(t, value, decimal.Zero)
})
}
191 changes: 191 additions & 0 deletions utils/external_market.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package utils

import (
"fmt"

"strings"
"time"

fastshot "github.com/opus-domini/fast-shot"
"github.com/shopspring/decimal"
)

var (
BitgetAPIURL = "https://api.bitget.com"
BinanceAPIURL = "https://api.binance.com"
QuidaxAPIURL = "https://www.quidax.com/api/v1"
)

// fetchExternalRate fetches the external rate for a fiat currency
func FetchExternalRate(currency string) (decimal.Decimal, error) {
currency = strings.ToUpper(currency)
supportedCurrencies := []string{"KES", "NGN", "GHS", "TZS", "UGX", "XOF"}
isSupported := false
for _, supported := range supportedCurrencies {
if currency == supported {
isSupported = true
break
}
}
if !isSupported {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: currency not supported")
}

var prices []decimal.Decimal

// Fetch rates based on currency
if currency == "NGN" {
quidaxRate, err := FetchQuidaxRate(currency)
if err == nil {
prices = append(prices, quidaxRate)
}
} else {
binanceRate, err := FetchBinanceRate(currency)
if err == nil {
prices = append(prices, binanceRate)
}
}

// Fetch Bitget rate for all supported currencies
bitgetRate, err := FetchBitgetRate(currency)
if err == nil {
prices = append(prices, bitgetRate)
}

if len(prices) == 0 {
return decimal.Zero, fmt.Errorf("ComputeMarketRate: no valid rates found")
}

// Return the median price
return Median(prices), nil
}

// FetchQuidaxRate fetches the USDT exchange rate from Quidax (NGN only)
func FetchQuidaxRate(currency string) (decimal.Decimal, error) {
url := fmt.Sprintf("/api/v1/markets/tickers/usdt%s", strings.ToLower(currency))

res, err := fastshot.NewClient(QuidaxAPIURL).
Config().SetTimeout(30*time.Second).
Build().GET(url).
Retry().Set(3, 5*time.Second).
Send()
if err != nil {
return decimal.Zero, fmt.Errorf("FetchQuidaxRate: %w", err)
}

data, err := ParseJSONResponse(res.RawResponse)
if err != nil {
return decimal.Zero, fmt.Errorf("FetchQuidaxRate: %w", err)
}

price, err := decimal.NewFromString(data["data"].(map[string]interface{})["ticker"].(map[string]interface{})["buy"].(string))
if err != nil {
return decimal.Zero, fmt.Errorf("FetchQuidaxRate: %w", err)
}

return price, nil
}

// FetchBinanceRate fetches the median USDT exchange rate from Binance P2P
func FetchBinanceRate(currency string) (decimal.Decimal, error) {

res, err := fastshot.NewClient(BinanceAPIURL).
Config().SetTimeout(30*time.Second).
Header().Add("Content-Type", "application/json").
Build().POST("/bapi/c2c/v2/friendly/c2c/adv/search").
Retry().Set(3, 5*time.Second).
Body().AsJSON(map[string]interface{}{
"asset": "USDT",
"fiat": currency,
"tradeType": "SELL",
"page": 1,
"rows": 20,
}).
Send()
if err != nil {
return decimal.Zero, fmt.Errorf("FetchBinanceRate: %w", err)
}

resData, err := ParseJSONResponse(res.RawResponse)
if err != nil {
return decimal.Zero, fmt.Errorf("FetchBinanceRate: %w", err)
}

data, ok := resData["data"].([]interface{})
if !ok || len(data) == 0 {
return decimal.Zero, fmt.Errorf("FetchBinanceRate: no data in response")
}

var prices []decimal.Decimal
for _, item := range data {
adv, ok := item.(map[string]interface{})["adv"].(map[string]interface{})
if !ok {
continue
}

price, err := decimal.NewFromString(adv["price"].(string))
if err != nil {
continue
}

prices = append(prices, price)
}

if len(prices) == 0 {
return decimal.Zero, fmt.Errorf("FetchBinanceRate: no valid prices found")
}

return Median(prices), nil
}

// FetchBitgetRate fetches the median USDT exchange rate from Bitget P2P
func FetchBitgetRate(currency string) (decimal.Decimal, error) {

res, err := fastshot.NewClient(BitgetAPIURL).
Config().SetTimeout(30*time.Second).
Header().Add("Content-Type", "application/json").
Comment on lines +144 to +146
Copy link
Contributor

Choose a reason for hiding this comment

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

aren't some extra auth headers required?

Build().POST("/api/v2/p2p/adv/search").
Retry().Set(3, 5*time.Second).
Comment on lines +147 to +148
Copy link
Contributor

Choose a reason for hiding this comment

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

which documentation are you using for Bitget?

Copy link
Collaborator Author

@sundayonah sundayonah Feb 20, 2025

Choose a reason for hiding this comment

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

I thought I had already mentioned this earlier, but the Bitget endpoint you provided in the user story https://www.bitget.com/api-doc/common/p2p/Get-P2P-Order-List isn’t working

I’m getting the following error message:

This site can’t be reached
www.bitget.com’s DNS address could not be found. Diagnosing the problem.
DNS_PROBE_POSSIBLE

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Body().AsJSON(map[string]interface{}{
"tokenId": "USDT",
"fiat": currency,
"side": "sell",
"page": 1,
"size": 20,
}).
Send()
if err != nil {
return decimal.Zero, fmt.Errorf("FetchBitgetRate: %w", err)
}

resData, err := ParseJSONResponse(res.RawResponse)
if err != nil {
return decimal.Zero, fmt.Errorf("FetchBitgetRate: %w", err)
}

data, ok := resData["data"].([]interface{})
if !ok || len(data) == 0 {
return decimal.Zero, fmt.Errorf("FetchBitgetRate: no data in response")
}

var prices []decimal.Decimal
for _, item := range data {
adv, ok := item.(map[string]interface{})["price"].(string)
if !ok {
continue
}

price, err := decimal.NewFromString(adv)
if err != nil {
continue
}

prices = append(prices, price)
}

if len(prices) == 0 {
return decimal.Zero, fmt.Errorf("FetchBitgetRate: no valid prices found")
}

return Median(prices), nil
}
Loading
Loading