From 02991359cfe7ab87a36bcea4ed806afb85e23613 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Fri, 18 Sep 2026 11:51:37 -0400 Subject: [PATCH] GetBalance RPC now supports fiat currency codes --- go.mod | 2 +- go.sum | 4 +- ocp/currency/exchange_rate.go | 7 ++ ocp/rpc/balance/server.go | 67 +++++++++++- ocp/rpc/balance/server_test.go | 179 ++++++++++++++++++++++++++------- 5 files changed, 216 insertions(+), 43 deletions(-) diff --git a/go.mod b/go.mod index b5604b5..a7899f8 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.59.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 github.com/code-payments/code-vm-indexer v1.2.0 - github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260914145736-82202912574e + github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.3.3 github.com/golang/protobuf v1.5.4 diff --git a/go.sum b/go.sum index c5753a5..0093131 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260914145736-82202912574e h1:gxClbVslDPKJvwgZbY6RMuUIZi3DfR18O07SoCU5W3w= -github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260914145736-82202912574e/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 h1:/cE319d70UiXoor2x9b2faiFTUFlzdgs10D4Fvq7dN4= +github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= diff --git a/ocp/currency/exchange_rate.go b/ocp/currency/exchange_rate.go index 6ea74a6..2c953ce 100644 --- a/ocp/currency/exchange_rate.go +++ b/ocp/currency/exchange_rate.go @@ -20,3 +20,10 @@ func CalculateExchangeRate(mintAccount *common.Account, quarks uint64, nativeAmo exchangeRate, _ := exchangeRateBig.Float64() return exchangeRate } + +// CalculateFiatValueFromCoreMintQuarks calculates the fiat value of a core mint +// amount in quarks given an exchange rate denominated in fiat per core mint unit. +func CalculateFiatValueFromCoreMintQuarks(quarks uint64, exchangeRate float64) float64 { + coreMintUnits := float64(quarks) / float64(common.CoreMintQuarksPerUnit) + return coreMintUnits * exchangeRate +} diff --git a/ocp/rpc/balance/server.go b/ocp/rpc/balance/server.go index eb733e6..242319a 100644 --- a/ocp/rpc/balance/server.go +++ b/ocp/rpc/balance/server.go @@ -2,6 +2,7 @@ package balance import ( "context" + "strings" "github.com/pkg/errors" "go.uber.org/zap" @@ -79,9 +80,17 @@ func (s *server) GetBalances(ctx context.Context, req *balancepb.GetBalancesRequ // mid-request. reserveStateCache := newReserveStateCache() + // Exchange rates are pinned once per request so every balance denominated + // in a currency uses the same rate. Currencies without a rate are dropped. + exchangeRates, err := s.getExchangeRates(ctx, req.CurrencyCodes) + if err != nil { + log.With(zap.Error(err)).Warn("failure getting live exchange rates") + return nil, status.Error(codes.Internal, "") + } + balancesByOwner := make(map[string]*balancepb.OwnerBalance, len(owners)) for _, owner := range owners { - ownerBalance, err := s.valueOwnerBalance(ctx, owner, balanceByOwnerAndTokenAccount[owner.PublicKey().ToBase58()], reserveStateCache) + ownerBalance, err := s.valueOwnerBalance(ctx, owner, balanceByOwnerAndTokenAccount[owner.PublicKey().ToBase58()], reserveStateCache, exchangeRates) if err != nil { log.With(zap.Error(err), zap.String("owner_account", owner.PublicKey().ToBase58())).Warn("failure valuing owner balance") return nil, status.Error(codes.Internal, "") @@ -99,7 +108,7 @@ func (s *server) GetBalances(ctx context.Context, req *balancepb.GetBalancesRequ // valueOwnerBalance values an owner's cached holdings. Owner metadata checks // are intentionally skipped as an optimization, so an owner with no ledger // records has an empty balance. -func (s *server) valueOwnerBalance(ctx context.Context, owner *common.Account, balanceByTokenAccount map[string]*balance.Balance, reserveStateCache reserveStateCache) (*balancepb.OwnerBalance, error) { +func (s *server) valueOwnerBalance(ctx context.Context, owner *common.Account, balanceByTokenAccount map[string]*balance.Balance, reserveStateCache reserveStateCache, exchangeRates exchangeRates) (*balancepb.OwnerBalance, error) { balancesByMint, err := s.calculateCoreMintValueByMint(ctx, balanceByTokenAccount, reserveStateCache) if err != nil { return nil, err @@ -108,12 +117,14 @@ func (s *server) valueOwnerBalance(ctx context.Context, owner *common.Account, b var totalCoreMintValue uint64 for _, mintBalance := range balancesByMint { totalCoreMintValue += mintBalance.CoreMintValue + mintBalance.FiatValuesByCurrency = exchangeRates.toFiatValues(mintBalance.CoreMintValue) } return &balancepb.OwnerBalance{ - Owner: owner.ToProto(), - CoreMintValue: totalCoreMintValue, - BalancesByMint: balancesByMint, + Owner: owner.ToProto(), + CoreMintValue: totalCoreMintValue, + BalancesByMint: balancesByMint, + FiatValuesByCurrency: exchangeRates.toFiatValues(totalCoreMintValue), }, nil } @@ -142,6 +153,52 @@ func newMintFilter(protoMints []*commonpb.SolanaAccountId) ([]*common.Account, e return mints, nil } +// exchangeRates pins the live exchange rate observed for each requested +// currency code so every valuation within a single RPC uses the same rate. +type exchangeRates map[string]float64 + +// getExchangeRates resolves the requested currency codes against the live +// exchange rates. Codes without a live rate are omitted, so the result only +// holds currencies that can be valued. Nothing is fetched when no codes are +// requested. +func (s *server) getExchangeRates(ctx context.Context, currencyCodes []string) (exchangeRates, error) { + if len(currencyCodes) == 0 { + return nil, nil + } + + liveExchangeRates, err := s.mintDataProvider.GetLiveExchangeRates(ctx) + if err != nil { + return nil, err + } + + rates := make(exchangeRates, len(currencyCodes)) + for _, currencyCode := range currencyCodes { + currencyCode = strings.ToLower(currencyCode) + + rate, ok := liveExchangeRates.Rates[currencyCode] + if !ok { + continue + } + rates[currencyCode] = rate + } + return rates, nil +} + +// toFiatValues denominates a core mint value in every pinned currency. A nil +// map is returned when there are no pinned currencies, which leaves the proto +// field unset. +func (r exchangeRates) toFiatValues(coreMintValue uint64) map[string]float64 { + if len(r) == 0 { + return nil + } + + fiatValues := make(map[string]float64, len(r)) + for currencyCode, rate := range r { + fiatValues[currencyCode] = currency_util.CalculateFiatValueFromCoreMintQuarks(coreMintValue, rate) + } + return fiatValues +} + // reserveStateCache pins the live reserve state observed for each launchpad // mint so every valuation within a single RPC uses the same supply. type reserveStateCache map[string]*currency_util.LiveReserveStateData diff --git a/ocp/rpc/balance/server_test.go b/ocp/rpc/balance/server_test.go index ba85bde..1806d79 100644 --- a/ocp/rpc/balance/server_test.go +++ b/ocp/rpc/balance/server_test.go @@ -2,6 +2,7 @@ package balance import ( "context" + "fmt" "testing" "time" @@ -20,6 +21,7 @@ import ( ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/balance" + "github.com/code-payments/ocp-server/ocp/data/currency" exchange_memory "github.com/code-payments/ocp-server/ocp/data/currency/exchange/memory" "github.com/code-payments/ocp-server/ocp/data/currency/holder" holder_memory "github.com/code-payments/ocp-server/ocp/data/currency/holder/memory" @@ -30,6 +32,14 @@ import ( "github.com/code-payments/ocp-server/testutil" ) +// testExchangeRates are the live exchange rates, in fiat per core mint unit, +// seeded into every test environment +var testExchangeRates = map[string]float64{ + "usd": 1.0, + "cad": 1.35, + "eur": 0.92, +} + type testEnv struct { ctx context.Context client balancepb.BalanceClient @@ -56,6 +66,11 @@ func setupWithDustValue(t *testing.T, dustValue uint64) (env testEnv, cleanup fu testutil.SetupRandomSubsidizer(t, env.data) exchangeRateStore := exchange_memory.New() + require.NoError(t, exchangeRateStore.PutExchangeRates(env.ctx, ¤cy.MultiRateRecord{ + Time: time.Now(), + Rates: testExchangeRates, + })) + mintDataProvider := currency_util.NewMintDataProvider(log, env.data, exchangeRateStore, env.reserveStore, env.holderStore, 0, time.Second, time.Second) s := NewBalanceServer(log, env.data, mintDataProvider, dustValue) @@ -106,7 +121,7 @@ func TestGetBalances_HappyPath(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 4) for _, owner := range []*common.Account{ownerAccount1, ownerAccount2, unknownOwnerAccount, giftCardOwnerAccount} { - ownerBalance := assertOwnerBalance(t, resp, owner, 0) + ownerBalance := assertOwnerBalance(t, resp, owner, 0, nil) assert.Empty(t, ownerBalance.BalancesByMint) } @@ -122,7 +137,7 @@ func TestGetBalances_HappyPath(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 4) for _, owner := range []*common.Account{ownerAccount1, ownerAccount2, unknownOwnerAccount, giftCardOwnerAccount} { - ownerBalance := assertOwnerBalance(t, resp, owner, 0) + ownerBalance := assertOwnerBalance(t, resp, owner, 0, nil) assert.Empty(t, ownerBalance.BalancesByMint) } @@ -135,26 +150,80 @@ func TestGetBalances_HappyPath(t *testing.T) { // sell for on the bonding curve. expectedLaunchpadMintValue := estimateLaunchpadSellValue(t, currencycreator.ToQuarks(100)) + // Without currency codes, only core mint values are returned + resp, err = env.client.GetBalances(env.ctx, req) + require.NoError(t, err) + assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) + require.Len(t, resp.BalancesByOwner, 4) + + ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)+expectedLaunchpadMintValue, nil) + require.Len(t, ownerBalance1.BalancesByMint, 2) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), nil) + assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue, nil) + + ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7), nil) + require.Len(t, ownerBalance2.BalancesByMint, 1) + assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7), nil) + + unknownOwnerBalance := assertOwnerBalance(t, resp, unknownOwnerAccount, 0, nil) + assert.Empty(t, unknownOwnerBalance.BalancesByMint) + + giftCardOwnerBalance := assertOwnerBalance(t, resp, giftCardOwnerAccount, common.ToCoreMintQuarks(1), nil) + require.Len(t, giftCardOwnerBalance.BalancesByMint, 1) + assertMintBalance(t, giftCardOwnerBalance.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(1), nil) + + // Every requested currency with a live rate is additionally populated on + // both the owner total and the per-mint breakdown, using the same rate + // throughout. Currencies without a live rate are skipped. + req.CurrencyCodes = []string{"usd", "cad", "jpy"} + expectedCurrencyCodes := []string{"usd", "cad"} + resp, err = env.client.GetBalances(env.ctx, req) require.NoError(t, err) assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 4) - ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)+expectedLaunchpadMintValue) + ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)+expectedLaunchpadMintValue, expectedCurrencyCodes) require.Len(t, ownerBalance1.BalancesByMint, 2) - assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42)) - assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), expectedCurrencyCodes) + assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue, expectedCurrencyCodes) - ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7)) + ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7), expectedCurrencyCodes) require.Len(t, ownerBalance2.BalancesByMint, 1) - assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7)) + assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7), expectedCurrencyCodes) - unknownOwnerBalance := assertOwnerBalance(t, resp, unknownOwnerAccount, 0) + // An owner with no balance still reports a zero fiat value in each + // available currency + unknownOwnerBalance = assertOwnerBalance(t, resp, unknownOwnerAccount, 0, expectedCurrencyCodes) assert.Empty(t, unknownOwnerBalance.BalancesByMint) - giftCardOwnerBalance := assertOwnerBalance(t, resp, giftCardOwnerAccount, common.ToCoreMintQuarks(1)) + giftCardOwnerBalance = assertOwnerBalance(t, resp, giftCardOwnerAccount, common.ToCoreMintQuarks(1), expectedCurrencyCodes) require.Len(t, giftCardOwnerBalance.BalancesByMint, 1) - assertMintBalance(t, giftCardOwnerBalance.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(1)) + assertMintBalance(t, giftCardOwnerBalance.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(1), expectedCurrencyCodes) + + // Requesting only currencies without a live rate results in no fiat values + req.CurrencyCodes = []string{"jpy", "gbp"} + + resp, err = env.client.GetBalances(env.ctx, req) + require.NoError(t, err) + assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) + require.Len(t, resp.BalancesByOwner, 4) + + ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)+expectedLaunchpadMintValue, nil) + require.Len(t, ownerBalance1.BalancesByMint, 2) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), nil) + assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue, nil) + + ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7), nil) + require.Len(t, ownerBalance2.BalancesByMint, 1) + assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7), nil) + + unknownOwnerBalance = assertOwnerBalance(t, resp, unknownOwnerAccount, 0, nil) + assert.Empty(t, unknownOwnerBalance.BalancesByMint) + + giftCardOwnerBalance = assertOwnerBalance(t, resp, giftCardOwnerAccount, common.ToCoreMintQuarks(1), nil) + require.Len(t, giftCardOwnerBalance.BalancesByMint, 1) + assertMintBalance(t, giftCardOwnerBalance.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(1), nil) } func TestGetBalances_MintFilter(t *testing.T) { @@ -191,13 +260,13 @@ func TestGetBalances_MintFilter(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 2) - ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)) + ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42), nil) require.Len(t, ownerBalance1.BalancesByMint, 1) - assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42)) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), nil) - ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7)) + ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7), nil) require.Len(t, ownerBalance2.BalancesByMint, 1) - assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7)) + assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7), nil) // The filter applies to every owner. An owner holding nothing in the // filtered mints is still present with an empty balance. @@ -209,11 +278,11 @@ func TestGetBalances_MintFilter(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 2) - ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, expectedLaunchpadMintValue) + ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, expectedLaunchpadMintValue, nil) require.Len(t, ownerBalance1.BalancesByMint, 1) - assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue) + assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue, nil) - ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, 0) + ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, 0, nil) assert.Empty(t, ownerBalance2.BalancesByMint) // Duplicate mints in the filter don't double count @@ -225,14 +294,14 @@ func TestGetBalances_MintFilter(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 2) - ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)+expectedLaunchpadMintValue) + ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)+expectedLaunchpadMintValue, nil) require.Len(t, ownerBalance1.BalancesByMint, 2) - assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42)) - assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), nil) + assertMintBalance(t, ownerBalance1.BalancesByMint, launchpadMint, expectedLaunchpadMintValue, nil) - ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7)) + ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, common.ToCoreMintQuarks(7), nil) require.Len(t, ownerBalance2.BalancesByMint, 1) - assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7)) + assertMintBalance(t, ownerBalance2.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(7), nil) // A mint no owner holds results in an OK response with no balances resp, err = env.client.GetBalances(env.ctx, &balancepb.GetBalancesRequest{ @@ -243,10 +312,10 @@ func TestGetBalances_MintFilter(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 2) - ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, 0) + ownerBalance1 = assertOwnerBalance(t, resp, ownerAccount1, 0, nil) assert.Empty(t, ownerBalance1.BalancesByMint) - ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, 0) + ownerBalance2 = assertOwnerBalance(t, resp, ownerAccount2, 0, nil) assert.Empty(t, ownerBalance2.BalancesByMint) } @@ -291,12 +360,12 @@ func TestGetBalances_UnmanagedAccountsExcluded(t *testing.T) { require.Len(t, resp.BalancesByOwner, 2) // Only the account still managed by Code contributes to owner 1's balance - ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)) + ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42), nil) require.Len(t, ownerBalance1.BalancesByMint, 1) - assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42)) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), nil) // An owner whose only account is unmanaged has an empty balance - ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, 0) + ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, 0, nil) assert.Empty(t, ownerBalance2.BalancesByMint) } @@ -342,16 +411,16 @@ func TestGetBalances_DustHidden(t *testing.T) { require.Len(t, resp.BalancesByOwner, 3) // Dust is excluded from both the total and the per-mint breakdown - ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42)) + ownerBalance1 := assertOwnerBalance(t, resp, ownerAccount1, common.ToCoreMintQuarks(42), nil) require.Len(t, ownerBalance1.BalancesByMint, 1) - assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42)) + assertMintBalance(t, ownerBalance1.BalancesByMint, common.CoreMintAccount, common.ToCoreMintQuarks(42), nil) - ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, 0) + ownerBalance2 := assertOwnerBalance(t, resp, ownerAccount2, 0, nil) assert.Empty(t, ownerBalance2.BalancesByMint) - ownerBalance3 := assertOwnerBalance(t, resp, ownerAccount3, dustValue) + ownerBalance3 := assertOwnerBalance(t, resp, ownerAccount3, dustValue, nil) require.Len(t, ownerBalance3.BalancesByMint, 1) - assertMintBalance(t, ownerBalance3.BalancesByMint, common.CoreMintAccount, dustValue) + assertMintBalance(t, ownerBalance3.BalancesByMint, common.CoreMintAccount, dustValue, nil) } func TestGetBalances_UnknownOwnerAccount(t *testing.T) { @@ -367,7 +436,7 @@ func TestGetBalances_UnknownOwnerAccount(t *testing.T) { assert.Equal(t, balancepb.GetBalancesResponse_OK, resp.Result) require.Len(t, resp.BalancesByOwner, 1) - unknownOwnerBalance := assertOwnerBalance(t, resp, unknownOwnerAccount, 0) + unknownOwnerBalance := assertOwnerBalance(t, resp, unknownOwnerAccount, 0, nil) assert.Empty(t, unknownOwnerBalance.BalancesByMint) } @@ -400,6 +469,31 @@ func TestGetBalances_InvalidRequest(t *testing.T) { }) testutil.AssertStatusErrorWithCode(t, err, codes.InvalidArgument) + tooManyCurrencyCodes := make([]string, 0, 257) + for i := range cap(tooManyCurrencyCodes) { + tooManyCurrencyCodes = append(tooManyCurrencyCodes, fmt.Sprintf("%c%c%c", 'a'+i%26, 'a'+(i/26)%26, 'a'+(i/676)%26)) + } + _, err = env.client.GetBalances(env.ctx, &balancepb.GetBalancesRequest{ + Owners: []*commonpb.SolanaAccountId{ownerAccount.ToProto()}, + CurrencyCodes: tooManyCurrencyCodes, + }) + testutil.AssertStatusErrorWithCode(t, err, codes.InvalidArgument) + + // Currency codes must be lowercase ISO 4217 alpha codes without duplicates + for _, currencyCodes := range [][]string{ + {"USD"}, + {"us"}, + {"usdxx"}, + {"us1"}, + {"usd", "usd"}, + } { + _, err = env.client.GetBalances(env.ctx, &balancepb.GetBalancesRequest{ + Owners: []*commonpb.SolanaAccountId{ownerAccount.ToProto()}, + CurrencyCodes: currencyCodes, + }) + testutil.AssertStatusErrorWithCode(t, err, codes.InvalidArgument) + } + // Accounts must be well-formed public keys _, err = env.client.GetBalances(env.ctx, &balancepb.GetBalancesRequest{ Owners: []*commonpb.SolanaAccountId{{Value: []byte("invalid")}}, @@ -464,17 +558,32 @@ func estimateLaunchpadSellValue(t *testing.T, quarks uint64) uint64 { return value } -func assertOwnerBalance(t *testing.T, resp *balancepb.GetBalancesResponse, owner *common.Account, expectedCoreMintValue uint64) *balancepb.OwnerBalance { +func assertOwnerBalance(t *testing.T, resp *balancepb.GetBalancesResponse, owner *common.Account, expectedCoreMintValue uint64, expectedCurrencyCodes []string) *balancepb.OwnerBalance { ownerBalance, ok := resp.BalancesByOwner[owner.PublicKey().ToBase58()] require.True(t, ok) assert.Equal(t, owner.PublicKey().ToBytes(), ownerBalance.Owner.Value) assert.EqualValues(t, expectedCoreMintValue, ownerBalance.CoreMintValue) + assertFiatValues(t, ownerBalance.FiatValuesByCurrency, expectedCoreMintValue, expectedCurrencyCodes) return ownerBalance } -func assertMintBalance(t *testing.T, balancesByMint map[string]*balancepb.MintBalance, mint *common.Account, expectedCoreMintValue uint64) { +func assertMintBalance(t *testing.T, balancesByMint map[string]*balancepb.MintBalance, mint *common.Account, expectedCoreMintValue uint64, expectedCurrencyCodes []string) { mintBalance, ok := balancesByMint[mint.PublicKey().ToBase58()] require.True(t, ok) assert.Equal(t, mint.PublicKey().ToBytes(), mintBalance.Mint.Value) assert.EqualValues(t, expectedCoreMintValue, mintBalance.CoreMintValue) + assertFiatValues(t, mintBalance.FiatValuesByCurrency, expectedCoreMintValue, expectedCurrencyCodes) +} + +// assertFiatValues asserts a core mint value is denominated in exactly the +// expected currencies using the seeded live exchange rates +func assertFiatValues(t *testing.T, fiatValuesByCurrency map[string]float64, coreMintValue uint64, expectedCurrencyCodes []string) { + require.Len(t, fiatValuesByCurrency, len(expectedCurrencyCodes)) + for _, currencyCode := range expectedCurrencyCodes { + fiatValue, ok := fiatValuesByCurrency[currencyCode] + require.True(t, ok, currencyCode) + + expected := float64(coreMintValue) / float64(common.CoreMintQuarksPerUnit) * testExchangeRates[currencyCode] + assert.Equal(t, expected, fiatValue, currencyCode) + } }