Skip to content
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
1 change: 1 addition & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ func NewApiServer(config config.Config) *ApiServer {
g.Get("/coins/volume-leaders", app.v1CoinsVolumeLeaders)
g.Get("/coins/:mint", app.v1Coin)
g.Get("/coins/ticker/:ticker", app.v1CoinByTicker)
g.Get("/coins/:mint/metadata", app.v1CoinMetadata)
g.Get("/coins/:mint/insights", app.v1CoinInsights)
g.Get("/coins/:mint/members", app.v1CoinsMembers)
g.Get("/coins/:mint/members/count", app.v1CoinMembersCount)
Expand Down
85 changes: 85 additions & 0 deletions api/v1_coin_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package api

import (
"errors"
"fmt"
"strings"

"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)

// CoinMetadata is the Metaplex Token Metadata off-chain JSON standard.
// This is the document the DBC pool's on-chain `uri` points at for coins
// launched after the Irys -> Audius migration, so the field names here are
// dictated by the standard rather than by our own API conventions and the
// response is served unwrapped (no `data` envelope).
type CoinMetadata struct {
Name string `json:"name"`
Symbol string `json:"symbol"`
Description string `json:"description"`
Image string `json:"image"`
ExternalUrl string `json:"external_url"`
Attributes []any `json:"attributes"`
}

func (app *ApiServer) v1CoinMetadata(c *fiber.Ctx) error {
mint := c.Params("mint")
if mint == "" {
return fiber.NewError(fiber.StatusBadRequest, "mint parameter is required")
}

var name, ticker string
var description, logoUri, handle *string
err := app.pool.QueryRow(c.Context(), `
SELECT artist_coins.name,
artist_coins.ticker,
artist_coins.description,
artist_coins.logo_uri,
users.handle
FROM artist_coins
LEFT JOIN users ON users.user_id = artist_coins.user_id
WHERE artist_coins.mint = $1
`, mint).Scan(&name, &ticker, &description, &logoUri, &handle)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(fiber.StatusNotFound, "Coin not found")
}
return err
}

appUrl := app.audiusAppUrl
if appUrl == "" {
appUrl = "https://audius.co"
}

metadata := CoinMetadata{
Name: name,
Symbol: ticker,
ExternalUrl: appUrl + "/coins/" + ticker,
Attributes: []any{},
}
if description != nil && *description != "" {
metadata.Description = *description
} else if handle != nil {
// The launchpad never persists a description - the Fan Club page builds
// this same sentence client-side, and storing it would make the page cite
// itself. Regenerate it here so wallets and explorers still get one.
metadata.Description = defaultCoinDescription(*handle, ticker, appUrl)
}
if logoUri != nil {
metadata.Image = *logoUri
}

return c.JSON(metadata)
}

// Kept in step with LAUNCHPAD_COIN_DESCRIPTION in the web client
// (packages/web/src/pages/fan-clubs-launchpad-page/constants.ts).
func defaultCoinDescription(handle string, ticker string, appUrl string) string {
upper := strings.ToUpper(ticker)
return fmt.Sprintf(
"$%s is an artist coin created by @%s on Audius. Learn more at %s/coins/%s",
upper, handle, appUrl, upper,
)
}
90 changes: 90 additions & 0 deletions api/v1_coin_metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package api

import (
"encoding/json"
"testing"
"time"

"api.audius.co/database"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestV1CoinMetadata(t *testing.T) {
app := emptyTestApp(t)

fixtures := database.FixtureMap{
"users": {
{"user_id": 1, "handle": "bearartist", "is_current": true},
{"user_id": 2, "handle": "bareartist", "is_current": true},
},
"artist_coins": {
{
"ticker": "BEAR",
"decimals": 9,
"user_id": 1,
"mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM",
"name": "Bear Coin",
"description": "A coin for bears",
"logo_uri": "https://creatornode.audius.co/content/bear-logo-cid",
"created_at": time.Now().Add(-time.Second),
},
{
"ticker": "BARE",
"decimals": 9,
"user_id": 2,
"mint": "3XyzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM",
"name": "Bare Coin",
"created_at": time.Now().Add(-time.Second),
},
},
}

database.Seed(app.pool.Replicas[0], fixtures)

// Serves the Metaplex standard document, unwrapped (no "data" envelope)
{
status, body := testGet(t, app, "/v1/coins/9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM/metadata")
assert.Equal(t, 200, status)

var metadata CoinMetadata
require.NoError(t, json.Unmarshal(body, &metadata))
assert.Equal(t, "Bear Coin", metadata.Name)
assert.Equal(t, "BEAR", metadata.Symbol)
assert.Equal(t, "A coin for bears", metadata.Description)
assert.Equal(t, "https://creatornode.audius.co/content/bear-logo-cid", metadata.Image)
assert.Equal(t, "http://localhost:1323/coins/BEAR", metadata.ExternalUrl)
assert.NotNil(t, metadata.Attributes)

// The on-chain uri points here, so wallets must not see our envelope
var raw map[string]any
require.NoError(t, json.Unmarshal(body, &raw))
assert.NotContains(t, raw, "data")
}

// A coin with no stored description gets the launchpad sentence rebuilt,
// matching LAUNCHPAD_COIN_DESCRIPTION in the web client. Coins launched
// through the launchpad never persist one.
{
status, body := testGet(t, app, "/v1/coins/3XyzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM/metadata")
assert.Equal(t, 200, status)

var metadata CoinMetadata
require.NoError(t, json.Unmarshal(body, &metadata))
assert.Equal(t,
"$BARE is an artist coin created by @bareartist on Audius. Learn more at http://localhost:1323/coins/BARE",
metadata.Description)

// Nullable columns serialize as empty strings, not null
var raw map[string]any
require.NoError(t, json.Unmarshal(body, &raw))
assert.Equal(t, "", raw["image"])
assert.Equal(t, []any{}, raw["attributes"])
}

// Unknown mint
{
status, _ := testGet(t, app, "/v1/coins/nonexistentmint/metadata")
assert.Equal(t, 404, status)
}
}
Loading