Skip to content

Commit c3a6148

Browse files
xdefragclaude
andcommitted
feat: add MTLAX (Synthetic) token category to home page
Add a new Synthetic section for MTLAX token holders, displayed between Persons and Corporate on the home page. Since the MTLAX RFC is not yet accepted, we show ALL accounts with an MTLAX trustline (even 0 balance) to demonstrate community interest, using a nullable mtlax_balance column to distinguish "no trustline" (NULL) from "has trustline with 0 balance". - Add TokenMTLAX constant and database migration for mtlax_balance column - Fetch MTLAX holders during sync alongside MTLAP/MTLAC - Add GetSynthetic repository method sorted by reputation score - Add Synthetic stats card and paginated table section to home page - Include mtlax_balance in search sort expressions - Update all tests and regenerate mocks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 51d38b4 commit c3a6148

13 files changed

Lines changed: 266 additions & 22 deletions

File tree

internal/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const (
1919
// TokenMTLAC is the asset code for Companies.
2020
TokenMTLAC = "MTLAC"
2121

22+
// TokenMTLAX is the asset code for Synthetic.
23+
TokenMTLAX = "MTLAX"
24+
2225
// DefaultPageLimit is the default number of accounts per page.
2326
DefaultPageLimit = 20
2427
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- +goose Up
2+
ALTER TABLE accounts ADD COLUMN mtlax_balance NUMERIC(20, 7) DEFAULT NULL;
3+
CREATE INDEX idx_accounts_mtlax ON accounts(mtlax_balance) WHERE mtlax_balance IS NOT NULL;
4+
5+
-- +goose Down
6+
DROP INDEX IF EXISTS idx_accounts_mtlax;
7+
ALTER TABLE accounts DROP COLUMN IF EXISTS mtlax_balance;

internal/handler/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type AccountQuerier interface {
3030
GetStats(ctx context.Context) (*repository.Stats, error)
3131
GetPersons(ctx context.Context, limit int, offset int) ([]repository.PersonRow, error)
3232
GetCorporate(ctx context.Context, limit int, offset int) ([]repository.CorporateRow, error)
33+
GetSynthetic(ctx context.Context, limit int, offset int) ([]repository.SyntheticRow, error)
3334
GetRelationships(ctx context.Context, accountID string) ([]repository.RelationshipRow, error)
3435
GetTrustRatings(ctx context.Context, accountID string) (*repository.TrustRating, error)
3536
GetConfirmedRelationships(ctx context.Context, accountID string) (map[string]bool, error)

internal/handler/handler_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func TestHomeHandler(t *testing.T) {
6565
TotalAccounts: 100,
6666
TotalPersons: 50,
6767
TotalCompanies: 25,
68+
TotalSynthetic: 10,
6869
TotalXLMValue: 1000000.0,
6970
}, nil)
7071

@@ -76,6 +77,10 @@ func TestHomeHandler(t *testing.T) {
7677
{AccountID: "GDEF", Name: "Test Company", MTLACBalance: 50.0, TotalXLMValue: 5000.0},
7778
}, nil)
7879

80+
accounts.EXPECT().GetSynthetic(mock.Anything, mock.Anything, mock.Anything).Return([]repository.SyntheticRow{
81+
{AccountID: "GHIJ", Name: "Test Synthetic", ReputationScore: 3.5, ReputationWeight: 10.0},
82+
}, nil)
83+
7984
var renderedData any
8085
tmpl.EXPECT().Render(mock.Anything, "home.html", mock.Anything).Run(func(w io.Writer, name string, data any) {
8186
renderedData = data
@@ -95,6 +100,7 @@ func TestHomeHandler(t *testing.T) {
95100
assert.Equal(t, 100, homeData.Stats.TotalAccounts)
96101
assert.Len(t, homeData.Persons, 1)
97102
assert.Len(t, homeData.Corporate, 1)
103+
assert.Len(t, homeData.Synthetic, 1)
98104
})
99105

100106
t.Run("pagination parameters parsed correctly", func(t *testing.T) {
@@ -106,6 +112,7 @@ func TestHomeHandler(t *testing.T) {
106112
// Expect offset 20 for persons and 40 for corporate
107113
accounts.EXPECT().GetPersons(mock.Anything, mock.Anything, 20).Return(nil, nil)
108114
accounts.EXPECT().GetCorporate(mock.Anything, mock.Anything, 40).Return(nil, nil)
115+
accounts.EXPECT().GetSynthetic(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
109116
tmpl.EXPECT().Render(mock.Anything, mock.Anything, mock.Anything).Return(nil)
110117

111118
h, err := New(stellar, accounts, nil, tmpl)
@@ -127,6 +134,7 @@ func TestHomeHandler(t *testing.T) {
127134
accounts.EXPECT().GetStats(mock.Anything).Return(&repository.Stats{}, nil)
128135
accounts.EXPECT().GetPersons(mock.Anything, mock.Anything, 0).Return(nil, nil)
129136
accounts.EXPECT().GetCorporate(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
137+
accounts.EXPECT().GetSynthetic(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
130138
tmpl.EXPECT().Render(mock.Anything, mock.Anything, mock.Anything).Return(nil)
131139

132140
h, err := New(stellar, accounts, nil, tmpl)
@@ -148,6 +156,7 @@ func TestHomeHandler(t *testing.T) {
148156
accounts.EXPECT().GetStats(mock.Anything).Return(&repository.Stats{}, nil)
149157
accounts.EXPECT().GetPersons(mock.Anything, mock.Anything, 0).Return(nil, nil)
150158
accounts.EXPECT().GetCorporate(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
159+
accounts.EXPECT().GetSynthetic(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
151160
tmpl.EXPECT().Render(mock.Anything, mock.Anything, mock.Anything).Return(nil)
152161

153162
h, err := New(stellar, accounts, nil, tmpl)
@@ -229,6 +238,7 @@ func TestHomeHandler(t *testing.T) {
229238
accounts.EXPECT().GetStats(mock.Anything).Return(&repository.Stats{}, nil)
230239
accounts.EXPECT().GetPersons(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
231240
accounts.EXPECT().GetCorporate(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
241+
accounts.EXPECT().GetSynthetic(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
232242
tmpl.EXPECT().Render(mock.Anything, mock.Anything, mock.Anything).Return(errors.New("template error"))
233243

234244
h, err := New(stellar, accounts, nil, tmpl)
@@ -256,6 +266,7 @@ func TestHomeHandler(t *testing.T) {
256266
accounts.EXPECT().GetStats(mock.Anything).Return(&repository.Stats{}, nil)
257267
accounts.EXPECT().GetPersons(mock.Anything, mock.Anything, mock.Anything).Return(persons, nil)
258268
accounts.EXPECT().GetCorporate(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
269+
accounts.EXPECT().GetSynthetic(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
259270

260271
var renderedData any
261272
tmpl.EXPECT().Render(mock.Anything, mock.Anything, mock.Anything).Run(func(w io.Writer, name string, data any) {

internal/handler/home.go

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@ import (
1212

1313
// HomeData holds data for the home page template.
1414
type HomeData struct {
15-
Stats *repository.Stats
16-
Persons []repository.PersonRow
17-
Corporate []repository.CorporateRow
18-
PersonsOffset int
19-
CorporateOffset int
20-
NextPersonsOffset int
21-
NextCorporateOffset int
22-
HasMorePersons bool
23-
HasMoreCorporate bool
15+
Stats *repository.Stats
16+
Persons []repository.PersonRow
17+
Corporate []repository.CorporateRow
18+
Synthetic []repository.SyntheticRow
19+
PersonsOffset int
20+
CorporateOffset int
21+
SyntheticOffset int
22+
NextPersonsOffset int
23+
NextCorporateOffset int
24+
NextSyntheticOffset int
25+
HasMorePersons bool
26+
HasMoreCorporate bool
27+
HasMoreSynthetic bool
2428
}
2529

2630
// Home handles the main page showing Persons and Companies.
@@ -43,6 +47,14 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
4347
}
4448
corporateOffset = 0
4549
}
50+
syntheticOffsetParam := r.URL.Query().Get("synthetic_offset")
51+
syntheticOffset, err := strconv.Atoi(syntheticOffsetParam)
52+
if err != nil || syntheticOffset < 0 {
53+
if syntheticOffsetParam != "" {
54+
slog.Debug("invalid synthetic_offset parameter, defaulting to 0", "value", syntheticOffsetParam)
55+
}
56+
syntheticOffset = 0
57+
}
4658

4759
stats, err := h.accounts.GetStats(ctx)
4860
if err != nil {
@@ -65,26 +77,41 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
6577
return
6678
}
6779

80+
synthetic, err := h.accounts.GetSynthetic(ctx, config.DefaultPageLimit+1, syntheticOffset)
81+
if err != nil {
82+
slog.Error("failed to fetch synthetic", "offset", syntheticOffset, "error", err)
83+
http.Error(w, "Failed to fetch synthetic accounts", http.StatusInternalServerError)
84+
return
85+
}
86+
6887
hasMorePersons := len(persons) > config.DefaultPageLimit
6988
hasMoreCorporate := len(corporate) > config.DefaultPageLimit
89+
hasMoreSynthetic := len(synthetic) > config.DefaultPageLimit
7090

7191
if hasMorePersons {
7292
persons = persons[:config.DefaultPageLimit]
7393
}
7494
if hasMoreCorporate {
7595
corporate = corporate[:config.DefaultPageLimit]
7696
}
97+
if hasMoreSynthetic {
98+
synthetic = synthetic[:config.DefaultPageLimit]
99+
}
77100

78101
data := HomeData{
79102
Stats: stats,
80103
Persons: persons,
81104
Corporate: corporate,
105+
Synthetic: synthetic,
82106
PersonsOffset: personsOffset,
83107
CorporateOffset: corporateOffset,
108+
SyntheticOffset: syntheticOffset,
84109
NextPersonsOffset: personsOffset + config.DefaultPageLimit,
85110
NextCorporateOffset: corporateOffset + config.DefaultPageLimit,
111+
NextSyntheticOffset: syntheticOffset + config.DefaultPageLimit,
86112
HasMorePersons: hasMorePersons,
87113
HasMoreCorporate: hasMoreCorporate,
114+
HasMoreSynthetic: hasMoreSynthetic,
88115
}
89116

90117
var buf bytes.Buffer

internal/handler/mocks/mock_AccountQuerier.go

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/repository/account.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Stats struct {
3232
TotalAccounts int
3333
TotalPersons int
3434
TotalCompanies int
35+
TotalSynthetic int
3536
TotalXLMValue float64
3637
}
3738

@@ -52,13 +53,22 @@ type CorporateRow struct {
5253
TotalXLMValue float64
5354
}
5455

56+
// SyntheticRow represents a synthetic account (MTLAX trustline holder) from the database.
57+
type SyntheticRow struct {
58+
AccountID string
59+
Name string
60+
ReputationScore float64
61+
ReputationWeight float64
62+
}
63+
5564
// GetStats returns aggregate statistics.
5665
func (r *AccountRepository) GetStats(ctx context.Context) (*Stats, error) {
5766
query, args, err := database.QB.
5867
Select(
5968
"COUNT(*) AS total_accounts",
6069
"COUNT(*) FILTER (WHERE mtlap_balance > 0 AND mtlap_balance <= 5) AS total_persons",
6170
"COUNT(*) FILTER (WHERE mtlac_balance > 0 AND mtlac_balance <= 4) AS total_companies",
71+
"COUNT(*) FILTER (WHERE mtlax_balance IS NOT NULL) AS total_synthetic",
6272
"COALESCE(SUM(total_xlm_value), 0) AS total_xlm_value",
6373
).
6474
From("accounts").
@@ -72,6 +82,7 @@ func (r *AccountRepository) GetStats(ctx context.Context) (*Stats, error) {
7282
&stats.TotalAccounts,
7383
&stats.TotalPersons,
7484
&stats.TotalCompanies,
85+
&stats.TotalSynthetic,
7586
&stats.TotalXLMValue,
7687
)
7788
if err != nil {
@@ -164,6 +175,48 @@ func (r *AccountRepository) GetCorporate(ctx context.Context, limit int, offset
164175
return corporate, nil
165176
}
166177

178+
// GetSynthetic returns MTLAX trustline holders sorted by reputation score.
179+
func (r *AccountRepository) GetSynthetic(ctx context.Context, limit int, offset int) ([]SyntheticRow, error) {
180+
query, args, err := database.QB.
181+
Select(
182+
"a.account_id",
183+
"COALESCE(m.data_value, CONCAT(LEFT(a.account_id, 6), '...', RIGHT(a.account_id, 6))) AS name",
184+
"COALESCE(rs.weighted_score, 0) AS reputation_score",
185+
"COALESCE(rs.total_weight, 0) AS reputation_weight",
186+
).
187+
From("accounts a").
188+
LeftJoin("account_metadata m ON a.account_id = m.account_id AND m.data_key = 'Name' AND m.data_index = ''").
189+
LeftJoin("reputation_scores rs ON a.account_id = rs.account_id").
190+
Where("a.mtlax_balance IS NOT NULL").
191+
OrderBy("COALESCE(rs.weighted_score, 0) DESC", "COALESCE(rs.total_weight, 0) DESC").
192+
Limit(uint64(limit)).
193+
Offset(uint64(offset)).
194+
ToSql()
195+
if err != nil {
196+
return nil, fmt.Errorf("build synthetic query: %w", err)
197+
}
198+
199+
rows, err := r.pool.Query(ctx, query, args...)
200+
if err != nil {
201+
return nil, fmt.Errorf("query synthetic: %w", err)
202+
}
203+
defer rows.Close()
204+
205+
var synthetic []SyntheticRow
206+
for rows.Next() {
207+
var s SyntheticRow
208+
if err := rows.Scan(&s.AccountID, &s.Name, &s.ReputationScore, &s.ReputationWeight); err != nil {
209+
return nil, fmt.Errorf("scan synthetic: %w", err)
210+
}
211+
synthetic = append(synthetic, s)
212+
}
213+
214+
if err := rows.Err(); err != nil {
215+
return nil, fmt.Errorf("iterate synthetic rows: %w", err)
216+
}
217+
return synthetic, nil
218+
}
219+
167220
// RelationshipRow represents a relationship from the database.
168221
type RelationshipRow struct {
169222
SourceAccountID string
@@ -563,7 +616,7 @@ func (r *AccountRepository) SearchAccounts(ctx context.Context, query string, ta
563616
case SearchSortByReputation:
564617
// Sort by membership level first (MTLAP/MTLAC balance), then by grade bucket, then by weight
565618
qb = qb.OrderBy(
566-
"GREATEST(a.mtlap_balance, a.mtlac_balance) DESC",
619+
"GREATEST(a.mtlap_balance, a.mtlac_balance, COALESCE(a.mtlax_balance, 0)) DESC",
567620
`CASE
568621
WHEN COALESCE(rs.weighted_score, 0) >= 3.5 THEN 1
569622
WHEN COALESCE(rs.weighted_score, 0) >= 3.0 THEN 2
@@ -577,7 +630,7 @@ func (r *AccountRepository) SearchAccounts(ctx context.Context, query string, ta
577630
"COALESCE(rs.total_weight, 0) DESC",
578631
)
579632
default: // SearchSortByBalance
580-
qb = qb.OrderBy("GREATEST(a.mtlap_balance, a.mtlac_balance) DESC", "a.total_xlm_value DESC")
633+
qb = qb.OrderBy("GREATEST(a.mtlap_balance, a.mtlac_balance, COALESCE(a.mtlax_balance, 0)) DESC", "a.total_xlm_value DESC")
581634
}
582635

583636
qb = qb.Limit(uint64(limit)).Offset(uint64(offset))

internal/sync/accounts.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,15 @@ func getMTLACBalance(data *AccountData) decimal.Decimal {
413413
func getNativeBalance(data *AccountData) decimal.Decimal {
414414
return findBalance(data.Balances, "XLM", "")
415415
}
416+
417+
// getMTLAXBalance returns a pointer to the MTLAX balance if the trustline exists, or nil if not.
418+
// This distinguishes "no trustline" (nil) from "has trustline with 0 balance" (*decimal.Zero).
419+
func getMTLAXBalance(data *AccountData) *decimal.Decimal {
420+
bal, found := lo.Find(data.Balances, func(b Balance) bool {
421+
return b.AssetCode == config.TokenMTLAX && b.AssetIssuer == config.TokenIssuer
422+
})
423+
if !found {
424+
return nil
425+
}
426+
return &bal.Balance
427+
}

0 commit comments

Comments
 (0)