Compare commits

..

18 Commits

Author SHA1 Message Date
Alfred Kamanda
fe897cca84 added the GetCreditSendReverseQuote to tests 2025-10-28 11:34:21 +03:00
Alfred Kamanda
7eaa771eb4 added the GetCreditSendReverseQuote function 2025-10-28 11:33:17 +03:00
Alfred Kamanda
72af514cf3 added the CreditSendReverseQouteResult type for API responses 2025-10-28 11:32:51 +03:00
Alfred Kamanda
81ff6c4034 added the CreditSendReverseQuote prefix and URL 2025-10-28 11:32:03 +03:00
Alfred Kamanda
a705443786 added a comment to describe the use of the TestAccountService 2025-10-28 11:10:48 +03:00
Alfred Kamanda
af76541c86 added the USD voucher to the TokenHoldings 2025-10-28 11:09:33 +03:00
Alfred Kamanda
75a7ec6b32 correct the spelling of toTokenAddress 2025-10-28 11:06:01 +03:00
Alfred Kamanda
3e82e16923 added the GetCreditSendMaxLimit to tests 2025-10-28 11:04:43 +03:00
Alfred Kamanda
c11060648d added the CreditSendLimitsResult type for the API responses 2025-10-28 11:04:12 +03:00
Alfred Kamanda
01569b9b39 added the GetCreditSendMaxLimit function 2025-10-28 11:03:08 +03:00
Alfred Kamanda
96c323f202 added the CreditSendPrefix and URL 2025-10-28 11:02:33 +03:00
2731a787e3 Merge pull request 'handle-error-codes' (#18) from handle-error-codes into master
Reviewed-on: #18 and is working on prod
2025-10-27 11:29:55 +01:00
Alfred Kamanda
532547899f include the error code 2025-10-22 11:46:13 +03:00
Alfred Kamanda
6f7802b58c modify doRequest() to return APIError on err 2025-10-21 15:05:22 +03:00
Alfred Kamanda
73e6220a8c create a custom error struct that carries both fields from the API 2025-10-21 15:02:36 +03:00
8d4fbb9c2e Merge pull request 'Normalize symbols before returning' (#17) from sanitize-symbols into master
Reviewed-on: #17

Merged after successful tests
2025-10-06 11:02:42 +02:00
Alfred Kamanda
61410b2b29 Normalize symbols before returning 2025-10-06 11:43:25 +03:00
c44ac0116f Merge pull request 'update-alias' (#15) from update-alias into master
Reviewed-on: #15
2025-08-19 11:35:23 +02:00
7 changed files with 208 additions and 52 deletions

View File

@@ -7,25 +7,27 @@ import (
)
const (
createAccountPath = "/api/v2/account/create"
trackStatusPath = "/api/track"
balancePathPrefix = "/api/account"
trackPath = "/api/v2/account/status"
tokenTransferPrefix = "/api/v2/token/transfer"
voucherHoldingsPathPrefix = "/api/v1/holdings"
voucherTransfersPathPrefix = "/api/v1/transfers/last10"
voucherDataPathPrefix = "/api/v1/token"
SendSMSPrefix = "api/v1/external/upsell"
poolDepositPrefix = "/api/v2/pool/deposit"
poolSwapQoutePrefix = "/api/v2/pool/quote"
poolSwapPrefix = "/api/v2/pool/swap"
topPoolsPrefix = "/api/v1/pool/top"
retrievePoolDetailsPrefix = "/api/v1/pool/reverse"
poolSwappableVouchersPrefix = "/api/v1/pool"
AliasRegistrationPrefix = "/api/v1/internal/register"
AliasResolverPrefix = "/api/v1/resolve"
ExternalSMSPrefix = "/api/v1/external"
AliasUpdatePrefix = "/api/v1/internal/update"
createAccountPath = "/api/v2/account/create"
trackStatusPath = "/api/track"
balancePathPrefix = "/api/account"
trackPath = "/api/v2/account/status"
tokenTransferPrefix = "/api/v2/token/transfer"
voucherHoldingsPathPrefix = "/api/v1/holdings"
voucherTransfersPathPrefix = "/api/v1/transfers/last10"
voucherDataPathPrefix = "/api/v1/token"
SendSMSPrefix = "api/v1/external/upsell"
poolDepositPrefix = "/api/v2/pool/deposit"
poolSwapQoutePrefix = "/api/v2/pool/quote"
poolSwapPrefix = "/api/v2/pool/swap"
topPoolsPrefix = "/api/v1/pool/top"
retrievePoolDetailsPrefix = "/api/v1/pool/reverse"
poolSwappableVouchersPrefix = "/api/v1/pool"
AliasRegistrationPrefix = "/api/v1/internal/register"
AliasResolverPrefix = "/api/v1/resolve"
ExternalSMSPrefix = "/api/v1/external"
AliasUpdatePrefix = "/api/v1/internal/update"
CreditSendPrefix = "/api/v1/credit-send"
CreditSendReverseQuotePrefix = "/api/v1/pool/reverse-quote"
)
var (
@@ -38,25 +40,27 @@ var (
)
var (
CreateAccountURL string
TrackStatusURL string
BalanceURL string
TrackURL string
TokenTransferURL string
VoucherHoldingsURL string
VoucherTransfersURL string
VoucherDataURL string
PoolDepositURL string
PoolSwapQuoteURL string
PoolSwapURL string
TopPoolsURL string
RetrievePoolDetailsURL string
PoolSwappableVouchersURL string
SendSMSURL string
AliasRegistrationURL string
AliasResolverURL string
ExternalSMSURL string
AliasUpdateURL string
CreateAccountURL string
TrackStatusURL string
BalanceURL string
TrackURL string
TokenTransferURL string
VoucherHoldingsURL string
VoucherTransfersURL string
VoucherDataURL string
PoolDepositURL string
PoolSwapQuoteURL string
PoolSwapURL string
TopPoolsURL string
RetrievePoolDetailsURL string
PoolSwappableVouchersURL string
SendSMSURL string
AliasRegistrationURL string
AliasResolverURL string
ExternalSMSURL string
AliasUpdateURL string
CreditSendURL string
CreditSendReverseQuoteURL string
)
func setBase() error {
@@ -105,6 +109,8 @@ func LoadConfig() error {
AliasResolverURL, _ = url.JoinPath(aliasEnsURLBase, AliasResolverPrefix)
ExternalSMSURL, _ = url.JoinPath(externalSMSBase, ExternalSMSPrefix)
AliasUpdateURL, _ = url.JoinPath(aliasEnsURLBase, AliasUpdatePrefix)
CreditSendURL, _ = url.JoinPath(dataURLBase, CreditSendPrefix)
CreditSendReverseQuoteURL, _ = url.JoinPath(dataURLBase, CreditSendReverseQuotePrefix)
return nil
}

View File

@@ -587,10 +587,10 @@ func (das *DevAccountService) FetchVouchers(ctx context.Context, publicKey strin
//TODO: Iterate over the account acc.Balances object
for _, voucher := range das.vouchers {
holdings = append(holdings, dataserviceapi.TokenHoldings{
TokenAddress: voucher.Address,
TokenSymbol: voucher.Symbol,
TokenDecimals: strconv.Itoa(voucher.Decimals),
Balance: strconv.Itoa(int(defaultVoucherBalance)),
TokenAddress: voucher.Address,
TokenSymbol: voucher.Symbol,
TokenDecimals: strconv.Itoa(voucher.Decimals),
Balance: strconv.Itoa(int(defaultVoucherBalance)),
})
}
@@ -861,10 +861,10 @@ func (das *DevAccountService) GetPoolSwappableFromVouchers(ctx context.Context,
}
for _, v := range p.Vouchers {
swapFromList = append(swapFromList, dataserviceapi.TokenHoldings{
TokenAddress: v.Address,
TokenSymbol: v.Symbol,
TokenDecimals: string(defaultDecimals),
Balance: fmt.Sprintf("%f", defaultVoucherBalance),
TokenAddress: v.Address,
TokenSymbol: v.Symbol,
TokenDecimals: string(defaultDecimals),
Balance: fmt.Sprintf("%f", defaultVoucherBalance),
})
}
@@ -907,3 +907,17 @@ func (das *DevAccountService) CheckTokenInPool(ctx context.Context, poolAddress,
CanSwapFrom: true,
}, nil
}
func (das *DevAccountService) GetCreditSendMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.CreditSendLimitsResult, error) {
return &models.CreditSendLimitsResult{
MaxRAT: "45599996",
MaxSAT: "3507692",
}, nil
}
func (das *DevAccountService) GetCreditSendReverseQuote(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount string) (*models.CreditSendReverseQouteResult, error) {
return &models.CreditSendReverseQouteResult{
InputAmount: "3076923",
OutputAmount: "40000000",
}, nil
}

View File

@@ -20,3 +20,13 @@ type MaxLimitResult struct {
type TokenInPoolResult struct {
CanSwapFrom bool `json:"canSwapFrom"`
}
type CreditSendLimitsResult struct {
MaxRAT string `json:"maxRAT"`
MaxSAT string `json:"maxSAT"`
}
type CreditSendReverseQouteResult struct {
InputAmount string `json:"inputAmount"`
OutputAmount string `json:"outputAmount"`
}

View File

@@ -30,4 +30,6 @@ type AccountService interface {
PoolSwap(ctx context.Context, amount, from, fromTokenAddress, poolAddress, toTokenAddress string) (*models.PoolSwapResult, error)
GetSwapFromTokenMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.MaxLimitResult, error)
CheckTokenInPool(ctx context.Context, poolAddress, tokenAddress string) (*models.TokenInPoolResult, error)
GetCreditSendMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.CreditSendLimitsResult, error)
GetCreditSendReverseQuote(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount string) (*models.CreditSendReverseQouteResult, error)
}

View File

@@ -27,11 +27,36 @@ var (
logg = logging.NewVanilla().WithDomain("sarafu-api.devapi")
)
type APIError struct {
Code string
Description string
}
func (e *APIError) Error() string {
if e.Code != "" {
return fmt.Sprintf("[%s] %s", e.Code, e.Description)
}
return e.Description
}
type HTTPAccountService struct {
SS storage.StorageService
UseApi bool
}
// symbolReplacements holds mappings of invalid symbols → valid ones
var symbolReplacements = map[string]string{
"USD₮": "USDT",
}
// sanitizeSymbol replaces known invalid token symbols with normalized ones
func sanitizeSymbol(symbol string) string {
if replacement, ok := symbolReplacements[symbol]; ok {
return replacement
}
return symbol
}
// Parameters:
// - trackingId: A unique identifier for the account.This should be obtained from a previous call to
// CreateAccount or a similar function that returns an AccountResponse. The `trackingId` field in the
@@ -130,6 +155,11 @@ func (as *HTTPAccountService) FetchVouchers(ctx context.Context, publicKey strin
return nil, err
}
// Normalize symbols before returning
for i := range r.Holdings {
r.Holdings[i].TokenSymbol = sanitizeSymbol(r.Holdings[i].TokenSymbol)
}
return r.Holdings, nil
}
@@ -156,6 +186,11 @@ func (as *HTTPAccountService) FetchTransactions(ctx context.Context, publicKey s
return nil, err
}
// Normalize symbols before returning
for i := range r.Transfers {
r.Transfers[i].TokenSymbol = sanitizeSymbol(r.Transfers[i].TokenSymbol)
}
return r.Transfers, nil
}
@@ -177,6 +212,9 @@ func (as *HTTPAccountService) VoucherData(ctx context.Context, address string) (
return nil, err
}
// Normalize symbols before returning
r.TokenDetails.TokenSymbol = sanitizeSymbol(r.TokenDetails.TokenSymbol)
_, err = doRequest(ctx, req, &r)
return &r.TokenDetails, err
}
@@ -367,7 +405,6 @@ func (as *HTTPAccountService) GetPoolSwappableFromVouchers(ctx context.Context,
svc := dev.NewDevAccountService(ctx, as.SS)
return svc.GetPoolSwappableFromVouchers(ctx, poolAddress, publicKey)
}
}
func (as *HTTPAccountService) getPoolSwappableFromVouchers(ctx context.Context, poolAddress, publicKey string) ([]dataserviceapi.TokenHoldings, error) {
@@ -383,6 +420,14 @@ func (as *HTTPAccountService) getPoolSwappableFromVouchers(ctx context.Context,
return nil, err
}
_, err = doRequest(ctx, req, &r)
if err != nil {
return nil, err
}
// Normalize symbols before returning
for i := range r.PoolSwappableVouchers {
r.PoolSwappableVouchers[i].TokenSymbol = sanitizeSymbol(r.PoolSwappableVouchers[i].TokenSymbol)
}
return r.PoolSwappableVouchers, nil
}
@@ -423,6 +468,15 @@ func (as HTTPAccountService) getPoolSwappableVouchers(ctx context.Context, poolA
}
_, err = doRequest(ctx, req, &r)
if err != nil {
return nil, err
}
// Normalize symbols before returning
for i := range r.PoolSwappableVouchers {
r.PoolSwappableVouchers[i].TokenSymbol = sanitizeSymbol(r.PoolSwappableVouchers[i].TokenSymbol)
}
return r.PoolSwappableVouchers, nil
}
@@ -463,10 +517,10 @@ func (as *HTTPAccountService) GetSwapFromTokenMaxLimit(ctx context.Context, pool
}
}
func (as *HTTPAccountService) getSwapFromTokenMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokeAddress, publicKey string) (*models.MaxLimitResult, error) {
func (as *HTTPAccountService) getSwapFromTokenMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.MaxLimitResult, error) {
var r models.MaxLimitResult
ep, err := url.JoinPath(config.PoolSwappableVouchersURL, poolAddress, "limit", fromTokenAddress, toTokeAddress, publicKey)
ep, err := url.JoinPath(config.PoolSwappableVouchersURL, poolAddress, "limit", fromTokenAddress, toTokenAddress, publicKey)
if err != nil {
return nil, err
}
@@ -688,6 +742,46 @@ func (as *HTTPAccountService) SendPINResetSMS(ctx context.Context, admin, phone
return nil
}
// GetCreditSendMaxLimit calls the API to check credit limits and return the maxRAT and maxSAT
func (as *HTTPAccountService) GetCreditSendMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.CreditSendLimitsResult, error) {
var r models.CreditSendLimitsResult
ep, err := url.JoinPath(config.CreditSendURL, poolAddress, fromTokenAddress, toTokenAddress, publicKey)
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", ep, nil)
if err != nil {
return nil, err
}
_, err = doRequest(ctx, req, &r)
if err != nil {
return nil, err
}
return &r, nil
}
// GetCreditSendReverseQuote calls the API to getthe reverse quote for sending RAT amount
func (as *HTTPAccountService) GetCreditSendReverseQuote(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount string) (*models.CreditSendReverseQouteResult, error) {
var r models.CreditSendReverseQouteResult
ep, err := url.JoinPath(config.CreditSendReverseQuoteURL, poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount)
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", ep, nil)
if err != nil {
return nil, err
}
_, err = doRequest(ctx, req, &r)
if err != nil {
return nil, err
}
return &r, nil
}
// TODO: remove eth-custodial api dependency
func doRequest(ctx context.Context, req *http.Request, rcpt any) (*api.OKResponse, error) {
var okResponse api.OKResponse
@@ -720,7 +814,11 @@ func doRequest(ctx context.Context, req *http.Request, rcpt any) (*api.OKRespons
if err := json.Unmarshal(body, &errResponse); err != nil {
return nil, err
}
return nil, errors.New(errResponse.Description)
return nil, &APIError{
Code: errResponse.ErrCode,
Description: errResponse.Description,
}
}
if err := json.Unmarshal(body, &okResponse); err != nil {
@@ -728,7 +826,7 @@ func doRequest(ctx context.Context, req *http.Request, rcpt any) (*api.OKRespons
}
if len(okResponse.Result) == 0 {
return nil, errors.New("Empty api result")
return nil, errors.New("empty api result")
}
v, err := json.Marshal(okResponse.Result)

View File

@@ -120,3 +120,13 @@ func (m MockAccountService) CheckTokenInPool(ctx context.Context, poolAddress, t
args := m.Called(poolAddress, tokenAddress)
return args.Get(0).(*models.TokenInPoolResult), args.Error(1)
}
func (m MockAccountService) GetCreditSendMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.CreditSendLimitsResult, error) {
args := m.Called(poolAddress, fromTokenAddress, toTokenAddress, publicKey)
return args.Get(0).(*models.CreditSendLimitsResult), args.Error(1)
}
func (m MockAccountService) GetCreditSendReverseQuote(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount string) (*models.CreditSendReverseQouteResult, error) {
args := m.Called(poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount)
return args.Get(0).(*models.CreditSendReverseQouteResult), args.Error(1)
}

View File

@@ -8,6 +8,8 @@ import (
dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
)
// This is used in the menu traversal tests
type TestAccountService struct {
}
@@ -34,12 +36,18 @@ func (tas *TestAccountService) TrackAccountStatus(ctx context.Context, publicKey
func (tas *TestAccountService) FetchVouchers(ctx context.Context, publicKey string) ([]dataserviceapi.TokenHoldings, error) {
return []dataserviceapi.TokenHoldings{
dataserviceapi.TokenHoldings{
{
TokenAddress: "0x6CC75A06ac72eB4Db2eE22F781F5D100d8ec03ee",
TokenSymbol: "SRF",
TokenDecimals: "6",
Balance: "2745987",
},
{
TokenAddress: "0x3f195a3F68BF4c6D49748eFa033a00C6634fF311",
TokenSymbol: "USD",
TokenDecimals: "6",
Balance: "4269100",
},
}, nil
}
@@ -116,3 +124,11 @@ func (m TestAccountService) GetSwapFromTokenMaxLimit(ctx context.Context, poolAd
func (m TestAccountService) CheckTokenInPool(ctx context.Context, poolAddress, tokenAddress string) (*models.TokenInPoolResult, error) {
return &models.TokenInPoolResult{}, nil
}
func (m TestAccountService) GetCreditSendMaxLimit(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, publicKey string) (*models.CreditSendLimitsResult, error) {
return &models.CreditSendLimitsResult{}, nil
}
func (m TestAccountService) GetCreditSendReverseQuote(ctx context.Context, poolAddress, fromTokenAddress, toTokenAddress, toTokenAMount string) (*models.CreditSendReverseQouteResult, error) {
return &models.CreditSendReverseQouteResult{}, nil
}