Skip to content
Merged
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
59 changes: 49 additions & 10 deletions internal/services/account_balances_mapping.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// ABOUTME: Maps wallet-backend SDK balance types into freighter snake_case REST response types.
// ABOUTME: mapBalance is a type switch over the 5 SDK balance variants, computing per-variant `available`.
// ABOUTME: mapBalance is a type switch over the 5 SDK balance variants, deriving the v1-format key/token per variant.
package services

import (
Expand All @@ -11,19 +11,25 @@ import (
)

// mapBalance dispatches an SDK balance to the matching freighter variant,
// building the shared BalanceBase (balance/token_id/token_type from the
// interface getters plus a per-variant `available`) and returning the embedding
// per-variant response type. The SDK's UnmarshalBalance rejects unknown __typename, so the
// default branch is unreachable in practice; it degrades a hypothetical future
// variant to the base fields rather than panicking or dropping the balance.
// building the shared BalanceBase (total/token_id/token_type from the
// interface getters plus per-variant `available`, `key`, and `token`) and
// returning the embedding per-variant response type. Key and Token follow the
// v1 balance-map conventions so clients index balances without re-deriving
// them (issue stellar/freighter-backend#319). The SDK's UnmarshalBalance
// rejects unknown __typename, so the default branch is unreachable in
// practice; it degrades a hypothetical future variant to the base fields
// (key = token_id, no token) rather than panicking or dropping the balance.
func mapBalance(b wbtypes.Balance) types.Balance {
base := types.BalanceBase{
Balance: b.GetBalance(),
Key: b.GetTokenID(),
Total: b.GetBalance(),
TokenID: b.GetTokenID(),
TokenType: string(b.GetTokenType()),
}
switch bal := b.(type) {
case *wbtypes.NativeBalance:
base.Key = "native"
base.Token = &types.Token{Type: "native", Code: "XLM"}
// MinimumBalance is the pure base reserve; selling liabilities also lock XLM,
// so both are subtracted (mirrors stellar-core getAvailableBalance).
base.Available = spendable(bal.BalanceValue, bal.MinimumBalance, bal.SellingLiabilities)
Expand All @@ -35,6 +41,11 @@ func mapBalance(b wbtypes.Balance) types.Balance {
LastModifiedLedger: bal.LastModifiedLedger,
}
case *wbtypes.TrustlineBalance:
code, issuer := deref(bal.Code), deref(bal.Issuer)
base.Key = code + ":" + issuer
// The SDK carries the trustline's asset type verbatim (e.g.
// credit_alphanum4), so no derivation is needed here.
base.Token = &types.Token{Type: bal.Type, Code: code, Issuer: &types.TokenIssuer{Key: issuer}}
base.Available = spendable(bal.BalanceValue, bal.SellingLiabilities)
return &types.TrustlineBalance{
BalanceBase: base,
Expand All @@ -49,9 +60,11 @@ func mapBalance(b wbtypes.Balance) types.Balance {
IsAuthorizedToMaintainLiabilities: bal.IsAuthorizedToMaintainLiabilities,
}
case *wbtypes.SACBalance:
base.Key = bal.Code + ":" + bal.Issuer
base.Token = &types.Token{Type: classicAssetType(bal.Code), Code: bal.Code, Issuer: &types.TokenIssuer{Key: bal.Issuer}}
// SAC balances are raw i128 amounts with no liabilities, so the full
// balance is spendable.
base.Available = base.Balance
base.Available = base.Total
return &types.SACBalance{
BalanceBase: base,
Code: bal.Code,
Expand All @@ -61,9 +74,14 @@ func mapBalance(b wbtypes.Balance) types.Balance {
IsClawbackEnabled: bal.IsClawbackEnabled,
}
case *wbtypes.SEP41Balance:
symbol := deref(bal.Symbol)
base.Key = symbol + ":" + bal.TokenID
// v1 parity: a pure SEP-41 token has no classic asset type, so Token
// carries only the symbol and the contract id as the issuer key.
base.Token = &types.Token{Code: symbol, Issuer: &types.TokenIssuer{Key: bal.TokenID}}
// SEP-41 balances are raw i128 amounts with no liabilities, so the full
// balance is spendable.
base.Available = base.Balance
base.Available = base.Total
return &types.SEP41Balance{
BalanceBase: base,
Symbol: bal.Symbol,
Expand All @@ -72,7 +90,9 @@ func mapBalance(b wbtypes.Balance) types.Balance {
LastModifiedLedger: bal.LastModifiedLedger,
}
case *wbtypes.LiquidityPoolBalance:
base.Available = base.Balance
// v1 parity: LP share entries are keyed "<poolId>:lp" and carry no token.
base.Key = bal.LiquidityPoolID + ":lp"
base.Available = base.Total
reserves := make([]types.LiquidityPoolReserve, 0, len(bal.Reserves))
for _, r := range bal.Reserves {
reserves = append(reserves, types.LiquidityPoolReserve{Asset: r.Asset, Amount: r.Amount})
Expand All @@ -88,6 +108,25 @@ func mapBalance(b wbtypes.Balance) types.Balance {
}
}

// classicAssetType derives the classic asset type from the code length for
// variants where the SDK does not carry it (SAC), mirroring v1 and the
// clients' fallback: codes longer than 4 characters are credit_alphanum12.
func classicAssetType(code string) string {
if len(code) > 4 {
return "credit_alphanum12"
}
return "credit_alphanum4"
}

// deref returns the pointed-to string, or "" for nil — the SDK models
// optional codes/issuers/symbols as *string.
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}

// spendable returns balance minus the sum of the reserved amounts, clamped at
// zero, as a Stellar amount string. All inputs are pre-formatted Stellar amount
// strings (7 decimal places); if any fails to parse it falls back to the raw
Expand Down
112 changes: 105 additions & 7 deletions internal/services/account_balances_mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ func TestMapBalance_AllVariants(t *testing.T) {
LastModifiedLedger: 100, NumSubentries: 3,
},
&types.NativeBalance{
BalanceBase: types.BalanceBase{Balance: "100.0000000", Available: "98.5000000", TokenID: "native", TokenType: "NATIVE"},
BalanceBase: types.BalanceBase{
Key: "native",
Token: &types.Token{Type: "native", Code: "XLM"},
Total: "100.0000000", Available: "98.5000000", TokenID: "native", TokenType: "NATIVE",
},
MinimumBalance: "1.3000000",
BuyingLiabilities: "0.5000000",
SellingLiabilities: "0.2000000",
Expand All @@ -46,7 +50,11 @@ func TestMapBalance_AllVariants(t *testing.T) {
LastModifiedLedger: 42, NumSubentries: 0,
},
&types.NativeBalance{
BalanceBase: types.BalanceBase{Balance: "1.0000000", Available: "0.0000000", TokenID: "native", TokenType: "NATIVE"},
BalanceBase: types.BalanceBase{
Key: "native",
Token: &types.Token{Type: "native", Code: "XLM"},
Total: "1.0000000", Available: "0.0000000", TokenID: "native", TokenType: "NATIVE",
},
MinimumBalance: "0.8000000",
BuyingLiabilities: "0.0000000",
SellingLiabilities: "0.5000000",
Expand All @@ -63,7 +71,14 @@ func TestMapBalance_AllVariants(t *testing.T) {
LastModifiedLedger: 200, IsAuthorized: true, IsAuthorizedToMaintainLiabilities: true,
},
&types.TrustlineBalance{
BalanceBase: types.BalanceBase{Balance: "50.0000000", Available: "40.0000000", TokenID: "USDC-GA5Z", TokenType: "CLASSIC"},
BalanceBase: types.BalanceBase{
Key: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Token: &types.Token{
Type: "credit_alphanum4", Code: "USDC",
Issuer: &types.TokenIssuer{Key: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"},
},
Total: "50.0000000", Available: "40.0000000", TokenID: "USDC-GA5Z", TokenType: "CLASSIC",
},
Code: strPtr("USDC"),
Issuer: strPtr("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"),
Type: "credit_alphanum4",
Expand All @@ -85,7 +100,14 @@ func TestMapBalance_AllVariants(t *testing.T) {
LastModifiedLedger: 201, IsAuthorized: true, IsAuthorizedToMaintainLiabilities: false,
},
&types.TrustlineBalance{
BalanceBase: types.BalanceBase{Balance: "5.0000000", Available: "0.0000000", TokenID: "USDC-GA5Z", TokenType: "CLASSIC"},
BalanceBase: types.BalanceBase{
Key: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Token: &types.Token{
Type: "credit_alphanum4", Code: "USDC",
Issuer: &types.TokenIssuer{Key: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"},
},
Total: "5.0000000", Available: "0.0000000", TokenID: "USDC-GA5Z", TokenType: "CLASSIC",
},
Code: strPtr("USDC"),
Issuer: strPtr("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"),
Type: "credit_alphanum4",
Expand All @@ -105,7 +127,14 @@ func TestMapBalance_AllVariants(t *testing.T) {
Decimals: 7, IsAuthorized: true, IsClawbackEnabled: false,
},
&types.SACBalance{
BalanceBase: types.BalanceBase{Balance: "5000000000", Available: "5000000000", TokenID: "USDC-GA5Z:contract", TokenType: "SAC"},
BalanceBase: types.BalanceBase{
Key: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Token: &types.Token{
Type: "credit_alphanum4", Code: "USDC",
Issuer: &types.TokenIssuer{Key: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"},
},
Total: "5000000000", Available: "5000000000", TokenID: "USDC-GA5Z:contract", TokenType: "SAC",
},
Code: "USDC",
Issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Decimals: 7,
Expand All @@ -120,7 +149,11 @@ func TestMapBalance_AllVariants(t *testing.T) {
Symbol: strPtr("ABC"), Name: strPtr("Alphabet"), Decimals: 6, LastModifiedLedger: 300,
},
&types.SEP41Balance{
BalanceBase: types.BalanceBase{Balance: "123456789", Available: "123456789", TokenID: "CBSEP41CONTRACT", TokenType: "SEP41"},
BalanceBase: types.BalanceBase{
Key: "ABC:CBSEP41CONTRACT",
Token: &types.Token{Code: "ABC", Issuer: &types.TokenIssuer{Key: "CBSEP41CONTRACT"}},
Total: "123456789", Available: "123456789", TokenID: "CBSEP41CONTRACT", TokenType: "SEP41",
},
Symbol: strPtr("ABC"),
Name: strPtr("Alphabet"),
Decimals: 6,
Expand All @@ -139,7 +172,10 @@ func TestMapBalance_AllVariants(t *testing.T) {
LastModifiedLedger: 400,
},
&types.LiquidityPoolBalance{
BalanceBase: types.BalanceBase{Balance: "10.0000000", Available: "10.0000000", TokenID: "pool-1", TokenType: "LIQUIDITY_POOL"},
BalanceBase: types.BalanceBase{
Key: "pool-1:lp",
Total: "10.0000000", Available: "10.0000000", TokenID: "pool-1", TokenType: "LIQUIDITY_POOL",
},
LiquidityPoolID: "pool-1",
Reserves: []types.LiquidityPoolReserve{
{Asset: "native", Amount: "100.0000000"},
Expand All @@ -148,6 +184,68 @@ func TestMapBalance_AllVariants(t *testing.T) {
LastModifiedLedger: 400,
},
},
{
// SAC with a >4-char code derives credit_alphanum12 (no Type on the SDK
// SAC variant, so the mapper derives it from code length like v1 does).
"sac_alphanum12", &wbtypes.SACBalance{
BalanceValue: "1", TokenID: "TOKEN-GA5Z:contract", TokenType: wbtypes.TokenTypeSAC,
Code: "TOKEN", Issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Decimals: 7, IsAuthorized: true, IsClawbackEnabled: false,
},
&types.SACBalance{
BalanceBase: types.BalanceBase{
Key: "TOKEN:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Token: &types.Token{
Type: "credit_alphanum12", Code: "TOKEN",
Issuer: &types.TokenIssuer{Key: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"},
},
Total: "1", Available: "1", TokenID: "TOKEN-GA5Z:contract", TokenType: "SAC",
},
Code: "TOKEN",
Issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
Decimals: 7,
IsAuthorized: true,
},
},
{
// Nil code/issuer (the SDK models them as optional): key degrades to
// ":" with empty token fields rather than panicking.
"trustline_nil_code_issuer", &wbtypes.TrustlineBalance{
BalanceValue: "1.0000000", TokenID: "tl", TokenType: wbtypes.TokenTypeClassic,
Type: "credit_alphanum4", Limit: "10.0000000",
BuyingLiabilities: "0.0000000", SellingLiabilities: "0.0000000",
LastModifiedLedger: 500,
},
&types.TrustlineBalance{
BalanceBase: types.BalanceBase{
Key: ":",
Token: &types.Token{Type: "credit_alphanum4", Code: "", Issuer: &types.TokenIssuer{Key: ""}},
Total: "1.0000000", Available: "1.0000000", TokenID: "tl", TokenType: "CLASSIC",
},
Type: "credit_alphanum4",
Limit: "10.0000000",
BuyingLiabilities: "0.0000000",
SellingLiabilities: "0.0000000",
LastModifiedLedger: 500,
},
},
{
// Nil symbol: key degrades to ":CONTRACT" with an empty token code,
// mirroring the client-side fallback this mapping replaces.
"sep41_nil_symbol", &wbtypes.SEP41Balance{
BalanceValue: "42", TokenID: "CBSEP41CONTRACT", TokenType: wbtypes.TokenTypeSEP41,
Decimals: 6, LastModifiedLedger: 600,
},
&types.SEP41Balance{
BalanceBase: types.BalanceBase{
Key: ":CBSEP41CONTRACT",
Token: &types.Token{Code: "", Issuer: &types.TokenIssuer{Key: "CBSEP41CONTRACT"}},
Total: "42", Available: "42", TokenID: "CBSEP41CONTRACT", TokenType: "SEP41",
},
Decimals: 6,
LastModifiedLedger: 600,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
33 changes: 26 additions & 7 deletions internal/types/account_balances.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
// ABOUTME: snake_case REST response types for the account balances endpoint.
// ABOUTME: Mirrors the wallet-backend SDK balance variants with consistent snake_case keys; `available` is the spendable amount.
// ABOUTME: Mirrors the wallet-backend SDK balance variants with v1-aligned key/token/total fields; `available` is the spendable amount.
package types

// Balance is a sealed interface implemented by every balance variant. The
// concrete type is determined by the BalanceBase.TokenType discriminator
// (token_type is 1:1 with the variants), so clients switch on "token_type".
type Balance interface{ isBalance() }

// BalanceBase holds the fields common to every balance variant. Balance is the
// on-ledger amount and Available is the spendable portion (balance minus the
// reserved amount for native/classic; equal to balance for contract tokens and
// pool shares). Both are Stellar amount strings so JavaScript clients never
// lose precision.
// TokenIssuer identifies the entity behind a token: a classic asset's issuing
// account or a Soroban token's contract id.
type TokenIssuer struct {
Key string `json:"key"`
}

// Token is the v1-pattern token identity object. Type is omitted for SEP-41
// tokens and Issuer is omitted for the native asset, matching the v1 shapes
// clients already consume.
type Token struct {
Type string `json:"type,omitempty"`
Code string `json:"code"`
Issuer *TokenIssuer `json:"issuer,omitempty"`
}

// BalanceBase holds the fields common to every balance variant. Key is the
// v1-format balance-map key (native / "CODE:ISSUER" / "SYMBOL:CONTRACT_ID" /
// "POOLID:lp") and Token is the v1 token identity (nil for liquidity-pool
// shares, which carry no token in v1). Total is the raw on-ledger amount and
// Available is the spendable portion (total minus the reserved amount for
// native/classic; equal to total for contract tokens and pool shares). Both
// are Stellar amount strings so JavaScript clients never lose precision.
Comment on lines +28 to +31
type BalanceBase struct {
Balance string `json:"balance"`
Key string `json:"key"`
Token *Token `json:"token,omitempty"`
Total string `json:"total"`
Available string `json:"available"`
TokenID string `json:"token_id"`
TokenType string `json:"token_type"`
Expand Down
Loading
Loading