diff --git a/api/server.go b/api/server.go index a5666aa2..8a485bdf 100644 --- a/api/server.go +++ b/api/server.go @@ -381,6 +381,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/now-playing", app.v1UsersNowPlaying) g.Get("/users/:userId/coins", app.v1UsersCoins) g.Get("/users/:userId/coins/:mint", app.v1UsersCoin) + g.Get("/wallet/:walletId/coins", app.v1WalletCoins) g.Get("/users/:userId/authorized_apps", app.v1UsersAuthorizedApps) g.Get("/users/:userId/authorized-apps", app.v1UsersAuthorizedApps) g.Get("/users/:userId/developer_apps", app.v1UsersDeveloperApps) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 4a0bc310..93acd3ef 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -2209,6 +2209,43 @@ paths: application/json: schema: $ref: '#/components/schemas/user_coin_response' + /wallet/{walletId}/coins: + get: + tags: + - wallet + description: 'Gets a list of the coins held by a wallet address and their balances' + parameters: + - name: walletId + in: path + description: A Solana wallet address + required: true + schema: + type: string + example: "Dez1g5f3h4j5k6l7m8n9o0p1q2r3s4t5u6v7w8x9y0z" + - name: offset + in: query + description: The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + default: 0 + minimum: 0 + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + operationId: Get Wallet Coins + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/user_coins_response' /users/{id}/collectibles: get: tags: diff --git a/api/v1_wallet_coins.go b/api/v1_wallet_coins.go new file mode 100644 index 00000000..d9265aa6 --- /dev/null +++ b/api/v1_wallet_coins.go @@ -0,0 +1,78 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetWalletCoinsQueryParams struct { + Limit int `query:"limit" default:"50" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0"` +} + +type WalletCoinsRouteParams struct { + WalletId string `params:"walletId"` +} + +func (app *ApiServer) v1WalletCoins(c *fiber.Ctx) error { + params := WalletCoinsRouteParams{} + if err := c.ParamsParser(¶ms); err != nil { + return err + } + + queryParams := GetWalletCoinsQueryParams{} + if err := app.ParseAndValidateQueryParams(c, &queryParams); err != nil { + return err + } + + sql := ` + WITH balances_by_mint AS ( + SELECT + balances.mint, + SUM(balances.balance) AS balance + FROM sol_token_account_balances AS balances + WHERE balances.owner = @wallet_address + GROUP BY balances.mint + ) + SELECT + artist_coins.ticker, + artist_coins.mint, + artist_coins.decimals, + artist_coins.has_discord, + artist_coins.user_id AS owner_id, + COALESCE(balances_by_mint.balance, 0) AS balance, + (COALESCE(balances_by_mint.balance, 0) * COALESCE(stats.price, pools.price_usd)) / POWER(10, artist_coins.decimals) AS balance_usd + FROM artist_coins + LEFT JOIN balances_by_mint ON balances_by_mint.mint = artist_coins.mint + LEFT JOIN artist_coin_stats stats ON stats.mint = artist_coins.mint + LEFT JOIN artist_coin_pools pools ON pools.base_mint = artist_coins.mint + WHERE balance > 0 -- Show coins with positive balance + ORDER BY + -- Prioritize AUDIO + artist_coins.ticker = 'AUDIO' DESC, + -- Then by number of coins (balance) + balance DESC, + -- Finally by mint for consistent ordering + artist_coins.mint ASC + LIMIT @limit + OFFSET @offset + ;` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "wallet_address": params.WalletId, + "limit": queryParams.Limit, + "offset": queryParams.Offset, + }) + if err != nil { + return err + } + + userCoins, err := pgx.CollectRows(rows, pgx.RowToStructByName[UserCoin]) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "data": userCoins, + }) +} diff --git a/api/v1_wallet_coins_test.go b/api/v1_wallet_coins_test.go new file mode 100644 index 00000000..77db9e9c --- /dev/null +++ b/api/v1_wallet_coins_test.go @@ -0,0 +1,450 @@ +package api + +import ( + "testing" + "time" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestWalletCoins(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "ticker": "AUDIO", + "decimals": 8, + "user_id": 1, + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "created_at": time.Now().Add(-time.Second), + }, + { + "ticker": "USDC", + "decimals": 6, + "user_id": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "created_at": time.Now(), + }, + { + "ticker": "MYCOIN", + "decimals": 9, + "user_id": 3, + "mint": "mycoin_mint_address_123", + "created_at": time.Now(), + }, + }, + "artist_coin_stats": { + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "price": 10.0, + "updated_at": time.Now(), + }, + { + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "price": 1.0, + "updated_at": time.Now(), + }, + { + "mint": "mycoin_mint_address_123", + "price": 0.5, + "updated_at": time.Now(), + }, + }, + "sol_token_account_balances": { + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account1", + "owner": "test_wallet_address", + "balance": 1000000000, // 10 AUDIO + }, + { + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "account": "account2", + "owner": "test_wallet_address", + "balance": 5000000, // 5 USDC + }, + { + "mint": "mycoin_mint_address_123", + "account": "account3", + "owner": "test_wallet_address", + "balance": 20000000000, // 20 MYCOIN + }, + // Different wallet - should not be included + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account4", + "owner": "different_wallet", + "balance": 5000000000, + }, + }, + } + + database.Seed(app.writePool, fixtures) + + { + status, body := testGet(t, app, "/v1/wallet/test_wallet_address/coins") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 3, + "data.0.ticker": "AUDIO", // AUDIO comes first + "data.0.mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "data.0.decimals": 8, + "data.0.owner_id": trashid.MustEncodeHashID(1), + "data.0.balance": 1000000000, // 10 AUDIO + "data.0.balance_usd": 100.0, // $10 per AUDIO + "data.1.ticker": "MYCOIN", // Higher balance than USDC + "data.1.mint": "mycoin_mint_address_123", + "data.1.decimals": 9, + "data.1.owner_id": trashid.MustEncodeHashID(3), + "data.1.balance": 20000000000, // 20 MYCOIN + "data.1.balance_usd": 10.0, // $0.50 per MYCOIN + "data.2.ticker": "USDC", + "data.2.mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "data.2.decimals": 6, + "data.2.owner_id": trashid.MustEncodeHashID(2), + "data.2.balance": 5000000, // 5 USDC + "data.2.balance_usd": 5.0, + }) + } +} + +func TestWalletCoinsNoBalance(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "ticker": "AUDIO", + "decimals": 8, + "user_id": 1, + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "created_at": time.Now(), + }, + }, + "sol_token_account_balances": { + // Different wallet has balance, not our test wallet + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account1", + "owner": "different_wallet", + "balance": 1000000000, + }, + }, + } + + database.Seed(app.writePool, fixtures) + + { + status, body := testGet(t, app, "/v1/wallet/test_wallet_with_no_balance/coins") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 0, // No coins with balance for this wallet + }) + } +} + +func TestWalletCoinsOrdering(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "ticker": "AUDIO", + "decimals": 8, + "user_id": 1, + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "created_at": time.Now().Add(-time.Second), + }, + { + "ticker": "HIGHVALUE", + "decimals": 9, + "user_id": 2, + "mint": "highvalue_mint_address_123", + "created_at": time.Now(), + }, + { + "ticker": "LOWVALUE", + "decimals": 9, + "user_id": 3, + "mint": "lowvalue_mint_address_123", + "created_at": time.Now(), + }, + }, + "artist_coin_stats": { + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "price": 1.0, + "updated_at": time.Now(), + }, + { + "mint": "highvalue_mint_address_123", + "price": 10.0, + "updated_at": time.Now(), + }, + { + "mint": "lowvalue_mint_address_123", + "price": 0.1, + "updated_at": time.Now(), + }, + }, + "sol_token_account_balances": { + // Small AUDIO balance (worth $5) + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account1", + "owner": "test_wallet", + "balance": 500000000, // 5 AUDIO + }, + // Large HIGHVALUE balance (worth $1000) + { + "mint": "highvalue_mint_address_123", + "account": "account2", + "owner": "test_wallet", + "balance": 100000000000, // 100 HIGHVALUE + }, + // Medium LOWVALUE balance (worth $10) + { + "mint": "lowvalue_mint_address_123", + "account": "account3", + "owner": "test_wallet", + "balance": 100000000000, // 100 LOWVALUE + }, + }, + } + + database.Seed(app.writePool, fixtures) + + status, body := testGet(t, app, "/v1/wallet/test_wallet/coins") + assert.Equal(t, 200, status) + + // AUDIO should come first regardless of balance, then by balance descending + jsonAssert(t, body, map[string]any{ + "data.#": 3, + "data.0.ticker": "AUDIO", // AUDIO prioritized + "data.0.mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "data.0.balance": 500000000, // 5 AUDIO + "data.0.balance_usd": 5.0, + "data.1.ticker": "HIGHVALUE", // Highest balance after AUDIO + "data.1.mint": "highvalue_mint_address_123", + "data.1.balance": 100000000000, // 100 HIGHVALUE + "data.1.balance_usd": 1000.0, + "data.2.ticker": "LOWVALUE", // Lower balance + "data.2.mint": "lowvalue_mint_address_123", + "data.2.balance": 100000000000, // 100 LOWVALUE + "data.2.balance_usd": 10.0, + }) +} + +func TestWalletCoinsPagination(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "ticker": "COIN1", + "decimals": 9, + "user_id": 1, + "mint": "mint1", + "created_at": time.Now(), + }, + { + "ticker": "COIN2", + "decimals": 9, + "user_id": 2, + "mint": "mint2", + "created_at": time.Now(), + }, + { + "ticker": "COIN3", + "decimals": 9, + "user_id": 3, + "mint": "mint3", + "created_at": time.Now(), + }, + }, + "artist_coin_stats": { + { + "mint": "mint1", + "price": 1.0, + "updated_at": time.Now(), + }, + { + "mint": "mint2", + "price": 1.0, + "updated_at": time.Now(), + }, + { + "mint": "mint3", + "price": 1.0, + "updated_at": time.Now(), + }, + }, + "sol_token_account_balances": { + { + "mint": "mint1", + "account": "account1", + "owner": "test_wallet", + "balance": 3000000000, // Highest + }, + { + "mint": "mint2", + "account": "account2", + "owner": "test_wallet", + "balance": 2000000000, // Middle + }, + { + "mint": "mint3", + "account": "account3", + "owner": "test_wallet", + "balance": 1000000000, // Lowest + }, + }, + } + + database.Seed(app.writePool, fixtures) + + // Test limit + { + status, body := testGet(t, app, "/v1/wallet/test_wallet/coins?limit=2") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.ticker": "COIN1", // Highest balance + "data.1.ticker": "COIN2", // Second highest + }) + } + + // Test offset + { + status, body := testGet(t, app, "/v1/wallet/test_wallet/coins?limit=2&offset=1") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.ticker": "COIN2", // Second highest (skipped first) + "data.1.ticker": "COIN3", // Third highest + }) + } + + // Test offset beyond results + { + status, body := testGet(t, app, "/v1/wallet/test_wallet/coins?offset=10") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 0, // No results + }) + } +} + +func TestWalletCoinsWithPoolPricing(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "ticker": "POOLCOIN", + "decimals": 9, + "user_id": 1, + "mint": "poolcoin_mint", + "created_at": time.Now(), + }, + }, + "artist_coin_pools": { + // Using pool price instead of stats price + { + "address": "pool_address_123", + "base_mint": "poolcoin_mint", + "price_usd": 2.5, + }, + }, + "sol_token_account_balances": { + { + "mint": "poolcoin_mint", + "account": "account1", + "owner": "test_wallet", + "balance": 10000000000, // 10 POOLCOIN + }, + }, + } + + database.Seed(app.writePool, fixtures) + + { + status, body := testGet(t, app, "/v1/wallet/test_wallet/coins") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.ticker": "POOLCOIN", + "data.0.mint": "poolcoin_mint", + "data.0.balance": 10000000000, // 10 POOLCOIN + "data.0.balance_usd": 25.0, // 10 * $2.50 + }) + } +} + +func TestWalletCoinsMultipleAccounts(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "ticker": "AUDIO", + "decimals": 8, + "user_id": 1, + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "created_at": time.Now(), + }, + }, + "artist_coin_stats": { + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "price": 1.0, + "updated_at": time.Now(), + }, + }, + "sol_token_account_balances": { + // Same wallet, multiple accounts with same mint + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account1", + "owner": "test_wallet", + "balance": 5000000000, // 50 AUDIO + }, + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account2", + "owner": "test_wallet", + "balance": 3000000000, // 30 AUDIO + }, + { + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "account": "account3", + "owner": "test_wallet", + "balance": 2000000000, // 20 AUDIO + }, + }, + } + + database.Seed(app.writePool, fixtures) + + { + status, body := testGet(t, app, "/v1/wallet/test_wallet/coins") + assert.Equal(t, 200, status) + + // Balance should be sum of all accounts + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.ticker": "AUDIO", + "data.0.balance": 10000000000, // 50 + 30 + 20 = 100 AUDIO + "data.0.balance_usd": 100.0, // 100 * $1 + }) + } +} diff --git a/database/seed.go b/database/seed.go index 1368a6e0..0391061c 100644 --- a/database/seed.go +++ b/database/seed.go @@ -460,6 +460,12 @@ var ( "created_at": time.Now(), "updated_at": time.Now(), }, + "artist_coin_pools": { + "address": nil, + "base_mint": nil, + "created_at": time.Now(), + "updated_at": time.Now(), + }, "sol_token_account_balances": { "account": nil, "owner": "owner-acc",