diff --git a/api/server.go b/api/server.go index 70496829..1cca8200 100644 --- a/api/server.go +++ b/api/server.go @@ -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) diff --git a/api/v1_coin_metadata.go b/api/v1_coin_metadata.go new file mode 100644 index 00000000..58c45400 --- /dev/null +++ b/api/v1_coin_metadata.go @@ -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, + ) +} diff --git a/api/v1_coin_metadata_test.go b/api/v1_coin_metadata_test.go new file mode 100644 index 00000000..7b87436a --- /dev/null +++ b/api/v1_coin_metadata_test.go @@ -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) + } +}