EDS - Add TokenStandard TokenMetadataV1 implementation (NONEVM-4878) - #823
EDS - Add TokenStandard TokenMetadataV1 implementation (NONEVM-4878)#823friedemannf wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR enables EDS’s TokenStandard API to implement the TokenMetadataV1 endpoints (ListInstruments/GetInstrument) and return token metadata including total supply, backed by EDS stores and covered by unit/integration tests.
Changes:
- Enable TokenStandard API in the CCIP Burn/Mint integration test and validate TokenMetadata responses (including totalSupply).
- Extend EDS stores to support listing holdings by instrument and returning multiple active contracts per (party, templateId).
- Implement totalSupply calculation + caching and wire TokenMetadataV1 responses in the TokenStandard API.
Reviewed changes
Copilot reviewed 10 out of 12 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| integration-tests/ccip/ccip_send_with_token_bnm_test.go | Enables TokenStandard API and asserts TokenMetadataV1 totalSupply behavior during a full send flow. |
| eds/service/service.go | Wires InstrumentHoldingStore into TokenStandard API server construction. |
| eds/internal/store/interfaces.go | Updates store interfaces for multi-contract template lookup + instrument holdings listing. |
| eds/internal/store/instrument_store.go | Rekeys holdings storage by instrument and adds ListHoldings API. |
| eds/internal/store/active_contract_store.go | Changes template index to support multiple active contracts per template + party. |
| eds/internal/mocks/mock_InstrumentHoldingStore.go | Updates mocks to include ListHoldings. |
| eds/internal/mocks/mock_ActiveContractStore.go | Updates mocks for GetByTemplateId returning a slice. |
| eds/internal/api/tokenpool/preapproval.go | Adapts to GetByTemplateId returning multiple results. |
| eds/internal/api/token_standard/token_standard.go | Implements TokenMetadataV1 fields (name/symbol/totalSupply/asOf) with caching. |
| eds/internal/api/token_standard/token_standard_test.go | Adds unit tests for totalSupply calculation/caching and metadata fields. |
| eds/config/config.go | Adds registry name/symbol and supply cache timeout configuration (with defaults). |
Files not reviewed (2)
- eds/internal/mocks/mock_ActiveContractStore.go: Generated file
- eds/internal/mocks/mock_InstrumentHoldingStore.go: Generated file
Comments suppressed due to low confidence (1)
eds/internal/store/active_contract_store.go:232
- Same as onActiveContract: the package-name TemplateID index isn't populated, so GetByTemplateId calls that use the "#packageName" syntax won't find created contracts.
contractsByPackageName, ok := contractsForParty[templateIdPackageName]
if !ok {
contractsByPackageName = make(map[types.CONTRACT_ID]*apiv2.ActiveContract)
contractsForParty[templateIdPackageName] = contractsByPackageName
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| snapshotTime := time.Now() | ||
| holdings, _ := s.instrumentHoldingStore.ListHoldings(splice_api_token_holding_v1.InstrumentId{ | ||
| Admin: s.admin, | ||
| Id: types.TEXT(instrumentId), | ||
| }) | ||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Files not reviewed (2)
- eds/internal/mocks/mock_ActiveContractStore.go: Generated file
- eds/internal/mocks/mock_InstrumentHoldingStore.go: Generated file
Comments suppressed due to low confidence (12)
eds/internal/store/active_contract_store.go:180
contractsByTemplateIdstores a map of contract IDs, but the PackageName key path never inserts the active contract intocontractsByPackageName, so lookups by#packageNamewill always return an empty result.
contractsByPackageName, ok := contractsForParty[templateIdPackageName]
if !ok {
contractsByPackageName = make(map[types.CONTRACT_ID]*apiv2.ActiveContract)
contractsForParty[templateIdPackageName] = contractsByPackageName
}
eds/internal/store/active_contract_store.go:232
- Same issue as in
onActiveContract: the PackageName key path initializes the map but never inserts the created contract, soGetByTemplateIdusing the#packageNameform will not work.
contractsByPackageName, ok := contractsForParty[templateIdPackageName]
if !ok {
contractsByPackageName = make(map[types.CONTRACT_ID]*apiv2.ActiveContract)
contractsForParty[templateIdPackageName] = contractsByPackageName
}
eds/internal/store/active_contract_store.go:25
- Now that
contractsByTemplateIdcan hold multiple contracts per (party, templateId), the archive path must delete only the archived contract ID from the inner map (and clean up empty inner maps). The current archive logic still deletes the entire template entry, which will incorrectly remove other active contracts of the same template for that party.
contractsByTemplateId map[types.PARTY]map[contracts.TemplateID]map[types.CONTRACT_ID]*apiv2.ActiveContract
eds/internal/store/instrument_store.go:117
ListHoldingsreturns an error when the instrument has no holdings. For a listing API, "no results" should generally be represented as an empty slice withnilerror so callers can treat it as zero supply instead of an exceptional condition.
instrumentHoldings, ok := s.holdings[instrumentId]
if !ok {
return nil, fmt.Errorf("no holdings found for instrument %v", instrumentId)
}
eds/internal/api/token_standard/token_standard.go:276
getTotalSupplyForInstrumentignores theListHoldingserror. This can silently return an incorrect totalSupply (e.g., if the holding store isn't running or the query fails).
holdings, _ := s.instrumentHoldingStore.ListHoldings(splice_api_token_holding_v1.InstrumentId{
Admin: s.admin,
Id: types.TEXT(instrumentId),
})
eds/internal/api/token_standard/token_standard.go:295
GetByTemplateId's boolean return value is ignored here. If no LockedLinkHolding contracts are present, treat it as an empty set explicitly so the intent is clear (and avoid accidentally looping over a nil slice produced by other implementations).
// List LockLinkHoldings to also sum up
lockedHoldings, _ := s.activeContractStore.GetByTemplateId(s.admin, contracts.TemplateIDFromBinding(link.LockedLinkHolding{}))
for _, activeContract := range lockedHoldings {
eds/internal/api/token_standard/token_standard.go:277
- Summing amounts using
big.Floatcan introduce rounding artifacts for decimal strings (e.g., 0.1-style values), which can lead to incorrecttotalSupplyoutput. Prefer summing using an exact representation (e.g.,big.Rat, or fixed-pointbig.Intin 1e10 smallest units for LINK) and then format deterministically.
totalSupply := new(big.Float)
eds/internal/api/tokenpool/preapproval.go:32
- If multiple preapproval contracts exist for the same (party, templateId), returning
activePreapprovals[0]is non-deterministic (map iteration order) and may pick an arbitrary preapproval. Consider failing fast when more than one is present, or selecting deterministically.
activePreapprovals, ok := acs.GetByTemplateId(party, templateId)
if !ok || len(activePreapprovals) == 0 {
return "", nil, fmt.Errorf("no preapproval found for user %s and template %s", party, templateId.String())
}
integration-tests/ccip/ccip_send_with_token_bnm_test.go:1083
- Same as above: this log dereferences
TotalSupply/TotalSupplyAsOfwithout nil checks, which can panic and make the test failure harder to diagnose.
require.Equal(t, http.StatusOK, linkMetadata.StatusCode(), "expected 200 OK from TokenMetadata API for LINK instrument")
t.Logf("Queried Token Metadata: id: %v, symbol: %v, name: %v, totalSupply: %v, totalSupplyAsOf: %v",
linkMetadata.JSON200.Id,
linkMetadata.JSON200.Symbol,
linkMetadata.JSON200.Name,
eds/service/service.go:184
- The TokenStandard API now depends on
instrumentHoldingStoreto computetotalSupply, butinstrumentHoldingStore.Run(...)is only started whenTokenPoolAPIConfig.Enabledis true. If TokenStandard is enabled without TokenPool,totalSupplywill be wrong (and may always compute as 0).
if cfg.TokenStandardAPIConfig.Enabled {
tokenStandardAPIServer, err := token_standard.NewServer(ctx, logger, activeContractStore, instrumentHoldingStore, cfg.TokenStandardAPIConfig)
if err != nil {
integration-tests/ccip/ccip_send_with_token_bnm_test.go:777
- This test dereferences
TotalSupply/TotalSupplyAsOfwithout asserting they are present. If the API returns these fields as null (orJSON200is nil), the test will panic instead of producing a helpful failure.
This issue also appears on line 1079 of the same file.
require.Equal(t, http.StatusOK, linkMetadata.StatusCode(), "expected 200 OK from TokenMetadata API for LINK instrument")
t.Logf("Queried Token Metadata: id: %v, symbol: %v, name: %v, totalSupply: %v, totalSupplyAsOf: %v",
linkMetadata.JSON200.Id,
linkMetadata.JSON200.Symbol,
linkMetadata.JSON200.Name,
integration-tests/ccip/ccip_send_with_token_bnm_test.go:824
tokenTransferAmountDecimalwas changed to1.0, but later comments still describe the old0.0000010000 → 10,000 smallest unitsscenario. Updating those comments will keep the test reasoning aligned with the new amount assertions.
const tokenTransferAmountDecimal = "1.0"
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Files not reviewed (2)
- eds/internal/mocks/mock_ActiveContractStore.go: Generated file
- eds/internal/mocks/mock_InstrumentHoldingStore.go: Generated file
Comments suppressed due to low confidence (6)
eds/internal/api/token_standard/token_standard.go:275
- getTotalSupplyForInstrument ignores the error returned by InstrumentHoldingStore.ListHoldings, which can mask store/stream failures and lead to silently incorrect TotalSupply values.
holdings, _ := s.instrumentHoldingStore.ListHoldings(splice_api_token_holding_v1.InstrumentId{
Admin: s.admin,
Id: types.TEXT(instrumentId),
})
eds/internal/api/token_standard/token_standard.go:281
- TotalSupply is accumulated using math/big.Float, which is a binary floating point type and can introduce rounding artifacts when summing decimal strings (e.g., 49.05 may serialize as 49.049999…); this can make the TokenMetadataV1 API return incorrect values and cause flaky tests.
totalSupply := new(big.Float)
switch tokenConfig.Type {
case config.TokenTypeLINK:
// Iterate over all LINK holdings and sum them up
for i, holding := range holdings {
integration-tests/ccip/ccip_send_with_token_bnm_test.go:1091
- Same issue as above: the second TokenMetadata query dereferences JSON200/TotalSupply pointers without nil checks, which can panic if the response body isn't decoded into JSON200.
// Query EDS' TokenMetadata API again, checking that totalSupply has decreased
linkMetadata, err = tokenMetadataAPIClient.GetInstrumentWithResponse(t.Context(), string(linkInstrumentId.Id))
require.NoError(t, err)
require.Equal(t, http.StatusOK, linkMetadata.StatusCode(), "expected 200 OK from TokenMetadata API for LINK instrument")
t.Logf("Queried Token Metadata: id: %v, symbol: %v, name: %v, totalSupply: %v, totalSupplyAsOf: %v",
linkMetadata.JSON200.Id,
linkMetadata.JSON200.Symbol,
linkMetadata.JSON200.Name,
*linkMetadata.JSON200.TotalSupply,
*linkMetadata.JSON200.TotalSupplyAsOf,
)
// Started with 50 LINK
// - 1 LINK bridged
// + 0.05 LINK pool feeBps retained by sender's pool (poolOwner == sender)
// = 49.05 LINK remaining in total supply
require.Equal(t, "49.05", *linkMetadata.JSON200.TotalSupply)
eds/internal/api/token_standard/token_standard.go:267
- getTotalSupplyForInstrument holds tokenSupplyMux for the entire cache-miss calculation (including listing/cloning all holdings). This serializes concurrent requests across all instruments and can significantly reduce throughput under load; the lock should only guard cache reads/writes, not the expensive computation.
s.tokenSupplyMux.Lock()
defer s.tokenSupplyMux.Unlock()
// Check cache first
cachedSupply, ok := s.tokenSupplies[instrumentId]
if ok && time.Since(cachedSupply.UpdatedAt) < s.tokenSupplyCacheTimeout {
return cachedSupply, nil
}
eds/internal/store/active_contract_store.go:283
- onArchivedEvent deletes contract IDs from the nested template maps but never removes now-empty maps. This can leave empty entries such that GetByTemplateId returns (emptySlice, true), which contradicts the method comment ('if any exist') and forces all callers to defensively check len().
// Delete by PackageId
contractsByPackageId := contractsForParty[contracts.TemplateID{
PackageID: archivedEvent.GetTemplateId().GetPackageId(),
ModuleName: archivedEvent.GetTemplateId().GetModuleName(),
EntityName: archivedEvent.GetTemplateId().GetEntityName(),
}]
delete(contractsByPackageId, types.CONTRACT_ID(archivedEvent.GetContractId()))
// Delete by PackageName
contractsByPackageName := contractsForParty[contracts.TemplateID{
PackageID: fmt.Sprintf("#%s", archivedEvent.GetPackageName()),
ModuleName: archivedEvent.GetTemplateId().GetModuleName(),
EntityName: archivedEvent.GetTemplateId().GetEntityName(),
}]
delete(contractsByPackageName, types.CONTRACT_ID(archivedEvent.GetContractId()))
integration-tests/ccip/ccip_send_with_token_bnm_test.go:779
- The test dereferences linkMetadata.JSON200 / TotalSupply / TotalSupplyAsOf without nil checks. OpenAPI clients can return StatusCode==200 with JSON200==nil if decoding fails (e.g., unexpected content-type), which would panic and make this test harder to diagnose. Also, a 1s timestamp tolerance is likely flaky under CI load.
This issue also appears on line 1076 of the same file.
// Query EDS' TokenMetadata API
linkMetadata, err := tokenMetadataAPIClient.GetInstrumentWithResponse(t.Context(), string(linkInstrumentId.Id))
require.NoError(t, err)
require.Equal(t, http.StatusOK, linkMetadata.StatusCode(), "expected 200 OK from TokenMetadata API for LINK instrument")
t.Logf("Queried Token Metadata: id: %v, symbol: %v, name: %v, totalSupply: %v, totalSupplyAsOf: %v",
linkMetadata.JSON200.Id,
linkMetadata.JSON200.Symbol,
linkMetadata.JSON200.Name,
*linkMetadata.JSON200.TotalSupply,
*linkMetadata.JSON200.TotalSupplyAsOf,
)
require.Equal(t, "50", *linkMetadata.JSON200.TotalSupply)
require.WithinDuration(t, time.Now(), *linkMetadata.JSON200.TotalSupplyAsOf, time.Second, "expected LastUpdated to be recent")
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Files not reviewed (2)
- eds/internal/mocks/mock_ActiveContractStore.go: Generated file
- eds/internal/mocks/mock_InstrumentHoldingStore.go: Generated file
Comments suppressed due to low confidence (3)
eds/internal/api/token_standard/token_standard.go:297
- Locked LINK holdings are summed without checking
lockedInstrumentId, so if multiple LINK instruments are configured, the total supply for each instrument will incorrectly include all locked holdings. Also theGetByTemplateIdboolean result is discarded, making it harder to distinguish "none" from "not tracked".
// List LockLinkHoldings to also sum up
lockedHoldings, _ := s.activeContractStore.GetByTemplateId(s.admin, contracts.TemplateIDFromBinding(link.LockedLinkHolding{}))
for _, activeContract := range lockedHoldings {
parsedLockedHolding, err := bindings.UnmarshalCreatedEvent[link.LockedLinkHolding](activeContract.GetCreatedEvent())
if err != nil {
eds/internal/api/token_standard/token_standard.go:276
ListHoldingsreturns an error, but it is currently ignored. If the holding store fails (e.g., stream not ready / backend error), this will silently produce an incorrect total supply and still cache it.
holdings, _ := s.instrumentHoldingStore.ListHoldings(splice_api_token_holding_v1.InstrumentId{
Admin: s.admin,
Id: types.TEXT(instrumentId),
})
eds/internal/api/token_standard/token_standard.go:266
getTotalSupplyForInstrumentholdstokenSupplyMuxfor the entire calculation (including store reads and protobuf unmarshalling). This serializes all token metadata requests and can become a bottleneck as holdings grow; consider locking only around cache read/write (compute outside the critical section) or using an RWMutex.
s.tokenSupplyMux.Lock()
defer s.tokenSupplyMux.Unlock()
// Check cache first
cachedSupply, ok := s.tokenSupplies[instrumentId]
if ok && time.Since(cachedSupply.UpdatedAt) < s.tokenSupplyCacheTimeout {
return cachedSupply, nil
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated 1 comment.
Files not reviewed (2)
- eds/internal/mocks/mock_ActiveContractStore.go: Generated file
- eds/internal/mocks/mock_InstrumentHoldingStore.go: Generated file
Comments suppressed due to low confidence (3)
eds/internal/api/token_standard/token_standard.go:276
- The error return from
instrumentHoldingStore.ListHoldingsis ignored, which can silently produce an incorrect totalSupply (e.g., if the store fails). Propagate the error so the handler can return 500 as intended.
holdings, _ := s.instrumentHoldingStore.ListHoldings(splice_api_token_holding_v1.InstrumentId{
Admin: s.admin,
Id: types.TEXT(instrumentId),
})
eds/internal/api/token_standard/token_standard.go:319
- The local variable name
tokenSupply := tokenSupply{...}shadows thetokenSupplytype name, making the code harder to read and grep. Rename the variable to avoid shadowing.
// Cache totalSupply
tokenSupply := tokenSupply{
UpdatedAt: snapshotTime,
TotalSupply: totalSupply.FloatString(10), // 10 decimal places
}
s.tokenSupplies[instrumentId] = tokenSupply
eds/internal/api/token_standard/token_standard_test.go:153
- This comment is misleading: the test mocks TotalSupply to be 100.0 (the mocked LockedLinkHolding is for instrumentId "LINK" and is explicitly ignored), not "100+1+2".
for _, instrument := range resp.JSON200.Instruments {
// Validate that TotalSupply is calculated for each instrument (mocked to be 100+1+2)
require.NotNil(t, instrument.TotalSupply)
require.Equal(t, "100.0000000000", *instrument.TotalSupply)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Files not reviewed (2)
- eds/internal/mocks/mock_ActiveContractStore.go: Generated file
- eds/internal/mocks/mock_InstrumentHoldingStore.go: Generated file
Comments suppressed due to low confidence (2)
eds/internal/api/token_standard/token_standard.go:275
ListHoldingsreturns an error but it’s currently ignored; failures will silently produce an incorrect TotalSupply (likely 0) and mask underlying store/stream issues.
holdings, _ := s.instrumentHoldingStore.ListHoldings(splice_api_token_holding_v1.InstrumentId{
Admin: s.admin,
Id: types.TEXT(instrumentId),
})
eds/internal/api/token_standard/token_standard.go:224
nextPageToken = new(strconv.Itoa(endIndex))does not compile in Go (the built-innewexpects a type, not a value). Create a local string and take its address instead.
var nextPageToken *string
if endIndex < len(sortedTokenConfigs) {
nextPageToken = new(strconv.Itoa(endIndex))
}
| mux sync.RWMutex | ||
| contractsByInstanceAddress map[contracts.InstanceAddress]*apiv2.ActiveContract | ||
| contractsByTemplateId map[types.PARTY]map[contracts.TemplateID]*apiv2.ActiveContract | ||
| contractsByTemplateId map[types.PARTY]map[contracts.TemplateID]map[types.CONTRACT_ID]*apiv2.ActiveContract |
There was a problem hiding this comment.
Adding an additional map keyed by ContractId here. Previously, only a single contract of each type would have been stored by party, but we need the ability to list all contracts of a given type.
| // Party -> InstrumentId -> ContractId -> Holding | ||
| holdings map[types.PARTY]map[splice_api_token_holding_v1.InstrumentId]map[types.CONTRACT_ID]*apiv2.ActiveContract | ||
| // InstrumentId -> Party -> ContractId -> Holding | ||
| holdings map[splice_api_token_holding_v1.InstrumentId]map[types.PARTY]map[types.CONTRACT_ID]*apiv2.ActiveContract |
There was a problem hiding this comment.
Reordering these maps to have the InstrumentId at the highest level. There will be more parties than there will be different instruments, and listing will be done per-instrument, not per-party.
This pull request introduces several improvements to the Token Standard API, focusing on enhanced instrument metadata, accurate and efficient total supply calculation with caching, and expanded test coverage. The main changes add support for instrument
nameandsymbol, implement a cache for total supply values to reduce expensive computations, and update tests to cover these new features.Instrument metadata and supply calculation improvements:
TokenNameandTokenSymbolfields to theRegistryandTokenConfigstructs, allowing each token to have a display name and symbol. These are now included in the API responses for instrument metadata. [1] [2] [3]ListInstrumentsandGetInstrumentendpoints to includename,symbol, andtotalSupply(with timestamp) in their responses, providing richer and more accurate metadata for clients. [1] [2]Total supply caching and calculation:
SupplyCacheTimeoutfield (default 10 seconds), to avoid repeated expensive calculations. The cache is thread-safe and automatically refreshed as needed. [1] [2] [3] [4] [5]Test and codebase updates:
token_standard_test.goto verify correct calculation of total supply, proper caching behavior, and inclusion of name/symbol in API responses. Tests also ensure that locked holdings for other instruments are correctly ignored. [1] [2] [3] [4] [5]InstrumentHoldingStoreand properly register parties, ensuring accurate test and production behavior. [1] [2] [3] [4] [5] [6]These changes collectively improve the API's usability, correctness, and performance when serving token instrument metadata.