Tags: AudiusProject/api
Tags
fix(rewards): resolve the reward manager from chain state, not the co… …nfig secret (#1015) ## The bug Launchpad reward-code creation derived `rewards_manager_pubkey` from the currently configured launchpad deterministic secret: ```go rmKey := utils.DeriveRewardManagerKeypair(cfg.LaunchpadDeterministicSecret, mintPubKey) rewardsManagerPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey)) ``` The Solana reward manager account is created **once**, at coin launch, using whatever secret was live at the time — and it can never move. So if that secret is later rotated, an already-launched mint starts deriving a reward manager that has no Solana account. The pool-creation branch then turns that into durable bad state. Its comment says *"first reward against this mint? create the pool"*, but the condition it actually tests is *"no pool exists for this **derived** reward manager"*. Those mean the same thing only while the secret never changes. After a rotation an established mint looks brand-new, and the code quietly creates a parallel pool bound to a reward manager that doesn't exist on chain. Rewards written to that pool are not lost — redemption resolves the reward manager by mint, independently — but core then records a reward manager that doesn't correspond to the mint's actual Solana account. ## The fix Resolve it from `sol_reward_manager_inits`, which the Solana indexer writes from observed `InitRewardManager` instructions. That's ground truth, and it's unaffected by the secret rotating. It's also the source [`v1_coins_post_redeem.go`](api/v1_coins_post_redeem.go) already reads — which is why redemption was unaffected while creation wasn't. Creating a pool still needs the reward manager **private** key for `rm_owner_signature`, and only derivation produces that. So the creation path derives the keypair and checks its public half against the reward manager Solana actually has. A mismatch is precisely the rotated-mint case, and it now fails loudly instead of creating a pool nothing can redeem against. In practice that check rarely fires, which is the point: - **established mint** — resolving the real reward manager finds the existing pool, so the creation branch is never entered - **mint launched under the current secret** — derived equals real, creation proceeds as before - **rotated mint with no pool** — the one genuinely broken case, and the only one that errors Both call sites (the HTTP handler and the bulk CLI) duplicated this logic; they now share `launchpad.PrepareRewardPool` so they can't drift. ## Tests `launchpad/reward_pool_test.go` covers: - a mint launched under an earlier secret reuses its real pool and **creates nothing** - a rotated mint with no existing pool fails with `ErrRewardManagerMismatch` and creates nothing - a mint with no indexed reward manager fails with `ErrRewardManagerNotIndexed` without touching cometbft - a mint launched under the current secret still creates its pool Verified **red against the previous behavior**: reverting to derive-then-create fails three of these, including with `an established mint must never look brand-new; creating a pool here is the phantom-pool bug`. ## Notes for review - Reads via `app.pool` rather than `app.writePool` — the latter is nil when `WriteDbUrl` is unset, and this is the same table and pool the redeem path already reads. - No backfill. Rewards already bound to a derived reward manager are untouched; this only stops new ones. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fix(api): restore album-purchase track access (#1014) Buying an album grants access to its tracks, and that access is meant to survive a track later leaving the album for anyone whose purchase predates the removal. This PR now fixes the full path: the API reader, the ETL writer dependency, and the recoverable historical data. ## Reader fixes `tracks.playlists_previously_containing_track` is a jsonb object keyed by playlist id: ```json {"1284768821": {"time": 1725873897}} ``` Three bugs prevented the API from using it: 1. The reader unmarshalled that object into a Go slice of `{playlist_id, removal_time}`. The error was swallowed, so access was denied silently. 2. The SQL used `jsonb_each_text` and cast the object value (`{"time": ...}`) directly to numeric, which would fail once the reader bug was fixed. 3. The result was modeled per playlist, but removal time belongs to a `(track, album)` pair. If two tracks leave the same album at different times, a purchase between the removals covers only the track that was still in the album. The API now parses the production object shape, flattens it to `(track_id, playlist_id, removal_time)` records, and joins those records to purchases with `jsonb_to_recordset`. Malformed input denies rather than grants. ## ETL writer `go.mod` now consumes the merged OpenAudio writer from [OpenAudio/go-openaudio#481](OpenAudio/go-openaudio#481): ```text github.com/OpenAudio/go-openaudio/pkg/etl v1.6.5-0.20260810163330-95f8e2ff0c66 ``` That pseudo-version resolves to merge commit `95f8e2ff0c66573920488e272dc53ca245c57343`. The writer updates `playlist_tracks` and both reverse-index columns in the same transaction, using the block timestamp for future removal records. ## Backfill Migration `0238_backfill_track_playlist_reverse_index.sql` treats `playlist_tracks` as the authoritative membership table and: - fills `tracks.playlists_containing_track` with every active relation; - removes inactive relations from that array; - clears stale removal records for tracks that are active again; and - preserves existing historical removal records. It compares arrays as sets so ordering alone does not rewrite a track, keeps the search trigger enabled, temporarily suppresses the expensive catalog recount trigger, preserves a pre-disabled trigger state, and is idempotent. The migration deliberately does **not** invent missing historical removal records. `playlist_tracks.updated_at` was written with `now()`, not block time; using it for entitlement could grant access to a buyer who purchased after the on-chain removal but before a delayed indexer processed it. The merged ETL writer records exact block timestamps going forward. ## Rollout API DDL runs in the pre-roll migration Job, while the old indexer can still be active. After the new indexer version is fully rolled out, manually execute `ddl/migrations/0238_backfill_track_playlist_reverse_index.sql` once more against the writer database. Restarting `pg_migrate` alone will not rerun an already tracked file. Then verify that the current reverse index has no mismatches: ```sql WITH expected AS ( SELECT track_id, COALESCE( array_agg(playlist_id ORDER BY playlist_id) FILTER (WHERE is_removed = false), '{}'::integer[] ) AS active_playlist_ids FROM playlist_tracks GROUP BY track_id ) SELECT count(*) AS mismatched_tracks FROM tracks t JOIN expected e USING (track_id) WHERE t.is_current = true AND ( NOT ( t.playlists_containing_track @> e.active_playlist_ids AND e.active_playlist_ids @> t.playlists_containing_track ) OR EXISTS ( SELECT 1 FROM unnest(e.active_playlist_ids) AS playlist_id WHERE jsonb_exists( t.playlists_previously_containing_track, playlist_id::text ) ) ); ``` Expected result: `0`. ## Verification - `go test -count=1 ./api/dbv1 -run 'TestParseTrackRemovals|TestTrackRemovalMarshalsToRecordsetColumns'` - `go test -count=1 ./api -run TestTrackAccessAfterRemovalFromPurchasedAlbum` - repository migration manager idempotency run and all SQL tests - migration fixture covering active, removed, stale-removal, and array-order cases - second migration run: `UPDATE 0` - pre-disabled `on_track` remains disabled - post-backfill verification query: `mismatched_tracks = 0` `TestSearch` still requires Elasticsearch when run locally; it was already unrelated to this change. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Raymond Jacobson <ray@audius.co>
fix(api): accept coin_flair_mint when creating a user (#1012) ## The gap `update_user_request_body` has carried `coin_flair_mint` since `0175`. The create body never did. Nothing about the field explains the difference. The column comment describes it as: > the coin which the user has selected as their preferred flair. NULL for auto, empty string for none. That's a display preference — the same shape as `spl_usdc_payout_wallet`, which *is* accepted on create. The ordering makes oversight the likelier reading than intent: | Migration | Field | In create body? | |---|---|:--:| | `0141` | `profile_type` | yes | | `0175` | `coin_flair_mint` | **no** | | `0200` | `spl_usdc_payout_wallet` | yes | The one in the middle is the only one missing. ## Result Create and update now differ by exactly the fields that should differ: ``` create-only : user_id, wallet update-only : artist_pick_track_id, is_deactivated ``` Both update-only fields have reasons independent of any schema: - **`artist_pick_track_id`** references a track the account cannot own at signup. Across 292,111 users never modified after creation, it appears **zero** times. - **`is_deactivated`** — creating an already-deactivated account is meaningless. ## Verification Parsed the modified document with `js-yaml`: ``` YAML parses OK create has coin_flair_mint: true create fields: 21 update fields: 21 update-only: is_deactivated, artist_pick_track_id ``` Three lines, response schemas untouched. ## Context Found while auditing the indexer, which was dropping four fields the create body already accepted — `profile_type`, `allow_ai_attribution`, `spl_usdc_payout_wallet`, `playlist_library`. Fixed in OpenAudio/go-openaudio#466, which also adds a test pinning the indexer's create and update column sets together. This PR closes the same gap one layer up so all three layers — API contract, SDK schema, indexer — agree. The SDK's Zod `CreateUserSchema` is a separate hand-written contract that's also missing `coin_flair_mint` (and `profile_type`); worth a follow-up there. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fix(api): scope user-subscriber reads to entity_type 'User'; unbreak … …main CI (#1013) ## What Four related fixes around the overloaded `subscriptions.user_id` column (it mirrors the event id for Event rows, and event ids are allocated independently of user ids). Background: #1011 seeded production's `subscriptions_current_uniq_idx` into the test schema, which both broke main's CI and led to the discovery of the bugs below (see OpenAudio/go-openaudio#469 for the upstream identity fix). **1. Live bug: event followers counted as user subscribers.** `/v1/users/{id}/subscribers` and the upload-notification fan-outs in `handle_track` / `handle_playlist` match on `user_id` alone. A follower of event N therefore counts as a subscriber of user N whenever the ids collide — they appear in the subscriber list and get "new upload" notifications for an artist they never subscribed to. Reachable today with a single Event row; go-openaudio#469 (which legalizes cross-type coexistence) widens the exposure. Fixed by adding `entity_type = 'User'` to all three readers (trigger functions updated in `ddl/functions/`, which `pg_migrate.sh` re-applies on md5 change, with the schema dump edited to match). The Event-side readers were already correctly scoped since #977; this is the mirror image nobody did. **2. Unbreak main CI.** The events-followers fixture seeds a legacy User-type row sharing `(subscriber_id, user_id)` with a deleted-event row — illegal under the index #1011 seeded, so `database.Seed` panics. The legacy row moves to its own subscriber; both exclusion behaviors stay covered. Once go-openaudio#469 widens the index to include `entity_type`, the same-subscriber collision becomes legal again and is worth re-adding (noted in a comment). **2b. Same bug in `does_current_user_subscribe`.** The `current_user_subscribed_targets` CTE in `get_users.sql` also matched on `user_id` alone, so a viewer following event N showed as subscribed to user N on every user-list surface. Same `entity_type = 'User'` fix (sqlc regenerated). Note: this CTE filters only `is_delete` and not `is_current`, unlike the other readers — that predates the #892 refactor and is left as-is here. **3. Close the cache hole that let this merge green.** `setup-go` restores the go-build cache (including test results) keyed on `go.sum`, but the test schema lives in dockerized Postgres, invisible to Go's cache invalidation — so #1011's sql/-only commits rode a stale green while its own schema change broke a test. `-count=1` forces tests to actually run. ## Tests - `TestUsersSubscribers` now seeds an Event subscription whose event id collides with the artist's user id and asserts the follower is not listed (fails without the filter). - `TestUserQuery_DoesCurrentUserSubscribeIgnoresEventSubscriptions` seeds a colliding Event subscription and asserts the flag stays false (fails without the filter). - `TestEventsFollowers_ReturnsOnlyLiveEventSubscribers` passes again (was panicking on main). - Full suite green locally with `-count=1` against a freshly initialized schema volume. ## Not in this PR The `pkg/etl` pin bump + seeded-index update land separately once go-openaudio#469/#470 cut a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Allow audiusAppUrl to be overridden via env var (#747) ## Summary `Cfg.AudiusAppUrl` was hardcoded inside the per-env `switch` in `config.init()` for `dev`, `stage` and `prod`, so it could not be pointed at a non-default frontend without a code change. Developer apps running against a local override or preview deploy got the wrong `redirect_uri` base. This applies an `audiusAppUrl` env override **after** the switch, alongside the existing `archiverNodes` and `antiAbuseOracles` overrides that solve the same problem for values the switch also sets per-env: ```go // Override the Audius app base URL when set, so developer apps running against a // non-default frontend (local override, preview deploy) get the right redirect_uri base. if v := os.Getenv("audiusAppUrl"); v != "" { Cfg.AudiusAppUrl = strings.TrimSuffix(v, "/") } ``` Placing it after the switch means it covers every env and cannot be silently missed when a new env case is added — the earlier revision of this PR guarded `dev` and `prod` individually, and a `stage` default added to `main` in the meantime went unguarded. The trailing slash is trimmed because every consumer builds URLs as `base + "/..."` (`v1_oauth.go`, `v1_sitemaps.go`, `v1_users_sales_download.go`, `v1_users_purchases_download.go`), so a trailing slash would produce `//`. Rebased onto current `main` — the previous revision was 246 commits behind and conflicting. ## Test plan `go build`, `go vet` and `gofmt` are clean. A standard unit test can't cover this: `init()` runs at package load, before `t.Setenv` could take effect. Verified instead by running the real package across the env matrix: | ENV | `audiusAppUrl` | `Cfg.AudiusAppUrl` | |---|---|---| | dev | *(unset)* | `http://localhost:3000` | | dev | `http://custom.example` | `http://custom.example` | | stage | *(unset)* | `https://staging.audius.co` | | stage | `http://custom.example` | `http://custom.example` | | prod | *(unset)* | `https://audius.co` | | prod | `https://preview.audius.co` | `https://preview.audius.co` | | prod | `https://trailing.example/` | `https://trailing.example` | ## Note `Cfg.AudiusdURL` has the same latent bug — read from `os.Getenv("audiusdUrl")` in the `Cfg` initializer, then unconditionally overwritten in all three switch branches. Left alone as out of scope. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
deps: pin pkg/etl v1.6.4 and backfill users + save/repost types (#1011) Supersedes #1008, #1009 and #1010, which were the same work split three ways. ## Why one PR These three changes are only correct **together, in one deploy**. Split, each one alone breaks something: | merged alone | result | |---|---| | the bump | ETL `0035` creates `users_current_uniq_idx`, **fails on the existing duplicates**, `RunMigrations` errors and the indexer won't start | | `0236` (album) | the old indexer keeps deriving `album` from `is_album` and undoes the backfill | | `0237` (users) | harmless, but pointless without the index that stops it recurring | As one PR the deploy is atomic, and the ordering inside it is guaranteed by existing machinery: `bridge migrate` runs as a pre-roll Job that every serving Deployment `DependsOn` (serving pods get `runMigrations=false`), so both ddl migrations complete before the indexer starts and runs the ETL's. ## Contents **`0237_users_one_current_row_backfill`** — deletes 5 duplicate `is_current` rows from `users`. Small count, large blast radius: joins from an entity to its owner's wallet fan out, measured at **+18 tracks and +787 follows** on a production clone. Must precede the ETL index. **`0236_saves_reposts_album_to_playlist`** — 670 saves and 528 reposts written as `album` collapse to `playlist`. `on_save`/`on_repost` are disabled for the update (their notification `group_id` embeds the type, so a plain UPDATE would mint duplicate favourite notifications); `trg_saves`/`trg_reposts` stay enabled so the search indexer sees the change. **`deps: pin pkg/etl v1.6.4`** — brings OpenAudio/go-openaudio#428 (album type), #425 (the `users` invariant + genesis-writer join simplification) and #433 (`0035` no longer deletes anything). ## The delete moved out of the ETL migration `v1.6.3`'s `0035` deleted the duplicates itself. Since ETL migrations run automatically at indexer start, that made a `go get` able to remove rows from this database. #433 split it: the index stays in the ETL migration, the repair moved to `0237` here. **`v1.6.4` ships zero `DELETE` statements** — verified against the resolved module, not just the tag. The comment above the ETL config now records that line, and its corollary: an ETL migration can depend on a ddl one having run, and `0035` fails loudly if `0237` hasn't. ## Verified - Resolved module `pkg/etl@v1.6.4` contains `0035` with `CREATE UNIQUE INDEX` and **0** `DELETE` statements. - Both migration orders against fixtures: backfill→index applies cleanly (`violations 0`, `indisvalid = t`); index→backfill fails with `could not create unique index … Key (user_id)=(98311147) is duplicated`, which is the intended signal that `0237` hasn't run. - Both migrations idempotent; re-running is a no-op. - `0236` fires **zero** `on_save`/`on_repost` triggers against a fixture with the real wiring, and exactly one `pg_notify` per updated row. - `go build ./...` and `go vet ./indexer/` clean. - No FK references `users`; its triggers are INSERT / INSERT OR UPDATE, so the delete fires neither. - Cutting `pkg/etl/v1.6.4` did not move `openaudio/go-openaudio:stable` — still the 2026-07-30 `v1.8.2` digest, so no node-operator rollout. ## Not established The cause of the duplicate `users` rows. Both indexer create paths reject an existing user, so a single writer can't produce them; a second writer can, since check-then-act isn't atomic across transactions. Three of five pairs put a bare-hex `txhash` next to a `0x`-prefixed one, which fits but doesn't prove it. The index will surface it if it recurs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fix: add NOT EXISTS guard to stop notification_id_seq burn on repeate… …d job runs (#1005) ## Problem The `notification_id_seq` column hit INT max (2³¹−1) on July 29 because all four sub-steps in `RemixContestNotificationsJob` re-scan the same events on every 30-second tick and attempt duplicate INSERTs. Postgres allocates a sequence ID **before** checking `ON CONFLICT DO NOTHING`, so every wasted attempt permanently burns an ID: | Step | Window | Re-scans/event/day | Burn multiplier | |---|---|---|---| | `fan_remix_contest_ending_soon` | 72 h | 8,640 | × N fan recipients | | `artist_remix_contest_ending_soon` | 48 h | 5,760 | × 1 (host only) | | `fan_remix_contest_ended` | 24 h | 2,880 | × N fan recipients | | `artist_remix_contest_ended` | 24 h | 2,880 | × 1 (host only) | **July 29 spike:** The Summer Cypher contest (1.97 M followers) entered the 72-hour ending-soon window. Each 30-second tick attempted 1.97 M INSERTs. After ~543 ticks (~4.5 h), ~1.07 B sequence IDs were burned — exhausting INT range and taking down notifications for all users. **Chronic baseline (~18 M IDs/day burned):** Smaller active contests (combined fan audiences on the order of a few thousand) still burned millions of IDs daily through the same mechanism. ## Fix Add an event-level `NOT EXISTS` guard to each of the four SQL blocks: ```sql AND NOT EXISTS ( SELECT 1 FROM notification WHERE group_id = 'fan_remix_contest_ending_soon:' || e.event_id::text ) ``` Once any notification row exists for an event (i.e., after the first successful fan-out pass), subsequent job runs skip that event entirely. The per-recipient `ON CONFLICT DO NOTHING` is kept as a safety net for partial-failure retries. **Effect:** Each contest triggers at most one fan-out pass instead of thousands. Sequence consumption drops from O(recipients × job_runs_in_window) to O(recipients × 1) per event. ## What this does NOT change - No user-visible notification is dropped. The guard only prevents re-attempting INSERTs that would conflict anyway. - The bigint migration already deployed by Ray stays in place as the unblock. This PR stops the source of future exhaustion. ## Testing Existing `TestRemixContest_Ended` already asserts idempotency (runs the job twice and verifies the count stays the same). `fanEndingSoon` idempotency is implicitly covered: after the first run the `NOT EXISTS` check short-circuits, so no inserts are attempted on re-runs.
feat(jobs): cut coin stats over to on-chain data and remove Birdeye (#… …1007) ## What Phase-2 cutover of the coin-stats shadow rollout: retires the Birdeye-backed `CoinStatsJob`, makes the on-chain job the source of truth, and **removes Birdeye from the service entirely**. Follows #1006 (AUDIO anchor on-chain), which was the last Birdeye call outside `CoinStatsJob`. ## Mechanism (why consumers don't change) `CoinStatsOnchainJob` already read the AUDIO anchor from `artist_coin_stats` and iterated `artist_coins`. The cutover just **repoints its upsert from `artist_coin_stats_onchain` → `artist_coin_stats`**. Every consumer — the `artist_coin_prices` view, `v1_coins`, `v1_coin_insights`, `get_users`, `coin_dbc` — keeps reading `artist_coin_stats` unchanged. (Renaming tables was rejected: Postgres views bind to table OID, so a rename would leave the view pointing at the old data.) The AUDIO row bootstrap holds: AUDIO is one of the 57 `artist_coins`, so the on-chain job upserts its row (price kept via `COALESCE`, never clobbered), and `AudioPriceJob` maintains its price from the pool. ## Birdeye removal - Delete the `birdeye/` client package, the **dead** server `BirdeyeClient` interface + field + unused `mock_birdeye_client.go`, and the `BirdeyeToken` config field. - Delete `jobs/coin_stats.go` and its indexer scheduling. - Drop the now-unused `artist_coin_stats_onchain` shadow table and `artist_coin_stats_comparison` view (**migration 0235**). - De-Birdeye the `artist_coin_prices` view comment, swagger `coin_insights` description, and stale test/model comments. (Applied migrations 0229–0231 keep their historical comments.) ## Behavior — market-cap change (verified against live prod data) Rendered stats — price, market cap, liquidity, holder, total supply, 24h change — are all on-chain-derived. The ~40 unrendered Birdeye-only columns remain in the table (NULL on new rows; consumers `COALESCE(...,0)`). Market cap moves **−8.5% in aggregate ($4.64M → $4.25M)**, and it is a **stale-price** effect on dormant coins — **not** market aggregation and **not** a supply basis. Evidence from the live `/v1/coins` payload: - **No missing markets.** Birdeye's own `numberMarkets` = 1 for **53 of 57** coins. Only AUDIO (12) and KITT/UWU/JAY (2) have more, and those match within ~2%. - **Not supply.** `totalSupply == circulatingSupply` for every coin. - **Stale last-trade price.** The 34 coins whose Birdeye market cap exceeds on-chain by >1.3× have **0 trades in 24h and average 87 days since their last trade**; Birdeye holds their last-trade quote while the on-chain value tracks the current pool. Coins that actually trade agree within **0.3%** (AUDIO within 0.2%; corroborated by DexScreener, which reads the live pool). - **Concentration.** 100% of the aggregate drop is dormant coins; the active-coin bucket is −0.3%. Every liquid coin a user would look at is effectively unchanged — dormant coins simply reprice from a months-old quote to their current pool price. ## Schema dump `sql/01_schema.sql` hand-edited to remove only the shadow table + comparison view (the committed dump was stale on unrelated merged migrations; a full regen would have pulled in `new_chain_queue` etc.). Diff is 1 comment reword + 113 deletions. Verified by reloading the dump into a fresh DB. ## Test `go build ./...`, `go vet ./...`, `go test ./jobs/` (coin-stats + audio-price) and `go test ./api/` (Coin/Wallet/Users) all pass against the regenerated DB. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(jobs): price AUDIO from the on-chain AUDIO/USDC pool, not Birdeye ( #1006) ## What Rewrites `AudioPriceJob` to derive the AUDIO USD anchor from the **Meteora DAMM v2 AUDIO/USDC pool** instead of Birdeye's `/defi/price`. It reads the pool account over RPC, decodes it with the existing `meteora_damm_v2` decoder, and computes AUDIO/USD via the **same `price_from_sqrt_price` SQL function** the `artist_coin_prices` view uses. ## Why `artist_coin_stats.price` for the AUDIO mint is the anchor every coin's USD price is derived from (`coin/AUDIO × AUDIO/USD`). It was the last Birdeye call in the pricing path outside `CoinStatsJob`. Sourcing it on-chain removes that dependency and is independent of the coin-stats flip — safe to ship first. ## Verification (on-chain) - Pool `Ha6tnG7LrhsTyw4tyarQ59HxAKqpdbEc2yQZp9mrDM4h` decoded live: `tokenA = AUDIO`, `tokenB = USDC`, so `price_from_sqrt_price(sqrt, 8, 6)` is the direct AUDIO/USD (no inversion). - The decode lands on **$0.012142**, matching DexScreener/Birdeye ($0.01214). The test asserts this against the real mainnet `sqrt_price`. ## Notes - **Env-gated**: pool lives in `SolanaConfig` — mainnet pool on prod/stage; **dev has none, so the job no-ops** (`poolAddr.IsZero()`). - **Nothing downstream changes**: same anchor slot (`artist_coin_stats.price` for AUDIO), so the view, `coin_dbc.go`, and `get_users.sql` are untouched. The anchor value barely moves (~0.2% vs Birdeye). - USDC pinned at $1 (negligible depeg risk). - Combined with the eventual coin-stats flip (retiring `CoinStatsJob`), this leaves **Birdeye fully removed** from the pricing path. ## Test New `audio_price_test.go`: injects the real pool `sqrt_price` via a fake fetcher and asserts the stored price ≈ $0.012142; plus a dev/no-pool no-op case. `go test ./jobs/`, build, and vet pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(jobs): on-chain holder count should match Birdeye (token accounts… …, not distinct owners) (#1000) ## What `CoinStatsOnchainJob` computed the on-chain `holder` metric as `COUNT(DISTINCT owner)`. That **undercounts real holders**: the Audius claimable-tokens program authority owns one token account *per user* (the user-bank mechanism), so many distinct users collapse into a single program owner. Switch to `COUNT(*)` (token accounts with balance > 0) to match Birdeye's metric and reflect real distinct-user holdings. ## How it was found Validated against a public Solana RPC (`getProgramAccounts` on the token program), cross-referenced with the DB: - **MZ**: 24 token accounts, 16 non-zero across only 5 distinct owners — one wallet holds 11. - **MONIST**: 175 token accounts, **145 non-zero** (= Birdeye's holder count exactly) across only **7 distinct owners** — one program authority holds **139** of them. So `COUNT(DISTINCT owner)` returned 5 / 7 where the real (and Birdeye) count is 16 / 145. The DB had full coverage in both cases; the bug was purely the aggregation. ## Test Updated `coin_stats_onchain_test.go`: two token accounts now share one owner, and the assertion expects the token-account count (4), not the distinct-owner count (3). `go test ./jobs/` passes; build clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
PreviousNext