Skip to content

Tags: AudiusProject/api

Tags

a2fa789

Toggle a2fa789's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

e3d1ecc

Toggle e3d1ecc's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

1255e2d

Toggle 1255e2d's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

0a446c0

Toggle 0a446c0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

65242c9

Toggle 65242c9's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

997c44c

Toggle 997c44c's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

4c2ae3a

Toggle 4c2ae3a's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

159391c

Toggle 159391c's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

5049a72

Toggle 5049a72's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

845b056

Toggle 845b056's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>