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
6 changes: 6 additions & 0 deletions api/v1_users_purchases_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func TestV1UsersPurchasesDownload(t *testing.T) {
"collision_id": 0,
},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": 1, "spl_usdc_payout_wallet": user1Wallet, "block_timestamp": time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{
"signature": "def",
Expand Down Expand Up @@ -145,6 +148,9 @@ func TestV1UsersPurchasesDownloadWithGrantee(t *testing.T) {
"collision_id": 0,
},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": 1, "spl_usdc_payout_wallet": user1Wallet, "block_timestamp": time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{
"signature": "def",
Expand Down
4 changes: 4 additions & 0 deletions api/v1_users_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func TestV1UsersPurchases(t *testing.T) {
"track_price_history": []map[string]any{
{"track_id": 1, "total_price_cents": 0, "block_timestamp": time.Date(2024, 6, 2, 23, 0, 0, 0, time.UTC), "splits": "[]"},
},
// Seller user 3 had "something" set as their payout wallet before purchase "def".
"user_payout_wallet_history": []map[string]any{
{"user_id": 3, "spl_usdc_payout_wallet": "something", "block_timestamp": time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{
"signature": "gfsgf",
Expand Down
6 changes: 6 additions & 0 deletions api/v1_users_sales_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func TestV1UsersSalesDownload(t *testing.T) {
"collision_id": 0,
},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": 1, "spl_usdc_payout_wallet": user1Wallet, "block_timestamp": time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{
"signature": "def",
Expand Down Expand Up @@ -175,6 +178,9 @@ func TestV1UsersSalesDownloadWithGrantee(t *testing.T) {
"collision_id": 0,
},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": 1, "spl_usdc_payout_wallet": user1Wallet, "block_timestamp": time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{
"signature": "def",
Expand Down
4 changes: 4 additions & 0 deletions api/v1_users_sales_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func TestV1UsersSales(t *testing.T) {
// Drives extra_amount = 1000000 for signature "abc" (amount=1000000, base_price=0)
{"track_id": 1, "total_price_cents": 0, "block_timestamp": time.Date(2024, 6, 2, 23, 0, 0, 0, time.UTC), "splits": "[]"},
},
// Seller user 1 had "something" set as their payout wallet before purchase "def".
"user_payout_wallet_history": []map[string]any{
{"user_id": 1, "spl_usdc_payout_wallet": "something", "block_timestamp": time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{"signature": "gfsgf", "instruction_index": 0, "buyer_user_id": 5, "amount": 2000000, "content_type": "playlist", "content_id": 1, "created_at": time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), "is_valid": true},
{"signature": "faddf", "instruction_index": 0, "buyer_user_id": 5, "amount": 2000000, "content_type": "album", "content_id": 2, "created_at": time.Date(2024, 6, 1, 0, 1, 0, 0, time.UTC), "is_valid": true},
Expand Down
207 changes: 207 additions & 0 deletions api/v_usdc_purchases_splits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package api

import (
"testing"
"time"

"api.audius.co/database"
"api.audius.co/trashid"
"github.com/stretchr/testify/assert"
)

// These tests pin down the v_usdc_purchases splits[*].user_id resolution
// rules, which are easy to get wrong because they need to look at the seller's
// payout wallet *at the time of purchase*, not the current one. The route's
// regression history: the first cut joined users.spl_usdc_payout_wallet
// directly, which silently broke for any seller who ever changed payout
// wallets after a sale.

const buyerWallet = "0x7d273271690538cf855e5b3002a0dd8c154bb060"

// Scenario: seller changes payout wallet AFTER a purchase.
// sol_payments.to_account is the on-chain destination (immutable, equal to
// the seller's payout wallet at the time of purchase). users.spl_usdc_payout_wallet
// now points at the new wallet. The view must still resolve the historical
// split to the seller via user_payout_wallet_history.
func TestVUsdcPurchasesSplits_PayoutWalletChangedAfterPurchase(t *testing.T) {
app := emptyTestApp(t)
buyerID := 1
sellerID := 2

purchaseTime := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
walletAtPurchase := "SellerWalletAtPurchase"
walletAfterChange := "SellerWalletAfterChange"

fixtures := database.FixtureMap{
"blocks": []map[string]any{
{"blockhash": "block_initial", "parenthash": "0", "number": 200},
{"blockhash": "block_change", "parenthash": "block_initial", "number": 300},
},
"users": []map[string]any{
{"user_id": buyerID, "handle": "buyer", "wallet": buyerWallet},
// users.spl_usdc_payout_wallet is the *current* (post-change) wallet.
{"user_id": sellerID, "handle": "seller", "wallet": "0xseller", "spl_usdc_payout_wallet": walletAfterChange},
},
"tracks": []map[string]any{
{"track_id": 1, "title": "song", "owner_id": sellerID},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": sellerID, "spl_usdc_payout_wallet": walletAtPurchase, "blocknumber": 200, "block_timestamp": purchaseTime.AddDate(0, -1, 0)},
// Wallet change happened a month AFTER the purchase, at a different block.
{"user_id": sellerID, "spl_usdc_payout_wallet": walletAfterChange, "blocknumber": 300, "block_timestamp": purchaseTime.AddDate(0, 1, 0)},
},
"sol_purchases": []map[string]any{
{"signature": "sig1", "instruction_index": 0, "buyer_user_id": buyerID, "amount": 1000000, "content_type": "track", "content_id": 1, "created_at": purchaseTime, "is_valid": true},
},
"sol_payments": []map[string]any{
// Payment landed at the seller's then-current payout wallet.
{"signature": "sig1", "instruction_index": 0, "route_index": 0, "to_account": walletAtPurchase, "amount": 1000000, "slot": 101},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

status, body := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(buyerID)+"/purchases", buyerWallet)
assert.Equal(t, 200, status)
jsonAssert(t, body, map[string]any{
"data.#": 1,
"data.0.splits.0.user_id": sellerID,
"data.0.splits.0.payout_wallet": walletAtPurchase,
})
}

// Scenario: seller has three historical payout wallets. The view should pick
// the one that was current at purchase time, not the most recent.
func TestVUsdcPurchasesSplits_PicksHistoricalWalletAtPurchaseTime(t *testing.T) {
app := emptyTestApp(t)
buyerID := 1
sellerID := 2

t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
t1 := time.Date(2024, 4, 1, 0, 0, 0, 0, time.UTC)
t2 := time.Date(2024, 7, 1, 0, 0, 0, 0, time.UTC)
purchaseTime := time.Date(2024, 5, 15, 0, 0, 0, 0, time.UTC) // between t1 and t2

fixtures := database.FixtureMap{
"blocks": []map[string]any{
{"blockhash": "block_a", "parenthash": "0", "number": 200},
{"blockhash": "block_b", "parenthash": "block_a", "number": 300},
{"blockhash": "block_c", "parenthash": "block_b", "number": 400},
},
"users": []map[string]any{
{"user_id": buyerID, "handle": "buyer", "wallet": buyerWallet},
{"user_id": sellerID, "handle": "seller", "wallet": "0xseller", "spl_usdc_payout_wallet": "WalletC"},
},
"tracks": []map[string]any{
{"track_id": 1, "title": "song", "owner_id": sellerID},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": sellerID, "spl_usdc_payout_wallet": "WalletA", "blocknumber": 200, "block_timestamp": t0},
{"user_id": sellerID, "spl_usdc_payout_wallet": "WalletB", "blocknumber": 300, "block_timestamp": t1},
{"user_id": sellerID, "spl_usdc_payout_wallet": "WalletC", "blocknumber": 400, "block_timestamp": t2},
},
"sol_purchases": []map[string]any{
{"signature": "sig1", "instruction_index": 0, "buyer_user_id": buyerID, "amount": 1000000, "content_type": "track", "content_id": 1, "created_at": purchaseTime, "is_valid": true},
},
"sol_payments": []map[string]any{
// Payment landed at the wallet that was current at purchase time (WalletB).
{"signature": "sig1", "instruction_index": 0, "route_index": 0, "to_account": "WalletB", "amount": 1000000, "slot": 101},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

status, body := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(buyerID)+"/purchases", buyerWallet)
assert.Equal(t, 200, status)
jsonAssert(t, body, map[string]any{
"data.0.splits.0.user_id": sellerID,
"data.0.splits.0.payout_wallet": "WalletB",
})
}

// Scenario: seller never set a custom payout wallet (no user_payout_wallet_history
// rows). The payment lands at their USDC user-bank PDA. The view falls back to
// sol_claimable_accounts -> users.wallet for resolution.
func TestVUsdcPurchasesSplits_FallsBackToUserBankWhenNoPayoutHistory(t *testing.T) {
app := emptyTestApp(t)
buyerID := 1
sellerID := 2
sellerEthWallet := "0xseller_eth_wallet"
sellerUserBank := "SellerUSDCUserBankPDA"

fixtures := database.FixtureMap{
"users": []map[string]any{
{"user_id": buyerID, "handle": "buyer", "wallet": buyerWallet},
// No spl_usdc_payout_wallet set; no user_payout_wallet_history rows.
{"user_id": sellerID, "handle": "seller", "wallet": sellerEthWallet},
},
"tracks": []map[string]any{
{"track_id": 1, "title": "song", "owner_id": sellerID},
},
"sol_claimable_accounts": []map[string]any{
{
"signature": "create_sig",
"instruction_index": 0,
"slot": 1,
"mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"ethereum_address": sellerEthWallet,
"account": sellerUserBank,
},
},
"sol_purchases": []map[string]any{
{"signature": "sig1", "instruction_index": 0, "buyer_user_id": buyerID, "amount": 1000000, "content_type": "track", "content_id": 1, "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "is_valid": true},
},
"sol_payments": []map[string]any{
{"signature": "sig1", "instruction_index": 0, "route_index": 0, "to_account": sellerUserBank, "amount": 1000000, "slot": 101},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

status, body := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(buyerID)+"/purchases", buyerWallet)
assert.Equal(t, 200, status)
jsonAssert(t, body, map[string]any{
"data.0.splits.0.user_id": sellerID,
"data.0.splits.0.payout_wallet": sellerUserBank,
})
}

// Scenario: a split goes to a wallet that doesn't map to any user (the network
// share / staking bridge). splits[*].user_id should be null, not an arbitrary
// match from current users.spl_usdc_payout_wallet.
func TestVUsdcPurchasesSplits_NetworkShareResolvesToNull(t *testing.T) {
app := emptyTestApp(t)
buyerID := 1
sellerID := 2

fixtures := database.FixtureMap{
"users": []map[string]any{
{"user_id": buyerID, "handle": "buyer", "wallet": buyerWallet},
{"user_id": sellerID, "handle": "seller", "wallet": "0xseller", "spl_usdc_payout_wallet": "SellerPayout"},
},
"tracks": []map[string]any{
{"track_id": 1, "title": "song", "owner_id": sellerID},
},
"blocks": []map[string]any{
{"blockhash": "block_payout_set", "parenthash": "0", "number": 200},
},
"user_payout_wallet_history": []map[string]any{
{"user_id": sellerID, "spl_usdc_payout_wallet": "SellerPayout", "blocknumber": 200, "block_timestamp": time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
},
"sol_purchases": []map[string]any{
{"signature": "sig1", "instruction_index": 0, "buyer_user_id": buyerID, "amount": 1000000, "content_type": "track", "content_id": 1, "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "is_valid": true},
},
"sol_payments": []map[string]any{
{"signature": "sig1", "instruction_index": 0, "route_index": 0, "to_account": "SellerPayout", "amount": 900000, "slot": 101},
// 10% goes to an unowned/network wallet.
{"signature": "sig1", "instruction_index": 0, "route_index": 1, "to_account": "StakingBridgeWallet", "amount": 100000, "slot": 101},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

status, body := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(buyerID)+"/purchases", buyerWallet)
assert.Equal(t, 200, status)
jsonAssert(t, body, map[string]any{
"data.0.splits.0.user_id": sellerID,
"data.0.splits.0.payout_wallet": "SellerPayout",
"data.0.splits.1.user_id": nil,
"data.0.splits.1.payout_wallet": "StakingBridgeWallet",
})
}
8 changes: 7 additions & 1 deletion database/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,12 @@ var (
"user_id": nil,
"pubkey_base64": nil,
},
"blocks": {
"blockhash": nil,
"parenthash": nil,
"is_current": false,
"number": nil,
},
"reward_codes": {
"code": nil,
"mint": nil,
Expand Down Expand Up @@ -802,7 +808,7 @@ func Seed(pool *pgxpool.Pool, fixtures FixtureMap) {
// explicitly do the "entity" tables first
// so that data dependencies exist before attempting to do saves, follows, etc.
// (also do aggregates first so we can override the ones the entities autocreate)
entityTables := []string{"aggregate_user", "aggregate_track", "aggregate_playlist", "users", "tracks", "playlists", "sol_token_account_balances", "chat", "chat_member", "chat_message", "chat_blast", "sol_user_balances", "chat_blocked_users", "chat_permissions"}
entityTables := []string{"blocks", "aggregate_user", "aggregate_track", "aggregate_playlist", "users", "tracks", "playlists", "sol_token_account_balances", "chat", "chat_member", "chat_message", "chat_blast", "sol_user_balances", "chat_blocked_users", "chat_permissions"}
for _, tableName := range entityTables {
if rows, ok := fixtures[tableName]; ok {
SeedTable(pool, tableName, rows)
Expand Down
12 changes: 12 additions & 0 deletions ddl/migrations/0200_user_payout_wallet_history_wallet_idx.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
BEGIN;

-- v_usdc_purchases looks up "which user owned this Solana payout wallet at
-- purchase time" by joining user_payout_wallet_history. The PK is
-- (user_id, block_timestamp), which is the wrong direction for that lookup
-- (we know the wallet, want the user). Add a covering index on
-- (spl_usdc_payout_wallet, block_timestamp) so the LATERAL subquery can
-- index-scan + bounded backward to find the row applicable at sp.created_at.
CREATE INDEX IF NOT EXISTS user_payout_wallet_history_wallet_idx
ON user_payout_wallet_history (spl_usdc_payout_wallet, block_timestamp);

COMMIT;
18 changes: 15 additions & 3 deletions ddl/views/v_usdc_purchases.sql
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,21 @@ SELECT
'[]'::jsonb
)
FROM sol_payments pay
LEFT JOIN users u_payout
ON u_payout.spl_usdc_payout_wallet = pay.to_account
AND u_payout.is_current = TRUE
-- Historical match: which user had this Solana wallet set as their
-- USDC payout wallet at the time of the purchase? Mirrors Python's
-- add_wallet_info_to_splits, which joins UserPayoutWalletHistory
-- filtered by block_timestamp < purchase_time.
LEFT JOIN LATERAL (
SELECT upwh.user_id
FROM user_payout_wallet_history upwh
WHERE upwh.spl_usdc_payout_wallet = pay.to_account
AND upwh.block_timestamp <= sp.created_at
ORDER BY upwh.block_timestamp DESC
LIMIT 1
) u_payout ON TRUE
-- Fallback: if the user never set a custom payout (so no history
-- row exists), pay.to_account is their USDC user-bank PDA, which
-- is stable over time and resolves via sol_claimable_accounts.
LEFT JOIN sol_claimable_accounts sca
ON sca.account = pay.to_account
AND sca.mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
Expand Down
13 changes: 12 additions & 1 deletion sql/01_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9651,7 +9651,11 @@ CREATE VIEW public.v_usdc_purchases AS
sp.country,
( SELECT COALESCE(jsonb_agg(jsonb_build_object('user_id', COALESCE(u_payout.user_id, u_sca.user_id), 'payout_wallet', pay.to_account, 'amount', pay.amount, 'percentage', (((pay.amount)::numeric * 100.0) / (NULLIF(sp.amount, 0))::numeric)) ORDER BY pay.route_index), '[]'::jsonb) AS "coalesce"
FROM (((public.sol_payments pay
LEFT JOIN public.users u_payout ON ((((u_payout.spl_usdc_payout_wallet)::text = (pay.to_account)::text) AND (u_payout.is_current = true))))
LEFT JOIN LATERAL ( SELECT upwh.user_id
FROM public.user_payout_wallet_history upwh
WHERE (((upwh.spl_usdc_payout_wallet)::text = (pay.to_account)::text) AND (upwh.block_timestamp <= sp.created_at))
ORDER BY upwh.block_timestamp DESC
LIMIT 1) u_payout ON (true))
LEFT JOIN public.sol_claimable_accounts sca ON ((((sca.account)::text = (pay.to_account)::text) AND ((sca.mint)::text = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'::text))))
LEFT JOIN public.users u_sca ON ((((u_sca.wallet)::text = (sca.ethereum_address)::text) AND (u_sca.is_current = true))))
WHERE (((pay.signature)::text = (sp.signature)::text) AND (pay.instruction_index = sp.instruction_index))) AS splits
Expand Down Expand Up @@ -12629,6 +12633,13 @@ CREATE INDEX user_challenges_user_id ON public.user_challenges USING btree (user
CREATE INDEX user_events_user_id_idx ON public.user_events USING btree (user_id);


--
-- Name: user_payout_wallet_history_wallet_idx; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX user_payout_wallet_history_wallet_idx ON public.user_payout_wallet_history USING btree (spl_usdc_payout_wallet, block_timestamp);


--
-- Name: users_new_blocknumber_idx; Type: INDEX; Schema: public; Owner: -
--
Expand Down
Loading