From d28938b97235b6f7a9d5cbea88ab96785c8feacf Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:07:14 -0700 Subject: [PATCH] feat(jobs): price AUDIO from the on-chain AUDIO/USDC pool, not Birdeye MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites AudioPriceJob to derive the AUDIO USD anchor (artist_coin_stats.price for the AUDIO mint) from the Meteora DAMM v2 AUDIO/USDC pool instead of Birdeye's /defi/price. It reads the pool account over RPC, decodes it, and computes AUDIO/USD via the same price_from_sqrt_price SQL function the artist_coin_prices view uses (base=8 AUDIO, quote=6 USDC, USDC ≈ $1). The pool (Ha6tnG7...) and its token ordering (tokenA=AUDIO, tokenB=USDC) were verified on-chain: the decode lands on $0.012142, matching DexScreener/Birdeye. Pool address is env-gated in SolanaConfig (prod/stage = mainnet pool; dev has none, so the job no-ops). Nothing downstream changes — the anchor slot is the same; it's just sourced on-chain now. Removes AudioPriceJob's Birdeye dependency. Co-Authored-By: Claude Opus 4.8 --- config/solana_config.go | 11 +++++ jobs/audio_price.go | 101 +++++++++++++++++++++++++++++++-------- jobs/audio_price_test.go | 68 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 jobs/audio_price_test.go diff --git a/config/solana_config.go b/config/solana_config.go index 97f380f6..9a87aa17 100644 --- a/config/solana_config.go +++ b/config/solana_config.go @@ -21,6 +21,10 @@ type SolanaConfig struct { MintAudio solana.PublicKey MintUSDC solana.PublicKey + // AudioUsdcPool is the Meteora DAMM v2 AUDIO/USDC pool used to price AUDIO + // on-chain (the USD anchor). Zero on dev, where AUDIO has no such pool. + AudioUsdcPool solana.PublicKey + RewardManagerProgramID solana.PublicKey RewardManagerState solana.PublicKey RewardManagerLookupTable solana.PublicKey @@ -61,6 +65,11 @@ const ( ProdSolanaRelay = "https://discoveryprovider.audius.co/solana/relay" ProdMintAudio = "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM" ProdMintUSDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + + // Meteora DAMM v2 AUDIO/USDC pool (mainnet). Stage uses the same mainnet + // mints/pool as prod; dev has no equivalent. + ProdAudioUsdcPool = "Ha6tnG7LrhsTyw4tyarQ59HxAKqpdbEc2yQZp9mrDM4h" + StageAudioUsdcPool = "Ha6tnG7LrhsTyw4tyarQ59HxAKqpdbEc2yQZp9mrDM4h" ProdRewardManagerProgramID = "DDZDcYdQFEMwcu2Mwo75yGFjJ1mUQyyXLWzhZLEVFcei" ProdRewardManagerState = "71hWFVYokLaN1PNYzTAWi13EfJ7Xt9VbSWUKsXUT8mxE" ProdRewardManagerLookupTable = "4UQwpGupH66RgQrWRqmPM9Two6VJEE68VZ7GeqZ3mvVv" @@ -117,6 +126,7 @@ func NewSolanaConfig() SolanaConfig { cfg.SolanaRelay = StageSolanaRelay cfg.MintAudio = solana.MustPublicKeyFromBase58(StageMintAudio) cfg.MintUSDC = solana.MustPublicKeyFromBase58(StageMintUSDC) + cfg.AudioUsdcPool = solana.MustPublicKeyFromBase58(StageAudioUsdcPool) cfg.RewardManagerProgramID = solana.MustPublicKeyFromBase58(StageRewardManagerProgramID) cfg.RewardManagerState = solana.MustPublicKeyFromBase58(StageRewardManagerState) cfg.RewardManagerLookupTable = solana.MustPublicKeyFromBase58(StageRewardManagerLookupTable) @@ -129,6 +139,7 @@ func NewSolanaConfig() SolanaConfig { cfg.SolanaRelay = ProdSolanaRelay cfg.MintAudio = solana.MustPublicKeyFromBase58(ProdMintAudio) cfg.MintUSDC = solana.MustPublicKeyFromBase58(ProdMintUSDC) + cfg.AudioUsdcPool = solana.MustPublicKeyFromBase58(ProdAudioUsdcPool) cfg.RewardManagerProgramID = solana.MustPublicKeyFromBase58(ProdRewardManagerProgramID) cfg.RewardManagerState = solana.MustPublicKeyFromBase58(ProdRewardManagerState) cfg.RewardManagerLookupTable = solana.MustPublicKeyFromBase58(ProdRewardManagerLookupTable) diff --git a/jobs/audio_price.go b/jobs/audio_price.go index 59f05235..28248523 100644 --- a/jobs/audio_price.go +++ b/jobs/audio_price.go @@ -1,22 +1,60 @@ package jobs import ( + "bytes" "context" "fmt" "sync" "time" - "api.audius.co/birdeye" "api.audius.co/config" "api.audius.co/database" "api.audius.co/logging" + "api.audius.co/solana/spl/programs/meteora_damm_v2" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" "go.uber.org/zap" ) +// dammV2PoolFetcher reads and decodes a Meteora DAMM v2 pool account. +// Abstracted so tests can inject a fake without a live RPC. +type dammV2PoolFetcher interface { + GetPool(ctx context.Context, addr solana.PublicKey) (*meteora_damm_v2.Pool, error) +} + +type rpcDammV2Fetcher struct{ rpc *rpc.Client } + +func (f rpcDammV2Fetcher) GetPool(ctx context.Context, addr solana.PublicKey) (*meteora_damm_v2.Pool, error) { + res, err := f.rpc.GetAccountInfo(ctx, addr) + if err != nil { + return nil, err + } + if res == nil || res.Value == nil || res.Value.Data == nil { + return nil, fmt.Errorf("pool account %s not found", addr) + } + data := res.Value.Data.GetBinary() + if len(data) < 8 || !bytes.Equal(data[:8], meteora_damm_v2.POOL_DISCRIMINATOR) { + return nil, fmt.Errorf("account %s is not a DAMM v2 pool", addr) + } + var pool meteora_damm_v2.Pool + if err := bin.NewBorshDecoder(data).Decode(&pool); err != nil { + return nil, fmt.Errorf("failed to decode DAMM v2 pool %s: %w", addr, err) + } + return &pool, nil +} + +// AudioPriceJob maintains the AUDIO USD anchor (artist_coin_stats.price for the +// AUDIO mint) that the artist_coin_prices view uses to convert every coin's +// AUDIO-denominated pool price to USD. It reads the on-chain Meteora DAMM v2 +// AUDIO/USDC pool rather than Birdeye. type AudioPriceJob struct { - birdeyeClient *birdeye.Client - pool database.DbPool - logger *zap.Logger + pool database.DbPool + fetcher dammV2PoolFetcher + audioMint string + usdcMint string + poolAddr solana.PublicKey + logger *zap.Logger mutex sync.Mutex isRunning bool @@ -24,12 +62,19 @@ type AudioPriceJob struct { func NewAudioPriceJob(config config.Config, pool database.DbPool) *AudioPriceJob { logger := logging.NewZapLogger(config).Named("AudioPriceJob") - birdeyeClient := birdeye.New(config.BirdeyeToken) + + var fetcher dammV2PoolFetcher + if len(config.SolanaConfig.RpcProviders) > 0 { + fetcher = rpcDammV2Fetcher{rpc: rpc.New(config.SolanaConfig.RpcProviders[0])} + } return &AudioPriceJob{ - birdeyeClient: birdeyeClient, - logger: logger, - pool: pool, + pool: pool, + fetcher: fetcher, + audioMint: config.SolanaConfig.MintAudio.String(), + usdcMint: config.SolanaConfig.MintUSDC.String(), + poolAddr: config.SolanaConfig.AudioUsdcPool, + logger: logger, } } @@ -61,8 +106,8 @@ func (j *AudioPriceJob) Run(ctx context.Context) { } } -// Gets the price for AUDIO from Birdeye and updates artist_coin_stats table. -// Ensures only one instance runs at a time. +// Reads the AUDIO/USDC DAMM v2 pool on-chain, derives AUDIO's USD price, and +// updates artist_coin_stats. Ensures only one instance runs at a time. func (j *AudioPriceJob) run(ctx context.Context) error { j.mutex.Lock() if j.isRunning { @@ -77,25 +122,41 @@ func (j *AudioPriceJob) run(ctx context.Context) error { j.mutex.Unlock() }() - audioMint := "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM" // AUDIO mint - priceData, err := j.birdeyeClient.GetPrice(ctx, audioMint) + // No AUDIO/USDC pool on dev — nothing to anchor from. + if j.poolAddr.IsZero() { + j.logger.Debug("no AUDIO/USDC pool configured; skipping AUDIO price update") + return nil + } + if j.fetcher == nil { + return fmt.Errorf("no RPC client configured") + } + + poolState, err := j.fetcher.GetPool(ctx, j.poolAddr) if err != nil { - return fmt.Errorf("failed to get prices: %w", err) + return fmt.Errorf("failed to fetch AUDIO/USDC pool: %w", err) } + // Guard the orientation this job assumes: token A = AUDIO, token B = USDC, so + // price_from_sqrt_price returns token-B-per-token-A = USDC-per-AUDIO = AUDIO/USD. + if poolState.TokenAMint.String() != j.audioMint || poolState.TokenBMint.String() != j.usdcMint { + return fmt.Errorf("unexpected AUDIO/USDC pool ordering: tokenA=%s tokenB=%s", + poolState.TokenAMint, poolState.TokenBMint) + } + + sqrtPrice := poolState.SqrtPrice.BigInt() + + // AUDIO USD = price_from_sqrt_price(sqrt, 8 [AUDIO decimals], 6 [USDC decimals]) + // (USDC ≈ $1). Reuses the same SQL function the artist_coin_prices view uses. _, err = j.pool.Exec(ctx, ` UPDATE artist_coin_stats - SET price = $1, + SET price = price_from_sqrt_price($1::numeric, 8, 6), updated_at = NOW() WHERE mint = $2 - `, priceData.Value, audioMint) + `, sqrtPrice.String(), j.audioMint) if err != nil { - return fmt.Errorf("failed to update artist coin prices: %w", err) + return fmt.Errorf("failed to update AUDIO price: %w", err) } - j.logger.Debug("Updated AUDIO price", - zap.Float64("price", priceData.Value), - ) - + j.logger.Debug("Updated AUDIO price on-chain", zap.String("pool", j.poolAddr.String())) return nil } diff --git a/jobs/audio_price_test.go b/jobs/audio_price_test.go new file mode 100644 index 00000000..f0aba251 --- /dev/null +++ b/jobs/audio_price_test.go @@ -0,0 +1,68 @@ +package jobs + +import ( + "context" + "testing" + + "api.audius.co/database" + "api.audius.co/solana/spl/programs/meteora_damm_v2" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type fakeDammV2Fetcher struct{ pool *meteora_damm_v2.Pool } + +func (f fakeDammV2Fetcher) GetPool(_ context.Context, _ solana.PublicKey) (*meteora_damm_v2.Pool, error) { + return f.pool, nil +} + +func TestAudioPriceJobOnchain(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_jobs") + defer pool.Close() + + ctx := context.Background() + cfg := newTestConfig() + audioMint := cfg.SolanaConfig.MintAudio.String() + usdcMint := cfg.SolanaConfig.MintUSDC.String() + + // AUDIO stats row must exist (the job UPDATEs it). Start from a stale price. + database.Seed(pool, database.FixtureMap{ + "artist_coin_stats": {{"mint": audioMint, "price": 999.0}}, + }) + + // Real sqrt_price from the mainnet Meteora DAMM v2 AUDIO/USDC pool + // (Ha6tnG7...), which decodes to AUDIO ≈ $0.012142 at 8/6 decimals. + poolState := &meteora_damm_v2.Pool{ + TokenAMint: solana.MustPublicKeyFromBase58(audioMint), + TokenBMint: solana.MustPublicKeyFromBase58(usdcMint), + SqrtPrice: bin.Uint128{Lo: 203268658239169394}, + } + + job := &AudioPriceJob{ + pool: pool, + fetcher: fakeDammV2Fetcher{pool: poolState}, + audioMint: audioMint, + usdcMint: usdcMint, + poolAddr: solana.MustPublicKeyFromBase58("Ha6tnG7LrhsTyw4tyarQ59HxAKqpdbEc2yQZp9mrDM4h"), + logger: zap.NewNop(), + } + + require.NoError(t, job.run(ctx)) + + var price float64 + require.NoError(t, pool.QueryRow(ctx, + `SELECT price FROM artist_coin_stats WHERE mint = $1`, audioMint).Scan(&price)) + assert.InDelta(t, 0.012142, price, 1e-5, "AUDIO/USD from pool sqrt_price via price_from_sqrt_price(_, 8, 6)") +} + +func TestAudioPriceJobSkipsWithoutPool(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_jobs") + defer pool.Close() + + // Zero pool address (as on dev) -> job no-ops without touching the DB. + job := &AudioPriceJob{pool: pool, logger: zap.NewNop()} + require.NoError(t, job.run(context.Background())) +}