diff --git a/api/v1_users_purchases_download_test.go b/api/v1_users_purchases_download_test.go index 92913749..304db051 100644 --- a/api/v1_users_purchases_download_test.go +++ b/api/v1_users_purchases_download_test.go @@ -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", @@ -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", diff --git a/api/v1_users_purchases_test.go b/api/v1_users_purchases_test.go index 00bfb0e1..4f65a1a8 100644 --- a/api/v1_users_purchases_test.go +++ b/api/v1_users_purchases_test.go @@ -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", diff --git a/api/v1_users_sales_download_test.go b/api/v1_users_sales_download_test.go index 18061501..0198c626 100644 --- a/api/v1_users_sales_download_test.go +++ b/api/v1_users_sales_download_test.go @@ -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", @@ -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", diff --git a/api/v1_users_sales_test.go b/api/v1_users_sales_test.go index f0cfae2b..224df38b 100644 --- a/api/v1_users_sales_test.go +++ b/api/v1_users_sales_test.go @@ -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}, diff --git a/api/v_usdc_purchases_splits_test.go b/api/v_usdc_purchases_splits_test.go new file mode 100644 index 00000000..441803c4 --- /dev/null +++ b/api/v_usdc_purchases_splits_test.go @@ -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", + }) +} diff --git a/database/seed.go b/database/seed.go index b616bb15..668d4d22 100644 --- a/database/seed.go +++ b/database/seed.go @@ -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, @@ -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) diff --git a/ddl/migrations/0200_user_payout_wallet_history_wallet_idx.sql b/ddl/migrations/0200_user_payout_wallet_history_wallet_idx.sql new file mode 100644 index 00000000..a7038ec3 --- /dev/null +++ b/ddl/migrations/0200_user_payout_wallet_history_wallet_idx.sql @@ -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; diff --git a/ddl/views/v_usdc_purchases.sql b/ddl/views/v_usdc_purchases.sql index 81437e91..40b11061 100644 --- a/ddl/views/v_usdc_purchases.sql +++ b/ddl/views/v_usdc_purchases.sql @@ -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' diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 0f0ef316..f5b2db2b 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -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 @@ -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: - --