Skip to content

Commit ab8c062

Browse files
committed
fetch missing SAC metadata using RPC
1 parent 1c05dc3 commit ab8c062

File tree

4 files changed

+293
-0
lines changed

4 files changed

+293
-0
lines changed

internal/services/contract_metadata.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"errors"
88
"fmt"
9+
"strings"
910
"sync"
1011
"time"
1112

@@ -44,6 +45,10 @@ type ContractMetadataService interface {
4445
// FetchSep41Metadata fetches metadata for SEP-41 contracts via RPC without storing.
4546
// Returns []*data.Contract with ID field pre-computed via DeterministicContractID.
4647
FetchSep41Metadata(ctx context.Context, contractIDs []string) ([]*data.Contract, error)
48+
// FetchSACMetadata fetches metadata for SAC contracts by calling name() via RPC.
49+
// SAC name() returns "code:issuer" format (or "native" for XLM).
50+
// Returns []*data.Contract with Code, Issuer, Name, Symbol, and Decimals=7.
51+
FetchSACMetadata(ctx context.Context, contractIDs []string) ([]*data.Contract, error)
4752
// FetchSingleField fetches a single contract method (name, symbol, decimals, balance, etc...) via RPC simulation.
4853
// The args parameter allows passing arguments to the contract function (e.g., address for balance(id) function).
4954
FetchSingleField(ctx context.Context, contractAddress, functionName string, args ...xdr.ScVal) (xdr.ScVal, error)
@@ -118,6 +123,94 @@ func (s *contractMetadataService) FetchSep41Metadata(ctx context.Context, contra
118123
return contracts, nil
119124
}
120125

126+
// FetchSACMetadata fetches metadata for SAC contracts by calling name() via RPC.
127+
// SAC contracts return "code:issuer" format from name() (or "native" for XLM).
128+
// Returns []*data.Contract with Code, Issuer, Name, Symbol, and Decimals=7 (hardcoded for Stellar assets).
129+
func (s *contractMetadataService) FetchSACMetadata(ctx context.Context, contractIDs []string) ([]*data.Contract, error) {
130+
if len(contractIDs) == 0 {
131+
return []*data.Contract{}, nil
132+
}
133+
134+
start := time.Now()
135+
var (
136+
contracts []*data.Contract
137+
mu sync.Mutex
138+
)
139+
140+
// Process in batches to avoid overwhelming the RPC
141+
for i := 0; i < len(contractIDs); i += simulateTransactionBatchSize {
142+
end := min(i+simulateTransactionBatchSize, len(contractIDs))
143+
batch := contractIDs[i:end]
144+
145+
group := s.pool.NewGroupContext(ctx)
146+
for _, contractID := range batch {
147+
group.Submit(func() {
148+
contract, err := s.fetchSACMetadataForContract(ctx, contractID)
149+
if err != nil {
150+
log.Ctx(ctx).Warnf("Failed to fetch SAC metadata for contract %s: %v", contractID, err)
151+
return
152+
}
153+
mu.Lock()
154+
contracts = append(contracts, contract)
155+
mu.Unlock()
156+
})
157+
}
158+
159+
if err := group.Wait(); err != nil {
160+
log.Ctx(ctx).Warnf("Error waiting for SAC metadata batch: %v", err)
161+
}
162+
163+
// Sleep between batches to avoid overwhelming the RPC (skip for last batch)
164+
if end < len(contractIDs) {
165+
time.Sleep(batchSleepDuration)
166+
}
167+
}
168+
169+
log.Ctx(ctx).Infof("Fetched metadata for %d SAC contracts in %.4f seconds", len(contracts), time.Since(start).Seconds())
170+
return contracts, nil
171+
}
172+
173+
// fetchSACMetadataForContract fetches metadata for a single SAC contract by calling name().
174+
// Parses the name as "code:issuer" format or handles "native" as XLM.
175+
func (s *contractMetadataService) fetchSACMetadataForContract(ctx context.Context, contractID string) (*data.Contract, error) {
176+
nameVal, err := s.FetchSingleField(ctx, contractID, "name")
177+
if err != nil {
178+
return nil, fmt.Errorf("fetching name: %w", err)
179+
}
180+
181+
nameStr, ok := nameVal.GetStr()
182+
if !ok {
183+
return nil, fmt.Errorf("name value is not a string")
184+
}
185+
name := string(nameStr)
186+
187+
var code, issuer string
188+
if name == "native" {
189+
// Native XLM asset
190+
code = "XLM"
191+
issuer = ""
192+
} else {
193+
// Parse "code:issuer" format
194+
parts := strings.SplitN(name, ":", 2)
195+
if len(parts) != 2 {
196+
return nil, fmt.Errorf("malformed SAC name '%s': expected 'code:issuer' format", name)
197+
}
198+
code = parts[0]
199+
issuer = parts[1]
200+
}
201+
202+
return &data.Contract{
203+
ID: data.DeterministicContractID(contractID),
204+
ContractID: contractID,
205+
Type: string(types.ContractTypeSAC),
206+
Code: &code,
207+
Issuer: &issuer,
208+
Name: &name,
209+
Symbol: &code,
210+
Decimals: 7, // Stellar assets always use 7 decimals
211+
}, nil
212+
}
213+
121214
// fetchMetadata fetches name, symbol, and decimals for a single SEP-41 contract in parallel.
122215
// Returns ContractMetadata with the fetched values.
123216
func (s *contractMetadataService) fetchMetadata(ctx context.Context, contractID string) (ContractMetadata, error) {

internal/services/contract_metadata_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,180 @@ func TestFetchSingleField(t *testing.T) {
381381
})
382382
}
383383

384+
func TestFetchSACMetadata(t *testing.T) {
385+
ctx := context.Background()
386+
387+
t.Run("returns empty slice for empty input", func(t *testing.T) {
388+
mockRPCService := NewRPCServiceMock(t)
389+
mockContractModel := data.NewContractModelMock(t)
390+
pool := pond.NewPool(5)
391+
defer pool.Stop()
392+
393+
service, err := NewContractMetadataService(mockRPCService, mockContractModel, pool)
394+
require.NoError(t, err)
395+
396+
result, err := service.FetchSACMetadata(ctx, []string{})
397+
398+
require.NoError(t, err)
399+
assert.Empty(t, result)
400+
// Verify no RPC calls were made
401+
mockRPCService.AssertNotCalled(t, "SimulateTransaction", mock.Anything, mock.Anything)
402+
})
403+
404+
t.Run("parses code:issuer format successfully", func(t *testing.T) {
405+
mockRPCService := NewRPCServiceMock(t)
406+
mockContractModel := data.NewContractModelMock(t)
407+
pool := pond.NewPool(5)
408+
defer pool.Stop()
409+
410+
contractID := "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
411+
412+
// Mock name() returning "USDC:GCNY..."
413+
nameScVal := xdr.ScVal{Type: xdr.ScValTypeScvString, Str: ptrToScString("USDC:GCNY5OXYSY4FKHOPT2SPOQZAOEIGXB5LBYW3HVU3OWSTQITS65M5RCNY")}
414+
415+
mockRPCService.On("SimulateTransaction", mock.Anything, mock.Anything).Return(
416+
entities.RPCSimulateTransactionResult{
417+
Results: []entities.RPCSimulateHostFunctionResult{{XDR: nameScVal}},
418+
}, nil,
419+
)
420+
421+
service, err := NewContractMetadataService(mockRPCService, mockContractModel, pool)
422+
require.NoError(t, err)
423+
424+
result, err := service.FetchSACMetadata(ctx, []string{contractID})
425+
426+
require.NoError(t, err)
427+
require.Len(t, result, 1)
428+
429+
contract := result[0]
430+
assert.Equal(t, contractID, contract.ContractID)
431+
assert.Equal(t, "SAC", contract.Type)
432+
assert.Equal(t, "USDC", *contract.Code)
433+
assert.Equal(t, "GCNY5OXYSY4FKHOPT2SPOQZAOEIGXB5LBYW3HVU3OWSTQITS65M5RCNY", *contract.Issuer)
434+
assert.Equal(t, "USDC:GCNY5OXYSY4FKHOPT2SPOQZAOEIGXB5LBYW3HVU3OWSTQITS65M5RCNY", *contract.Name)
435+
assert.Equal(t, "USDC", *contract.Symbol)
436+
assert.Equal(t, uint32(7), contract.Decimals)
437+
})
438+
439+
t.Run("handles native XLM asset", func(t *testing.T) {
440+
mockRPCService := NewRPCServiceMock(t)
441+
mockContractModel := data.NewContractModelMock(t)
442+
pool := pond.NewPool(5)
443+
defer pool.Stop()
444+
445+
contractID := "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
446+
447+
// Mock name() returning "native"
448+
nameScVal := xdr.ScVal{Type: xdr.ScValTypeScvString, Str: ptrToScString("native")}
449+
450+
mockRPCService.On("SimulateTransaction", mock.Anything, mock.Anything).Return(
451+
entities.RPCSimulateTransactionResult{
452+
Results: []entities.RPCSimulateHostFunctionResult{{XDR: nameScVal}},
453+
}, nil,
454+
)
455+
456+
service, err := NewContractMetadataService(mockRPCService, mockContractModel, pool)
457+
require.NoError(t, err)
458+
459+
result, err := service.FetchSACMetadata(ctx, []string{contractID})
460+
461+
require.NoError(t, err)
462+
require.Len(t, result, 1)
463+
464+
contract := result[0]
465+
assert.Equal(t, contractID, contract.ContractID)
466+
assert.Equal(t, "SAC", contract.Type)
467+
assert.Equal(t, "XLM", *contract.Code)
468+
assert.Equal(t, "", *contract.Issuer)
469+
assert.Equal(t, "native", *contract.Name)
470+
assert.Equal(t, "XLM", *contract.Symbol)
471+
assert.Equal(t, uint32(7), contract.Decimals)
472+
})
473+
474+
t.Run("skips contract with malformed name gracefully", func(t *testing.T) {
475+
mockRPCService := NewRPCServiceMock(t)
476+
mockContractModel := data.NewContractModelMock(t)
477+
pool := pond.NewPool(5)
478+
defer pool.Stop()
479+
480+
contractID := "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
481+
482+
// Mock name() returning malformed value (no colon)
483+
nameScVal := xdr.ScVal{Type: xdr.ScValTypeScvString, Str: ptrToScString("MALFORMED_NO_COLON")}
484+
485+
mockRPCService.On("SimulateTransaction", mock.Anything, mock.Anything).Return(
486+
entities.RPCSimulateTransactionResult{
487+
Results: []entities.RPCSimulateHostFunctionResult{{XDR: nameScVal}},
488+
}, nil,
489+
)
490+
491+
service, err := NewContractMetadataService(mockRPCService, mockContractModel, pool)
492+
require.NoError(t, err)
493+
494+
result, err := service.FetchSACMetadata(ctx, []string{contractID})
495+
496+
// Should not error, but skip the malformed contract
497+
require.NoError(t, err)
498+
assert.Empty(t, result)
499+
})
500+
501+
t.Run("skips contract when RPC fails gracefully", func(t *testing.T) {
502+
mockRPCService := NewRPCServiceMock(t)
503+
mockContractModel := data.NewContractModelMock(t)
504+
pool := pond.NewPool(5)
505+
defer pool.Stop()
506+
507+
contractID := "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
508+
509+
mockRPCService.On("SimulateTransaction", mock.Anything, mock.Anything).Return(
510+
entities.RPCSimulateTransactionResult{}, errors.New("RPC timeout"),
511+
)
512+
513+
service, err := NewContractMetadataService(mockRPCService, mockContractModel, pool)
514+
require.NoError(t, err)
515+
516+
result, err := service.FetchSACMetadata(ctx, []string{contractID})
517+
518+
// Should not error, but skip the failed contract
519+
require.NoError(t, err)
520+
assert.Empty(t, result)
521+
})
522+
523+
t.Run("processes multiple contracts successfully", func(t *testing.T) {
524+
mockRPCService := NewRPCServiceMock(t)
525+
mockContractModel := data.NewContractModelMock(t)
526+
pool := pond.NewPool(5)
527+
defer pool.Stop()
528+
529+
contractID1 := "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
530+
contractID2 := "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
531+
532+
// Mock responses for two contracts - use mock.Anything for both calls
533+
nameScVal1 := xdr.ScVal{Type: xdr.ScValTypeScvString, Str: ptrToScString("USDC:GCNY5OXYSY4FKHOPT2SPOQZAOEIGXB5LBYW3HVU3OWSTQITS65M5RCNY")}
534+
nameScVal2 := xdr.ScVal{Type: xdr.ScValTypeScvString, Str: ptrToScString("native")}
535+
536+
// Return different values for the two calls
537+
mockRPCService.On("SimulateTransaction", mock.Anything, mock.Anything).Return(
538+
entities.RPCSimulateTransactionResult{
539+
Results: []entities.RPCSimulateHostFunctionResult{{XDR: nameScVal1}},
540+
}, nil,
541+
).Once()
542+
mockRPCService.On("SimulateTransaction", mock.Anything, mock.Anything).Return(
543+
entities.RPCSimulateTransactionResult{
544+
Results: []entities.RPCSimulateHostFunctionResult{{XDR: nameScVal2}},
545+
}, nil,
546+
).Once()
547+
548+
service, err := NewContractMetadataService(mockRPCService, mockContractModel, pool)
549+
require.NoError(t, err)
550+
551+
result, err := service.FetchSACMetadata(ctx, []string{contractID1, contractID2})
552+
553+
require.NoError(t, err)
554+
assert.Len(t, result, 2)
555+
})
556+
}
557+
384558
func TestFetchBatch(t *testing.T) {
385559
ctx := context.Background()
386560

internal/services/mocks.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,14 @@ func (c *ContractMetadataServiceMock) FetchSep41Metadata(ctx context.Context, co
276276
return args.Get(0).([]*data.Contract), args.Error(1)
277277
}
278278

279+
func (c *ContractMetadataServiceMock) FetchSACMetadata(ctx context.Context, contractIDs []string) ([]*data.Contract, error) {
280+
args := c.Called(ctx, contractIDs)
281+
if args.Get(0) == nil {
282+
return nil, args.Error(1)
283+
}
284+
return args.Get(0).([]*data.Contract), args.Error(1)
285+
}
286+
279287
func (c *ContractMetadataServiceMock) FetchSingleField(ctx context.Context, contractAddress, functionName string, funcArgs ...xdr.ScVal) (xdr.ScVal, error) {
280288
args := c.Called(ctx, contractAddress, functionName, funcArgs)
281289
return args.Get(0).(xdr.ScVal), args.Error(1)

internal/services/token_ingestion.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,24 @@ func (s *tokenIngestionService) PopulateAccountTokens(ctx context.Context, check
246246
return fmt.Errorf("streaming checkpoint data: %w", txErr)
247247
}
248248

249+
// Identify SAC contracts missing code/issuer and fetch metadata via RPC
250+
sacContractsNeedingMetadata := make([]string, 0)
251+
for _, contract := range cpData.uniqueContractTokens {
252+
if contract.Type == string(types.ContractTypeSAC) && contract.Code == nil {
253+
sacContractsNeedingMetadata = append(sacContractsNeedingMetadata, contract.ContractID)
254+
}
255+
}
256+
if len(sacContractsNeedingMetadata) > 0 {
257+
log.Ctx(ctx).Infof("Fetching metadata for %d SAC contracts via RPC", len(sacContractsNeedingMetadata))
258+
sacContracts, txErr := s.contractMetadataService.FetchSACMetadata(ctx, sacContractsNeedingMetadata)
259+
if txErr != nil {
260+
return fmt.Errorf("fetching SAC metadata: %w", txErr)
261+
}
262+
for _, contract := range sacContracts {
263+
cpData.uniqueContractTokens[contract.ID] = contract
264+
}
265+
}
266+
249267
// Extract contract spec from WASM hash and validate SEP-41 contracts
250268
sep41Tokens, err := s.fetchSep41Metadata(ctx, cpData.contractIDsByWasmHash, cpData.contractTypesByWasmHash)
251269
if err != nil {

0 commit comments

Comments
 (0)