Skip to content

Commit de53f22

Browse files
rickyromboclaude
andauthored
Migrate /v1/users/{id}/transactions/audio to v_token_transactions_history (#844)
## Summary Adds a mint-agnostic transactions-history view (\`v_token_transactions_history\`) anchored on \`sol_token_account_balance_changes\` and migrates the AUDIO transactions endpoint to use it. The same view can power the USDC endpoint and any future artist coin's history once two indexer gaps are filled (called out below). ## The view \`sol_token_account_balance_changes\` is the only sol_* table with both \`mint\` and \`block_timestamp\` directly, so it's the natural hub for a chronological transactions view. Per-row \`transaction_type\` is derived via LEFT JOINs to the typed tables: | Source | transaction_type | |---|---| | \`sol_reward_disbursements\` + \`challenges.type = 'trending'\` | \`trending_reward\` | | \`sol_reward_disbursements\` (else) | \`user_reward\` | | \`sol_purchases\` (USDC content purchases via Payment Router) | \`purchase_content\` | | \`sol_claimable_account_transfers\` + both ends resolve to distinct user_banks | \`tip\` | | \`sol_claimable_account_transfers\` (else) | \`transfer\` | | (no typed match) | \`transfer\` | \`method\` (\`send\`/\`receive\`) derives from \`SIGN(change)\`. Legacy semantic of unsigned \`change\` is preserved via \`ABS()\`. Callers filter at query time: \`WHERE mint = '<mint>' AND user_id = X\`. ## Route migration \`/v1/users/{id}/transactions/audio\` (and \`/count\`) replace the \`audio_transactions_history JOIN user_bank_accounts JOIN users\` chain with a single filter on the view. Response shape, sort options (\`date\` / \`transaction_type\`, asc/desc), and pagination are unchanged. \`/v1/users/{id}/transactions/usdc\` and \`/v1/users/{id}/withdrawals/download\` are **not** touched — see "future work" below. ## Known regression AUDIO Stripe/Coinbase top-ups, classified by the Python indexer as \`PURCHASE_STRIPE\` / \`PURCHASE_COINBASE\` / \`PURCHASE_UNKNOWN\` (parsed from memo instructions), now come back as bare \`transfer\` from the new view. The Go indexer doesn't capture vendor memos yet. The client adapter at \`apps/packages/common/src/adapters/audioTransactions.ts\` will route these to the TRANSFER icon/label until vendor-memo capture lands. ## Tests New \`api/v_token_transactions_history_test.go\` covers each branch of the type-derivation CASE: - **TIP** — claimable transfer between two distinct user_banks. Both sender and receiver see the row with method derived correctly and counterpart user_id in metadata. - **TRANSFER** — claimable transfer where the counterpart isn't a known user_bank. - **USER_REWARD** — reward disbursement on a non-trending challenge. - **TRENDING_REWARD** — reward disbursement on a trending challenge. - **Mint isolation** — a USDC balance change on the same user doesn't leak into the AUDIO endpoint. Plus the existing \`TestGetUserAudioTransactions\` and \`TestGetUserAudioTransactionsCount\` continue to pass against the new view (fixtures updated in \`api/testdata/sol_audio_transactions_fixtures.go\`). ## Future work (not in this PR) - **\`sol_withdrawals\` table** + Go indexer memo parsing — unblocks the USDC route migration (legacy types: \`prepare_withdrawal\` / \`recover_withdrawal\` / \`withdrawal\`). - **Vendor-memo capture** on the Go indexer — restores \`PURCHASE_STRIPE\` / \`COINBASE\` / \`UNKNOWN\` classification on the AUDIO view. - **USDC route migration** once both gaps above are filled. Will reuse this same view, filtered by USDC mint. - **Python \`index_user_bank\` / \`index_spl_token\` / \`index_rewards_manager\` decommission** after both routes are off legacy. ## Test plan - [x] \`go test ./api/ -run 'TestGetUserAudioTransactions|TestVTokenTransactionsHistory|TestV1UsersPurchases|TestV1UsersSales' -count=1\` — green locally - [ ] Spot-check on prod replica: \`SELECT transaction_type, count(*) FROM v_token_transactions_history WHERE mint = '9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM' GROUP BY 1\` should show \`transfer\` (largest, includes top-ups), \`user_reward\`, \`tip\`, \`trending_reward\`. **No** \`purchase_stripe\` / \`coinbase\` / \`unknown\` — verified-degraded. - [ ] \`EXPLAIN (ANALYZE, BUFFERS)\` on the audio endpoint query for a real user — should be an Index Scan on \`sol_token_account_balance_changes_account_idx\` driving the per-row joins via \`(signature, instruction_index)\` PK leftmost-prefix on the typed tables. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9fd7e23 commit de53f22

7 files changed

Lines changed: 541 additions & 12 deletions

File tree

api/server_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ func testAppWithFixtures(t *testing.T) *ApiServer {
8383
database.SeedTable(app.pool.Replicas[0], "aggregate_track", testdata.AggregateTrack)
8484
database.SeedTable(app.pool.Replicas[0], "aggregate_user", testdata.AggregateUser)
8585
database.SeedTable(app.pool.Replicas[0], "aggregate_user_tips", testdata.AggregateUserTips)
86-
database.SeedTable(app.pool.Replicas[0], "audio_transactions_history", testdata.AudioTransactionsHistory)
86+
database.SeedTable(app.pool.Replicas[0], "sol_claimable_accounts", testdata.SolClaimableAccountsAudioFixtures)
87+
database.SeedTable(app.pool.Replicas[0], "sol_token_account_balance_changes", testdata.SolTokenAccountBalanceChangesAudioFixtures)
88+
database.SeedTable(app.pool.Replicas[0], "sol_claimable_account_transfers", testdata.SolClaimableAccountTransfersAudioFixtures)
8789
database.SeedTable(app.pool.Replicas[0], "challenges", testdata.Challenges)
8890
database.SeedTable(app.pool.Replicas[0], "challenge_listen_streak", testdata.ChallengeListenStreak)
8991
database.SeedTable(app.pool.Replicas[0], "comments", testdata.Comment)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package testdata
2+
3+
import "time"
4+
5+
// wAUDIO mint constant — duplicated here to avoid importing the api package.
6+
const wAudioMintTestData = "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM"
7+
8+
// Bank accounts used by the audio-transactions tests.
9+
const (
10+
user1AudioBank = "DsUGy77ssRh9EXzef3AZLLT9GQBuyqHRdhkBkfqQ3x1D" // user 7eP5n (user_id=1)
11+
user2AudioBank = "User2AudioBank_______________________________" // user_id=2 (tip counterpart)
12+
externalAudioAccount = "ExternalNonUserBank__________________________" // not in sol_claimable_accounts (transfer counterpart)
13+
)
14+
15+
// Mirrors the rows in AudioTransactionsHistory but in sol_* shape, used by
16+
// /v1/users/{id}/transactions/audio and the v_token_transactions_history view.
17+
//
18+
// Per legacy fixture for user 7eP5n's user_bank (5 rows, distinct dates):
19+
// 0x12345 — TIP receive, change=+100, balance=100
20+
// 0x23456 — TIP send, change=-10, balance=90
21+
// 0x34567 — TIP send, change=-10, balance=80
22+
// 0x45678 — TRANSFER send, change=-50, balance=30
23+
// 0x56789 — TRANSFER send, change=-10, balance=20
24+
//
25+
// Tips resolve when both endpoints map to known user_banks of distinct users.
26+
// Transfers are claimable transfers where the counterpart is NOT a known
27+
// user_bank (here: externalAudioAccount).
28+
29+
var SolClaimableAccountsAudioFixtures = []map[string]any{
30+
{
31+
"signature": "claim_create_user1",
32+
"instruction_index": 0,
33+
"slot": 1,
34+
"mint": wAudioMintTestData,
35+
"ethereum_address": "0x7d273271690538cf855e5b3002a0dd8c154bb060", // user 1
36+
"account": user1AudioBank,
37+
},
38+
{
39+
"signature": "claim_create_user2",
40+
"instruction_index": 0,
41+
"slot": 1,
42+
"mint": wAudioMintTestData,
43+
"ethereum_address": "0x1234567890abcdef", // user 2 (stereosteve)
44+
"account": user2AudioBank,
45+
},
46+
}
47+
48+
var SolTokenAccountBalanceChangesAudioFixtures = []map[string]any{
49+
{
50+
"signature": "0x12345",
51+
"mint": wAudioMintTestData,
52+
"owner": "claimable-tokens-pda",
53+
"account": user1AudioBank,
54+
"change": 100,
55+
"balance": 100,
56+
"slot": 10,
57+
"block_timestamp": time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
58+
},
59+
{
60+
"signature": "0x23456",
61+
"mint": wAudioMintTestData,
62+
"owner": "claimable-tokens-pda",
63+
"account": user1AudioBank,
64+
"change": -10,
65+
"balance": 90,
66+
"slot": 20,
67+
"block_timestamp": time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC),
68+
},
69+
{
70+
"signature": "0x34567",
71+
"mint": wAudioMintTestData,
72+
"owner": "claimable-tokens-pda",
73+
"account": user1AudioBank,
74+
"change": -10,
75+
"balance": 80,
76+
"slot": 30,
77+
"block_timestamp": time.Date(2021, 1, 3, 0, 0, 0, 0, time.UTC),
78+
},
79+
{
80+
"signature": "0x45678",
81+
"mint": wAudioMintTestData,
82+
"owner": "claimable-tokens-pda",
83+
"account": user1AudioBank,
84+
"change": -50,
85+
"balance": 30,
86+
"slot": 40,
87+
"block_timestamp": time.Date(2021, 1, 4, 0, 0, 0, 0, time.UTC),
88+
},
89+
{
90+
"signature": "0x56789",
91+
"mint": wAudioMintTestData,
92+
"owner": "claimable-tokens-pda",
93+
"account": user1AudioBank,
94+
"change": -10,
95+
"balance": 20,
96+
"slot": 50,
97+
"block_timestamp": time.Date(2021, 1, 5, 0, 0, 0, 0, time.UTC),
98+
},
99+
}
100+
101+
var SolClaimableAccountTransfersAudioFixtures = []map[string]any{
102+
// TIP receive: user2 -> user1 (both known user_banks)
103+
{
104+
"signature": "0x12345",
105+
"instruction_index": 0,
106+
"amount": 100,
107+
"slot": 10,
108+
"from_account": user2AudioBank,
109+
"to_account": user1AudioBank,
110+
"sender_eth_address": "0x1234567890abcdef",
111+
},
112+
// TIP send: user1 -> user2
113+
{
114+
"signature": "0x23456",
115+
"instruction_index": 0,
116+
"amount": 10,
117+
"slot": 20,
118+
"from_account": user1AudioBank,
119+
"to_account": user2AudioBank,
120+
"sender_eth_address": "0x7d273271690538cf855e5b3002a0dd8c154bb060",
121+
},
122+
// TIP send: user1 -> user2 (another one)
123+
{
124+
"signature": "0x34567",
125+
"instruction_index": 0,
126+
"amount": 10,
127+
"slot": 30,
128+
"from_account": user1AudioBank,
129+
"to_account": user2AudioBank,
130+
"sender_eth_address": "0x7d273271690538cf855e5b3002a0dd8c154bb060",
131+
},
132+
// TRANSFER send: user1 -> external (recipient is not a known user_bank)
133+
{
134+
"signature": "0x45678",
135+
"instruction_index": 0,
136+
"amount": 50,
137+
"slot": 40,
138+
"from_account": user1AudioBank,
139+
"to_account": externalAudioAccount,
140+
"sender_eth_address": "0x7d273271690538cf855e5b3002a0dd8c154bb060",
141+
},
142+
// TRANSFER send: user1 -> external (another one)
143+
{
144+
"signature": "0x56789",
145+
"instruction_index": 0,
146+
"amount": 10,
147+
"slot": 50,
148+
"from_account": user1AudioBank,
149+
"to_account": externalAudioAccount,
150+
"sender_eth_address": "0x7d273271690538cf855e5b3002a0dd8c154bb060",
151+
},
152+
}

api/v1_users_transactions_audio.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
"github.com/jackc/pgx/v5/pgtype"
1010
)
1111

12+
// wAUDIO mint on mainnet — used to filter v_token_transactions_history.
13+
const wAudioMint = "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM"
14+
1215
type GetUsersTransactionsAudioParams struct {
1316
Limit int `query:"limit" default:"100" validate:"min=1,max=100"`
1417
Offset int `query:"offset" default:"0" validate:"min=0"`
@@ -38,23 +41,22 @@ func (app *ApiServer) v1UsersTransactionsAudio(c *fiber.Ctx) error {
3841
sortDirection = "asc"
3942
}
4043

41-
var orderBy = fmt.Sprintf("ath.created_at %s", sortDirection)
44+
var orderBy = fmt.Sprintf("transaction_date %s", sortDirection)
4245
if params.Sort == "transaction_type" {
43-
orderBy = fmt.Sprintf("transaction_type %s, ath.created_at desc", sortDirection)
46+
orderBy = fmt.Sprintf("transaction_type %s, transaction_date desc", sortDirection)
4447
}
4548

4649
sql := `
47-
SELECT ath.created_at as transaction_date, transaction_type, ath.signature, method, ath.user_bank, tx_metadata as metadata, change::text, balance::text
48-
FROM users
49-
JOIN user_bank_accounts uba ON uba.ethereum_address = users.wallet
50-
JOIN audio_transactions_history ath ON ath.user_bank = uba.bank_account
51-
WHERE users.user_id = @user_id::int AND users.is_current = TRUE
50+
SELECT transaction_date, transaction_type, signature, method, user_bank, tx_metadata AS metadata, change, balance
51+
FROM v_token_transactions_history
52+
WHERE mint = @mint AND user_id = @user_id::int
5253
ORDER BY ` + orderBy + `
5354
LIMIT @limit_val
5455
OFFSET @offset_val;
5556
`
5657

5758
args := pgx.NamedArgs{
59+
"mint": wAudioMint,
5860
"user_id": app.getUserId(c),
5961
"limit_val": params.Limit,
6062
"offset_val": params.Offset,
@@ -78,13 +80,12 @@ func (app *ApiServer) v1UsersTransactionsAudio(c *fiber.Ctx) error {
7880
func (app *ApiServer) v1UsersTransactionsAudioCount(c *fiber.Ctx) error {
7981
sql := `
8082
SELECT count(*)
81-
FROM users
82-
JOIN user_bank_accounts uba ON uba.ethereum_address = users.wallet
83-
JOIN audio_transactions_history ath ON ath.user_bank = uba.bank_account
84-
WHERE users.user_id = @user_id::int AND users.is_current = TRUE;
83+
FROM v_token_transactions_history
84+
WHERE mint = @mint AND user_id = @user_id::int;
8585
`
8686

8787
row := app.pool.QueryRow(c.Context(), sql, pgx.NamedArgs{
88+
"mint": wAudioMint,
8889
"user_id": app.getUserId(c),
8990
})
9091

0 commit comments

Comments
 (0)