From 3805f1e7a8f5e8779e022cbb810f945e4ef41e8c Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 27 Mar 2026 17:16:59 -0700 Subject: [PATCH 1/7] Add coin-gated text posts --- api/dbv1/access.go | 2 +- api/dbv1/full_comments.go | 34 +-- api/dbv1/parallel.go | 10 +- api/relay.go | 59 ++--- api/server.go | 2 + api/solana_wallet_middleware.go | 11 + api/v1_fan_club_comments.go | 400 ++++++++++++++++++++++++++++++++ api/v1_fan_club_feed_test.go | 396 +++++++++++++++++++++++++++++++ api/v1_track_access_info.go | 8 +- 9 files changed, 870 insertions(+), 52 deletions(-) create mode 100644 api/v1_fan_club_comments.go create mode 100644 api/v1_fan_club_feed_test.go diff --git a/api/dbv1/access.go b/api/dbv1/access.go index 6dd8109a..6f667ec1 100644 --- a/api/dbv1/access.go +++ b/api/dbv1/access.go @@ -323,7 +323,7 @@ func (q *Queries) GetBulkTrackAccess( var mint string var balance int64 if err := rows.Scan(&mint, &balance); err == nil { - walletTokenBalances[mint] = balance + walletTokenBalances[mint] += balance } } return rows.Err() diff --git a/api/dbv1/full_comments.go b/api/dbv1/full_comments.go index e8a14ee2..3e8a3042 100644 --- a/api/dbv1/full_comments.go +++ b/api/dbv1/full_comments.go @@ -40,7 +40,6 @@ type FullComment struct { Replies []FullComment `json:"replies"` ParentCommentId pgtype.Int4 `json:"parent_comment_id"` - // this should be omitted ReplyIds []int32 `db:"reply_ids" json:"-"` } @@ -51,12 +50,12 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) sql := ` SELECT - comment_id as id, - parent_comment_id, - entity_type, - entity_id, - user_id, - text as message, + comments.comment_id AS id, + comment_threads.parent_comment_id, + comments.entity_type, + comments.entity_id, + comments.user_id, + comments.text AS message, ( SELECT json_agg( @@ -72,7 +71,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) ) m )::jsonb as mentions, - track_timestamp_s, + comments.track_timestamp_s, ( SELECT count(*) @@ -90,7 +89,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) AND cc.is_delete = false ) as reply_ids, - is_edited, + comments.is_edited, EXISTS ( SELECT 1 @@ -104,7 +103,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) SELECT 1 FROM comment_reactions WHERE comment_id = comments.comment_id - AND user_id = tracks.owner_id + AND user_id = COALESCE(tracks.owner_id, comments.entity_id) AND is_delete = false ) AS is_artist_reacted, @@ -115,8 +114,8 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) FROM comment_notification_settings mutes WHERE @my_id > 0 AND mutes.user_id = @my_id - AND mutes.entity_type = entity_type - AND mutes.entity_id = entity_id + AND mutes.entity_type = comments.entity_type + AND mutes.entity_id = comments.entity_id LIMIT 1 ), false) as is_muted, @@ -124,10 +123,13 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) comments.updated_at FROM comments - JOIN tracks ON entity_id = track_id + LEFT JOIN tracks ON comments.entity_type = 'Track' AND comments.entity_id = tracks.track_id LEFT JOIN comment_threads USING (comment_id) - WHERE comment_id = ANY(@ids::int[]) - AND (@include_unlisted = true OR tracks.is_unlisted = false) + WHERE comments.comment_id = ANY(@ids::int[]) + AND ( + (comments.entity_type = 'Track' AND (@include_unlisted = true OR COALESCE(tracks.is_unlisted, false) = false)) + OR comments.entity_type = 'Coin' + ) ORDER BY comments.created_at DESC ` @@ -150,7 +152,6 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) commentMap[int32(comment.Id)] = comment } - // fetch replies replyIds := []int32{} for _, comment := range comments { replyIds = append(replyIds, comment.ReplyIds...) @@ -170,7 +171,6 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) comment.Replies = append(comment.Replies, reply) } } - // todo: sort replies? comment.ReplyCount = len(comment.Replies) if comment.IsDelete { diff --git a/api/dbv1/parallel.go b/api/dbv1/parallel.go index 69efd789..8de1a37f 100644 --- a/api/dbv1/parallel.go +++ b/api/dbv1/parallel.go @@ -7,11 +7,11 @@ import ( ) type ParallelParams struct { - UserIds []int32 - TrackIds []int32 - PlaylistIds []int32 - MyID int32 - AuthedWallet string + UserIds []int32 + TrackIds []int32 + PlaylistIds []int32 + MyID int32 + AuthedWallet string } type ParallelResult struct { diff --git a/api/relay.go b/api/relay.go index b07204b9..5d93b358 100644 --- a/api/relay.go +++ b/api/relay.go @@ -11,6 +11,7 @@ import ( "api.audius.co/trashid" "connectrpc.com/connect" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/sdk" cconfig "github.com/OpenAudio/go-openaudio/pkg/core/config" "github.com/OpenAudio/go-openaudio/pkg/core/server" eth_gen "github.com/OpenAudio/go-openaudio/pkg/eth/contracts/gen" @@ -259,37 +260,43 @@ func (app *ApiServer) relay(c *fiber.Ctx) error { }) } +const sosEndpoint = "https://sos.audius.co" + func (app *ApiServer) handleRelay(ctx context.Context, logger *zap.Logger, decodedTx *v1.ManageEntityLegacy) (*v1.Transaction, error) { - allClients := app.openAudioPool.GetAll() - if len(allClients) == 0 { - logger.Error("no OpenAudio clients configured") - return nil, fmt.Errorf("no OpenAudio clients configured") - } - - var lastErr error - for i, clientInfo := range allClients { - endpointLogger := logger.With(zap.String("openaudio_endpoint", clientInfo.Endpoint), zap.Int("attempt", i+1)) - res, err := clientInfo.Client.Core.SendTransaction(ctx, connect.NewRequest(&v1.SendTransactionRequest{ - Transaction: &v1.SignedTransaction{ - Transaction: &v1.SignedTransaction_ManageEntity{ - ManageEntity: decodedTx, - }, + req := &v1.SendTransactionRequest{ + Transaction: &v1.SignedTransaction{ + Transaction: &v1.SignedTransaction_ManageEntity{ + ManageEntity: decodedTx, }, - })) - - if err != nil { - lastErr = err - endpointLogger.Warn("transaction failed, trying next", zap.Error(err)) - continue + }, + } + + // mainnet + go func() { + allClients := app.openAudioPool.GetAll() + for i, clientInfo := range allClients { + endpointLogger := logger.With(zap.String("openaudio_endpoint", clientInfo.Endpoint), zap.Int("attempt", i+1)) + res, err := clientInfo.Client.Core.SendTransaction(context.Background(), connect.NewRequest(req)) + if err != nil { + endpointLogger.Warn("transaction failed, trying next", zap.Error(err)) + continue + } + endpointLogger.Info("transaction confirmed", zap.String("hash", res.Msg.Transaction.GetHash())) + return } + logger.Error("all mainnet endpoints failed") + }() - msg := res.Msg.Transaction - endpointLogger.Info("transaction confirmed", zap.String("hash", msg.GetHash())) - return msg, nil + // sos + sosClient := sdk.NewOpenAudioSDK(sosEndpoint) + sosLogger := logger.With(zap.String("openaudio_endpoint", sosEndpoint)) + res, err := sosClient.Core.SendTransaction(ctx, connect.NewRequest(req)) + if err != nil { + sosLogger.Warn("sos dual-write failed", zap.Error(err)) + return nil, err } - - logger.Error("all OpenAudio endpoints failed", zap.Error(lastErr)) - return nil, fmt.Errorf("all endpoints failed, last error: %w", lastErr) + sosLogger.Info("sos dual-write confirmed", zap.String("hash", res.Msg.Transaction.GetHash())) + return res.Msg.Transaction, nil } func transactionToReceipt(tx *v1.Transaction, wallet string) map[string]interface{} { diff --git a/api/server.go b/api/server.go index e6bf1f4f..737e3c47 100644 --- a/api/server.go +++ b/api/server.go @@ -507,6 +507,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/tracks/:trackId/downloads", app.requireAuthMiddleware, app.requireWriteScope, app.postV1TrackDownload) g.Put("/tracks/:trackId", app.requireAuthMiddleware, app.requireWriteScope, app.putV1Track) g.Delete("/tracks/:trackId", app.requireAuthMiddleware, app.requireWriteScope, app.deleteV1Track) + g.Get("/fan_club/feed", app.v1FanClubFeed) + g.Get("/tracks/:trackId/comments", app.v1TrackComments) g.Get("/tracks/:trackId/comment_count", app.v1TrackCommentCount) g.Get("/tracks/:trackId/comment-count", app.v1TrackCommentCount) diff --git a/api/solana_wallet_middleware.go b/api/solana_wallet_middleware.go index 00533a09..fc8663e7 100644 --- a/api/solana_wallet_middleware.go +++ b/api/solana_wallet_middleware.go @@ -52,3 +52,14 @@ func (app *ApiServer) solanaWalletMiddleware(c *fiber.Ctx) error { c.Locals(SolanaWalletCtxKey, wallet) return c.Next() } + +// tryGetSolanaWallet returns the verified wallet from the request context, or "". +func (app *ApiServer) tryGetSolanaWallet(c *fiber.Ctx) string { + if c == nil { + return "" + } + if w, ok := c.UserContext().Value(SolanaWalletCtxKey).(string); ok { + return w + } + return "" +} diff --git a/api/v1_fan_club_comments.go b/api/v1_fan_club_comments.go new file mode 100644 index 00000000..7d7ada9b --- /dev/null +++ b/api/v1_fan_club_comments.go @@ -0,0 +1,400 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "time" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +// fanClubFeedMergedSQL returns rows: kind ('text_post' | 'track'), comment_id, track_id, created_at. +const fanClubFeedMergedSQL = ` +WITH +coin AS ( + SELECT mint, user_id AS owner_id + FROM artist_coins + WHERE mint = @mint +), + +muted_by_karma AS ( + SELECT muted_user_id + FROM muted_users + JOIN aggregate_user ON muted_users.user_id = aggregate_user.user_id + WHERE muted_users.is_delete = false + GROUP BY muted_user_id + HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold +), + +low_abuse_score AS ( + SELECT user_id + FROM aggregate_user + WHERE score < 0 +), + +deactivated_users AS ( + SELECT user_id + FROM users + WHERE is_deactivated = true +), + +high_karma_reporters AS ( + SELECT comment_reports.comment_id + FROM comment_reports + JOIN aggregate_user ON comment_reports.user_id = aggregate_user.user_id + WHERE comment_reports.is_delete = false + GROUP BY comment_reports.comment_id + HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold +), + +eligible_comments AS ( + SELECT + 'text_post'::text AS kind, + comments.comment_id AS comment_id, + NULL::integer AS track_id, + comments.created_at + FROM comments + LEFT JOIN coin ON comments.entity_id = coin.owner_id + LEFT JOIN comment_threads USING (comment_id) + LEFT JOIN comment_reports ON comments.comment_id = comment_reports.comment_id + LEFT JOIN muted_users ON ( + muted_users.muted_user_id = comments.user_id + AND ( + muted_users.user_id = @myId + OR muted_users.user_id = coin.owner_id + OR muted_users.muted_user_id IN (SELECT muted_user_id FROM muted_by_karma) + ) + AND @myId != comments.user_id + ) + LEFT JOIN low_abuse_score ON ( + low_abuse_score.user_id = comments.user_id + AND @myId != comments.user_id + AND coin.owner_id != comments.user_id + ) + LEFT JOIN deactivated_users ON ( + deactivated_users.user_id = comments.user_id + AND @myId != comments.user_id + AND coin.owner_id != comments.user_id + ) + WHERE comments.entity_id = coin.owner_id + AND NOT EXISTS ( + SELECT 1 FROM comment_threads ct WHERE ct.comment_id = comments.comment_id + ) + AND comments.entity_type = 'Coin' + AND comments.is_delete = false + AND ( + comment_reports.comment_id IS NULL + OR ( + comment_reports.user_id != COALESCE(@myId, 0) + AND comment_reports.user_id != coin.owner_id + AND comments.comment_id NOT IN (SELECT hkr.comment_id FROM high_karma_reporters hkr) + ) + OR comment_reports.is_delete = true + ) + AND ( + muted_users.muted_user_id IS NULL + OR muted_users.is_delete = true + ) + AND low_abuse_score.user_id IS NULL + AND deactivated_users.user_id IS NULL +), + +eligible_tracks AS ( + SELECT + 'track'::text AS kind, + NULL::integer AS comment_id, + tracks.track_id AS track_id, + tracks.created_at + FROM tracks + INNER JOIN coin ON tracks.owner_id = coin.owner_id + WHERE tracks.is_current = true + AND tracks.is_delete = false + AND tracks.is_unlisted = false + AND tracks.is_stream_gated = true + AND tracks.stream_conditions->'token_gate'->>'token_mint' = @mint +) + +SELECT kind, comment_id, track_id, created_at FROM ( + SELECT * FROM eligible_comments + UNION ALL + SELECT * FROM eligible_tracks +) AS merged +` + +// viewerCanRevealFanClubText is true for the artist, Audius users holding >= 1 whole token, +// or a verified third-party Solana wallet with enough raw balance for that mint. +func (app *ApiServer) viewerCanRevealFanClubText( + ctx context.Context, + artistUserID int32, + mint string, + myID int32, + solanaWallet string, +) (bool, error) { + if myID != 0 && myID == artistUserID { + return true, nil + } + + var audiusOK bool + err := app.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM sol_user_balances sub + JOIN artist_coins ac ON ac.mint = sub.mint + WHERE sub.user_id = $1 + AND ac.user_id = $2 + AND ac.mint = $3 + AND sub.balance >= power(10::numeric, ac.decimals)::bigint + ) + `, myID, artistUserID, mint).Scan(&audiusOK) + if err != nil { + return false, err + } + if audiusOK { + return true, nil + } + + if solanaWallet == "" { + return false, nil + } + + var walletOK bool + err = app.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(st.balance), 0) >= power(10::numeric, ac.decimals)::bigint + FROM artist_coins ac + LEFT JOIN sol_token_account_balances st ON st.owner = $2 AND st.mint = ac.mint + WHERE ac.mint = $1 + GROUP BY ac.decimals + `, mint, solanaWallet).Scan(&walletOK) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, err + } + return walletOK, nil +} + +func fanClubRedactCommentMessageMap(m map[string]any) { + m["message"] = nil + reps, ok := m["replies"].([]any) + if !ok || reps == nil { + return + } + for _, r := range reps { + rm, ok := r.(map[string]any) + if !ok { + continue + } + fanClubRedactCommentMessageMap(rm) + } +} + +func fanClubCommentForFeedJSON(co dbv1.FullComment, reveal bool) (any, error) { + if reveal { + return co, nil + } + b, err := json.Marshal(co) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + fanClubRedactCommentMessageMap(m) + return m, nil +} + +func (app *ApiServer) v1FanClubFeed(c *fiber.Ctx) error { + mint := c.Query("mint") + if mint == "" { + return fiber.NewError(fiber.StatusBadRequest, "mint query parameter is required") + } + + var artistUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM artist_coins WHERE mint = $1 LIMIT 1 + `, mint).Scan(&artistUserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(fiber.StatusNotFound, "unknown artist coin mint") + } + return err + } + + var params GetCommentsParams + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myID := app.getMyId(c) + solWallet := app.tryGetSolanaWallet(c) + + revealText, err := app.viewerCanRevealFanClubText(c.Context(), artistUserID, mint, myID, solWallet) + if err != nil { + return err + } + + args := pgx.NamedArgs{ + "myId": myID, + "mint": mint, + "karmaCommentCountThreshold": karmaCommentCountThreshold, + "limit": params.Limit, + "offset": params.Offset, + } + + orderBy := `merged.created_at DESC` + switch params.SortMethod { + case "timestamp": + orderBy = `merged.created_at ASC` + case "top": + orderBy = `(SELECT COUNT(*) FROM comment_reactions cr WHERE cr.comment_id = merged.comment_id) DESC NULLS LAST, merged.created_at DESC` + } + + sql := fanClubFeedMergedSQL + ` ORDER BY ` + orderBy + ` +LIMIT @limit +OFFSET @offset +` + + type feedRow struct { + Kind string + CommentID *int32 + TrackID *int32 + CreatedAt time.Time + } + + rows, err := app.pool.Query(c.Context(), sql, args) + if err != nil { + return err + } + defer rows.Close() + + var feedRows []feedRow + for rows.Next() { + var r feedRow + if err := rows.Scan(&r.Kind, &r.CommentID, &r.TrackID, &r.CreatedAt); err != nil { + return err + } + feedRows = append(feedRows, r) + } + if err := rows.Err(); err != nil { + return err + } + + commentIDs := make([]int32, 0) + trackIDs := make([]int32, 0) + for _, r := range feedRows { + switch r.Kind { + case "text_post": + if r.CommentID != nil { + commentIDs = append(commentIDs, *r.CommentID) + } + case "track": + if r.TrackID != nil { + trackIDs = append(trackIDs, *r.TrackID) + } + } + } + + comments, err := app.queries.FullComments(c.Context(), dbv1.GetCommentsParams{ + Ids: commentIDs, + MyID: myID, + IncludeUnlisted: true, + }) + if err != nil { + return err + } + commentByID := make(map[int32]dbv1.FullComment, len(comments)) + for _, co := range comments { + commentByID[int32(co.Id)] = co + } + + parallel, err := app.queries.Parallel(c.Context(), dbv1.ParallelParams{ + UserIds: nil, + TrackIds: trackIDs, + MyID: myID, + AuthedWallet: app.tryGetAuthedWallet(c), + }) + if err != nil { + return err + } + trackMap := parallel.TrackMap + if trackMap == nil { + trackMap = map[int32]dbv1.Track{} + } + + userIDs := []int32{} + relatedTrackIDs := append([]int32(nil), trackIDs...) + for _, co := range comments { + userIDs = append(userIDs, int32(co.UserId)) + if co.EntityType == "Track" { + relatedTrackIDs = append(relatedTrackIDs, int32(co.EntityId)) + } + if co.EntityType == "Coin" { + userIDs = append(userIDs, int32(co.EntityId)) + } + for _, m := range co.Mentions { + userIDs = append(userIDs, int32(m.UserId)) + } + } + for _, tid := range trackIDs { + if t, ok := trackMap[tid]; ok { + userIDs = append(userIDs, int32(t.UserID)) + } + } + + related, err := app.queries.Parallel(c.Context(), dbv1.ParallelParams{ + UserIds: userIDs, + TrackIds: relatedTrackIDs, + MyID: myID, + AuthedWallet: app.tryGetAuthedWallet(c), + }) + if err != nil { + return err + } + + items := make([]fiber.Map, 0, len(feedRows)) + for _, r := range feedRows { + switch r.Kind { + case "text_post": + if r.CommentID == nil { + continue + } + co, ok := commentByID[*r.CommentID] + if !ok { + continue + } + commentJSON, err := fanClubCommentForFeedJSON(co, revealText) + if err != nil { + return err + } + items = append(items, fiber.Map{ + "item_type": "text_post", + "comment": commentJSON, + }) + case "track": + if r.TrackID == nil { + continue + } + tr, ok := trackMap[*r.TrackID] + if !ok { + continue + } + items = append(items, fiber.Map{ + "item_type": "track", + "track": tr, + }) + } + } + + return c.JSON(fiber.Map{ + "data": items, + "related": fiber.Map{ + "users": related.UserList(), + "tracks": related.TrackList(), + }, + }) +} diff --git a/api/v1_fan_club_feed_test.go b/api/v1_fan_club_feed_test.go new file mode 100644 index 00000000..53126057 --- /dev/null +++ b/api/v1_fan_club_feed_test.go @@ -0,0 +1,396 @@ +package api + +import ( + "crypto/ed25519" + "io" + "net/http/httptest" + "net/url" + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testFanClubFeedMint = "FanClubMintTest11111111111111111111111" + +func testFanClubFeedBaseUsers() []map[string]any { + return []map[string]any{ + { + "user_id": 1, + "handle": "fcartist", + "handle_lc": "fcartist", + "name": "FC Artist", + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + }, + { + "user_id": 2, + "handle": "fcfan", + "handle_lc": "fcfan", + "name": "FC Fan", + "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", + }, + } +} + +func testFanClubFeedURL(viewerUserID int, extraQuery string) string { + v := url.Values{} + v.Set("mint", testFanClubFeedMint) + if viewerUserID > 0 { + v.Set("user_id", trashid.MustEncodeHashID(viewerUserID)) + } + if extraQuery != "" { + more, err := url.ParseQuery(extraQuery) + if err != nil { + panic(err) + } + for k, vals := range more { + for _, val := range vals { + v.Add(k, val) + } + } + } + return "/v1/fan_club/feed?" + v.Encode() +} + +func testGetWithSolanaHeaders(t *testing.T, app *ApiServer, path string, walletPub58, message string, sig []byte) (int, []byte) { + t.Helper() + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("X-Solana-Wallet", walletPub58) + req.Header.Set("X-Solana-Message", message) + req.Header.Set("X-Solana-Signature", base58.Encode(sig)) + res, err := app.Test(req, -1) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + return res.StatusCode, body +} + +func TestFanClubFeed_MissingMint(t *testing.T) { + app := emptyTestApp(t) + status, _ := testGet(t, app, "/v1/fan_club/feed") + assert.Equal(t, 400, status) +} + +func TestFanClubFeed_UnknownMint(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + }) + status, _ := testGet(t, app, "/v1/fan_club/feed?mint=UnknownMintZZZZZZZZZZZZZZZZZZZZZZZZZZZ") + assert.Equal(t, 404, status) +} + +func TestFanClubFeed_UnauthedStillReturnsFeed(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + }) + status, _ := testGet(t, app, testFanClubFeedURL(0, "")) + assert.Equal(t, 200, status) +} + +func TestFanClubFeed_ArtistSeesOnlyCoinGatedTrack(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "tracks": { + { + "track_id": 701, + "owner_id": 1, + "title": "Coin gated for feed", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-02 00:00:00", + }, + { + "track_id": 702, + "owner_id": 1, + "title": "Public not gated", + "is_stream_gated": false, + "created_at": "2020-01-03 00:00:00", + }, + { + "track_id": 703, + "owner_id": 1, + "title": "Gated other mint", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": "OtherMintZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "token_amount": 1, + }, + }, + "created_at": "2020-01-04 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(1, "") + status, body := testGetWithWallet(t, app, path, "0x7d273271690538cf855e5b3002a0dd8c154bb060") + require.Equal(t, 200, status, string(body)) + + enc701, err := trashid.EncodeHashId(701) + require.NoError(t, err) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.item_type": "track", + "data.0.track.title": "Coin gated for feed", + "data.0.track.id": enc701, + "data.1.item_type": "", + "related.tracks.#": 1, + }) +} + +func TestFanClubFeed_FanWithBalanceSeesFeed(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_user_balances": { + { + "user_id": 2, + "mint": testFanClubFeedMint, + "balance": int64(1_000_000), + }, + }, + "tracks": { + { + "track_id": 801, + "owner_id": 1, + "title": "Gated track", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-01 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(2, "") + status, body := testGetWithWallet(t, app, path, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + require.Equal(t, 200, status, string(body)) + + enc801, err := trashid.EncodeHashId(801) + require.NoError(t, err) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.item_type": "track", + "data.0.track.id": enc801, + "data.0.track.title": "Gated track", + }) +} + +func TestFanClubFeed_IncludesTextPostWhenCommentRowPresent(t *testing.T) { + app := emptyTestApp(t) + + commentRow := map[string]any{ + "comment_id": 910, + "user_id": 2, + "entity_id": 1, + "entity_type": "Coin", + "text": "hello fan club", + "created_at": "2020-06-01 00:00:00", + } + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_user_balances": { + { + "user_id": 2, + "mint": testFanClubFeedMint, + "balance": int64(1_000_000), + }, + }, + "tracks": { + { + "track_id": 901, + "owner_id": 1, + "title": "Gated", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-01 00:00:00", + }, + }, + "comments": []map[string]any{commentRow}, + }) + + path := testFanClubFeedURL(2, "sort_method=newest") + status, body := testGetWithWallet(t, app, path, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + if status != 200 { + t.Fatalf("expected 200 got %d: %s", status, string(body)) + } + + enc910, err := trashid.EncodeHashId(910) + require.NoError(t, err) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.item_type": "text_post", + "data.0.comment.message": "hello fan club", + "data.0.comment.id": enc910, + "data.1.item_type": "track", + "data.1.track.title": "Gated", + }) +} + +func TestFanClubFeed_FanWithoutBalanceGetsFeedWithRedactedText(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_user_balances": { + { + "user_id": 2, + "mint": testFanClubFeedMint, + "balance": int64(1), + }, + }, + "tracks": { + { + "track_id": 902, + "owner_id": 1, + "title": "Gated", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-01 00:00:00", + }, + }, + "comments": []map[string]any{ + { + "comment_id": 920, + "user_id": 2, + "entity_id": 1, + "entity_type": "Coin", + "text": "secret post", + "created_at": "2020-06-01 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(2, "sort_method=newest") + status, body := testGetWithWallet(t, app, path, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + require.Equal(t, 200, status, string(body)) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.item_type": "text_post", + "data.1.item_type": "track", + }) + jsonAssert(t, body, map[string]any{ + "data.0.comment.message": nil, + }) +} + +func TestFanClubFeed_ExternalSolanaWalletRevealsTextPost(t *testing.T) { + app := emptyTestApp(t) + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + wallet58 := base58.Encode(pub) + msg := "Audius fan club" + sig := ed25519.Sign(priv, []byte(msg)) + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_token_account_balances": { + { + "mint": testFanClubFeedMint, + "account": "fcfeedta1", + "owner": wallet58, + "balance": int64(1_000_000), + }, + }, + "comments": []map[string]any{ + { + "comment_id": 930, + "user_id": 2, + "entity_id": 1, + "entity_type": "Coin", + "text": "holders only", + "created_at": "2020-06-01 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(0, "") + status, body := testGetWithSolanaHeaders(t, app, path, wallet58, msg, sig) + require.Equal(t, 200, status, string(body)) + + enc930, err := trashid.EncodeHashId(930) + require.NoError(t, err) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.item_type": "text_post", + "data.0.comment.message": "holders only", + "data.0.comment.id": enc930, + }) +} diff --git a/api/v1_track_access_info.go b/api/v1_track_access_info.go index b877d30d..f50e409b 100644 --- a/api/v1_track_access_info.go +++ b/api/v1_track_access_info.go @@ -53,15 +53,17 @@ func (app *ApiServer) v1TrackAccessInfo(c *fiber.Ctx) error { myId := app.getMyId(c) trackId := c.Locals("trackId").(int) - // Get the track with extended information - tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + params := dbv1.TracksParams{ GetTracksParams: dbv1.GetTracksParams{ MyID: myId, Ids: []int32{int32(trackId)}, AuthedWallet: app.tryGetAuthedWallet(c), IncludeUnlisted: true, }, - }) + } + + // Get the track with extended information (Solana wallet for token gates comes from context via middleware) + tracks, err := app.queries.Tracks(c.Context(), params) if err != nil { return err } From 03d6f1250957965f2ecffaac7cbe6e65a0c95ad2 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 30 Mar 2026 12:14:54 -0700 Subject: [PATCH 2/7] Update swagger --- api/swagger/swagger-v1.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 716d05b0..92a1a0c6 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -14682,6 +14682,7 @@ components: description: Type of entity that can be commented on enum: - Track + - Coin top_listener: type: object properties: From e3bd7d19b137192071271ec55a34b60a275ca791 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 30 Mar 2026 14:34:22 -0700 Subject: [PATCH 3/7] Updates --- api/relay.go | 59 +++++++++++++++------------------ api/solana_wallet_middleware.go | 11 ------ api/v1_fan_club_comments.go | 2 +- 3 files changed, 27 insertions(+), 45 deletions(-) diff --git a/api/relay.go b/api/relay.go index 5d93b358..b07204b9 100644 --- a/api/relay.go +++ b/api/relay.go @@ -11,7 +11,6 @@ import ( "api.audius.co/trashid" "connectrpc.com/connect" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" - "github.com/OpenAudio/go-openaudio/pkg/sdk" cconfig "github.com/OpenAudio/go-openaudio/pkg/core/config" "github.com/OpenAudio/go-openaudio/pkg/core/server" eth_gen "github.com/OpenAudio/go-openaudio/pkg/eth/contracts/gen" @@ -260,43 +259,37 @@ func (app *ApiServer) relay(c *fiber.Ctx) error { }) } -const sosEndpoint = "https://sos.audius.co" - func (app *ApiServer) handleRelay(ctx context.Context, logger *zap.Logger, decodedTx *v1.ManageEntityLegacy) (*v1.Transaction, error) { - req := &v1.SendTransactionRequest{ - Transaction: &v1.SignedTransaction{ - Transaction: &v1.SignedTransaction_ManageEntity{ - ManageEntity: decodedTx, + allClients := app.openAudioPool.GetAll() + if len(allClients) == 0 { + logger.Error("no OpenAudio clients configured") + return nil, fmt.Errorf("no OpenAudio clients configured") + } + + var lastErr error + for i, clientInfo := range allClients { + endpointLogger := logger.With(zap.String("openaudio_endpoint", clientInfo.Endpoint), zap.Int("attempt", i+1)) + res, err := clientInfo.Client.Core.SendTransaction(ctx, connect.NewRequest(&v1.SendTransactionRequest{ + Transaction: &v1.SignedTransaction{ + Transaction: &v1.SignedTransaction_ManageEntity{ + ManageEntity: decodedTx, + }, }, - }, - } - - // mainnet - go func() { - allClients := app.openAudioPool.GetAll() - for i, clientInfo := range allClients { - endpointLogger := logger.With(zap.String("openaudio_endpoint", clientInfo.Endpoint), zap.Int("attempt", i+1)) - res, err := clientInfo.Client.Core.SendTransaction(context.Background(), connect.NewRequest(req)) - if err != nil { - endpointLogger.Warn("transaction failed, trying next", zap.Error(err)) - continue - } - endpointLogger.Info("transaction confirmed", zap.String("hash", res.Msg.Transaction.GetHash())) - return + })) + + if err != nil { + lastErr = err + endpointLogger.Warn("transaction failed, trying next", zap.Error(err)) + continue } - logger.Error("all mainnet endpoints failed") - }() - // sos - sosClient := sdk.NewOpenAudioSDK(sosEndpoint) - sosLogger := logger.With(zap.String("openaudio_endpoint", sosEndpoint)) - res, err := sosClient.Core.SendTransaction(ctx, connect.NewRequest(req)) - if err != nil { - sosLogger.Warn("sos dual-write failed", zap.Error(err)) - return nil, err + msg := res.Msg.Transaction + endpointLogger.Info("transaction confirmed", zap.String("hash", msg.GetHash())) + return msg, nil } - sosLogger.Info("sos dual-write confirmed", zap.String("hash", res.Msg.Transaction.GetHash())) - return res.Msg.Transaction, nil + + logger.Error("all OpenAudio endpoints failed", zap.Error(lastErr)) + return nil, fmt.Errorf("all endpoints failed, last error: %w", lastErr) } func transactionToReceipt(tx *v1.Transaction, wallet string) map[string]interface{} { diff --git a/api/solana_wallet_middleware.go b/api/solana_wallet_middleware.go index fc8663e7..00533a09 100644 --- a/api/solana_wallet_middleware.go +++ b/api/solana_wallet_middleware.go @@ -52,14 +52,3 @@ func (app *ApiServer) solanaWalletMiddleware(c *fiber.Ctx) error { c.Locals(SolanaWalletCtxKey, wallet) return c.Next() } - -// tryGetSolanaWallet returns the verified wallet from the request context, or "". -func (app *ApiServer) tryGetSolanaWallet(c *fiber.Ctx) string { - if c == nil { - return "" - } - if w, ok := c.UserContext().Value(SolanaWalletCtxKey).(string); ok { - return w - } - return "" -} diff --git a/api/v1_fan_club_comments.go b/api/v1_fan_club_comments.go index 7d7ada9b..97acf36e 100644 --- a/api/v1_fan_club_comments.go +++ b/api/v1_fan_club_comments.go @@ -231,7 +231,7 @@ func (app *ApiServer) v1FanClubFeed(c *fiber.Ctx) error { } myID := app.getMyId(c) - solWallet := app.tryGetSolanaWallet(c) + solWallet, _ := c.Context().Value(SolanaWalletCtxKey).(string) revealText, err := app.viewerCanRevealFanClubText(c.Context(), artistUserID, mint, myID, solWallet) if err != nil { From 9bda99b52916c3c0f8f064a8d1dae8474018323f Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 30 Mar 2026 16:12:07 -0700 Subject: [PATCH 4/7] fix: use FanClub entity type instead of Coin in full_comments query --- api/dbv1/full_comments.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/dbv1/full_comments.go b/api/dbv1/full_comments.go index 3e8a3042..2068d0c0 100644 --- a/api/dbv1/full_comments.go +++ b/api/dbv1/full_comments.go @@ -128,7 +128,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) WHERE comments.comment_id = ANY(@ids::int[]) AND ( (comments.entity_type = 'Track' AND (@include_unlisted = true OR COALESCE(tracks.is_unlisted, false) = false)) - OR comments.entity_type = 'Coin' + OR comments.entity_type = 'FanClub' ) ORDER BY comments.created_at DESC ` From 4b9521cf950b7b43325fe37a11414fd4d8f28b66 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 30 Mar 2026 16:13:12 -0700 Subject: [PATCH 5/7] Update and rename v1_fan_club_comments.go to v1_fan_club_feed.go --- api/{v1_fan_club_comments.go => v1_fan_club_feed.go} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename api/{v1_fan_club_comments.go => v1_fan_club_feed.go} (99%) diff --git a/api/v1_fan_club_comments.go b/api/v1_fan_club_feed.go similarity index 99% rename from api/v1_fan_club_comments.go rename to api/v1_fan_club_feed.go index 97acf36e..a4f66d5f 100644 --- a/api/v1_fan_club_comments.go +++ b/api/v1_fan_club_feed.go @@ -83,7 +83,7 @@ eligible_comments AS ( AND NOT EXISTS ( SELECT 1 FROM comment_threads ct WHERE ct.comment_id = comments.comment_id ) - AND comments.entity_type = 'Coin' + AND comments.entity_type = 'FanClub' AND comments.is_delete = false AND ( comment_reports.comment_id IS NULL @@ -333,7 +333,7 @@ OFFSET @offset if co.EntityType == "Track" { relatedTrackIDs = append(relatedTrackIDs, int32(co.EntityId)) } - if co.EntityType == "Coin" { + if co.EntityType == "FanClub" { userIDs = append(userIDs, int32(co.EntityId)) } for _, m := range co.Mentions { From 13f5d4295f503f7e0220bef7205d883f4f39e04a Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 30 Mar 2026 16:14:17 -0700 Subject: [PATCH 6/7] feat: add /fan-club/feed hyphenated alias route --- api/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/server.go b/api/server.go index 737e3c47..0757338f 100644 --- a/api/server.go +++ b/api/server.go @@ -508,6 +508,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Put("/tracks/:trackId", app.requireAuthMiddleware, app.requireWriteScope, app.putV1Track) g.Delete("/tracks/:trackId", app.requireAuthMiddleware, app.requireWriteScope, app.deleteV1Track) g.Get("/fan_club/feed", app.v1FanClubFeed) + g.Get("/fan-club/feed", app.v1FanClubFeed) g.Get("/tracks/:trackId/comments", app.v1TrackComments) g.Get("/tracks/:trackId/comment_count", app.v1TrackCommentCount) From 21ebba211eed0d29a9638658af823a414719f279 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 30 Mar 2026 16:18:20 -0700 Subject: [PATCH 7/7] Add fan club feed endpoints to Swagger API --- api/swagger/swagger-v1.yaml | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 92a1a0c6..55160766 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -611,6 +611,100 @@ paths: "500": description: Server error content: {} + /fan_club/feed: + get: + tags: + - fan_club + description: Get the fan club feed for a given artist, including text posts and comments + operationId: Get Fan Club Feed + security: + - {} + - OAuth2: + - read + parameters: + - name: mint + in: query + description: The mint address of the artist's fan club token + required: true + schema: + type: string + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/track_comments_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} + /fan-club/feed: + get: + tags: + - fan_club + description: Get the fan club feed for a given artist (hyphenated alias), including text posts and comments + operationId: Get Fan Club Feed Alias + security: + - {} + - OAuth2: + - read + parameters: + - name: mint + in: query + description: The mint address of the artist's fan club token + required: true + schema: + type: string + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/track_comments_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /developer-apps: post: tags: