Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config/solana_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
101 changes: 81 additions & 20 deletions jobs/audio_price.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,80 @@
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
}

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,
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
68 changes: 68 additions & 0 deletions jobs/audio_price_test.go
Original file line number Diff line number Diff line change
@@ -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()))
}
Loading