diff --git a/api/syscoin_asset_metadata_test.go b/api/syscoin_asset_metadata_test.go new file mode 100644 index 0000000000..a533f7253d --- /dev/null +++ b/api/syscoin_asset_metadata_test.go @@ -0,0 +1,30 @@ +package api + +import "testing" + +func TestDecodeSyscoinBridgeAssetMetadata(t *testing.T) { + metadata := decodeSyscoinBridgeAssetMetadata("7", []byte(`{"description":"ERC20 Token","assetType":"ERC20","originDecimals":2}`)) + if metadata.Description != "ERC20 Token" || metadata.AssetType != "ERC20" { + t.Fatalf("decoded metadata = %+v", metadata) + } + if metadata.OriginDecimals == nil || *metadata.OriginDecimals != 2 { + t.Fatalf("origin decimals = %v, want 2", metadata.OriginDecimals) + } +} + +func TestDecodeSyscoinBridgeAssetMetadataPreservesLegacyText(t *testing.T) { + metadata := decodeSyscoinBridgeAssetMetadata("7", []byte("legacy metadata")) + if metadata.Description != "legacy metadata" { + t.Fatalf("description = %q, want legacy metadata", metadata.Description) + } + if metadata.AssetType != "" || metadata.OriginDecimals != nil || metadata.TokenID != "" { + t.Fatalf("legacy metadata unexpectedly gained bridge fields: %+v", metadata) + } +} + +func TestDecodeSyscoinBridgeAssetMetadataRecognizesLegacySYSX(t *testing.T) { + metadata := decodeSyscoinBridgeAssetMetadata("123456", []byte("Syscoin Native Asset")) + if metadata.AssetType != "SYSX" || metadata.Description != "Syscoin Native Asset" { + t.Fatalf("legacy SYSX metadata = %+v", metadata) + } +} diff --git a/api/types.go b/api/types.go index 7539ff641e..8ea89f3903 100644 --- a/api/types.go +++ b/api/types.go @@ -147,24 +147,30 @@ type AssetInfo struct { // SYSCOIN: AssetSpecific contains Syscoin SPT metadata exposed by asset // endpoints and explorer pages. type AssetSpecific struct { - AssetGuid string `json:"assetGuid" ts_doc:"Syscoin SPT asset GUID."` - Contract string `json:"contract,omitempty" ts_doc:"NEVM contract address associated with this asset, if any."` - Symbol string `json:"symbol" ts_doc:"Asset symbol."` - TotalSupply *Amount `json:"totalSupply" ts_doc:"Current total supply in base units."` - MaxSupply *Amount `json:"maxSupply" ts_doc:"Maximum supply in base units."` - Decimals int `json:"decimals" ts_doc:"Number of asset decimal places."` - MetaData string `json:"metaData,omitempty" ts_doc:"Raw Syscoin asset metadata."` + AssetGuid string `json:"assetGuid" ts_doc:"Syscoin SPT asset GUID."` + AssetType string `json:"assetType,omitempty" ts_doc:"Origin bridge asset standard (SYSX, ERC20, ERC721, or ERC1155)."` + Contract string `json:"contract,omitempty" ts_doc:"NEVM contract address associated with this asset, if any."` + Symbol string `json:"symbol" ts_doc:"Asset symbol."` + TotalSupply *Amount `json:"totalSupply" ts_doc:"Current total supply in base units."` + MaxSupply *Amount `json:"maxSupply" ts_doc:"Maximum supply in base units."` + Decimals int `json:"decimals" ts_doc:"Number of asset decimal places."` + OriginDecimals *int `json:"originDecimals,omitempty" ts_doc:"Decimals registered by the bridge for the origin asset."` + TokenID string `json:"tokenId,omitempty" ts_doc:"Origin token ID for ERC721 and ERC1155 assets."` + MetaData string `json:"metaData,omitempty" ts_doc:"Raw Syscoin asset metadata."` } // SYSCOIN: AssetsSpecific is the compact Syscoin SPT row used by asset search. type AssetsSpecific struct { - AssetGuid string `json:"assetGuid" ts_doc:"Syscoin SPT asset GUID."` - Contract string `json:"contract" ts_doc:"NEVM contract address associated with this asset, if any."` - Symbol string `json:"symbol" ts_doc:"Asset symbol."` - TotalSupply *Amount `json:"totalSupply" ts_doc:"Current total supply in base units."` - Decimals int `json:"precision" ts_doc:"Number of asset decimal places."` - Txs int `json:"txs" ts_doc:"Number of indexed transactions touching this asset."` - MetaData string `json:"metaData,omitempty" ts_doc:"Raw Syscoin asset metadata."` + AssetGuid string `json:"assetGuid" ts_doc:"Syscoin SPT asset GUID."` + AssetType string `json:"assetType,omitempty" ts_doc:"Origin bridge asset standard (SYSX, ERC20, ERC721, or ERC1155)."` + Contract string `json:"contract" ts_doc:"NEVM contract address associated with this asset, if any."` + Symbol string `json:"symbol" ts_doc:"Asset symbol."` + TotalSupply *Amount `json:"totalSupply" ts_doc:"Current total supply in base units."` + Decimals int `json:"precision" ts_doc:"Number of asset decimal places."` + OriginDecimals *int `json:"originDecimals,omitempty" ts_doc:"Decimals registered by the bridge for the origin asset."` + TokenID string `json:"tokenId,omitempty" ts_doc:"Origin token ID for ERC721 and ERC1155 assets."` + Txs int `json:"txs" ts_doc:"Number of indexed transactions touching this asset."` + MetaData string `json:"metaData,omitempty" ts_doc:"Raw Syscoin asset metadata."` } // Vin contains information about single transaction input @@ -286,6 +292,9 @@ type Token struct { UnconfirmedBalanceSat *Amount `json:"unconfirmedBalance,omitempty" ts_doc:"Unconfirmed token balance delta."` UnconfirmedTransfers int `json:"unconfirmedTransfers,omitempty" ts_doc:"Number of unconfirmed Syscoin SPT transfers."` AssetGuid string `json:"assetGuid,omitempty" ts_doc:"Syscoin SPT asset GUID."` + AssetType string `json:"assetType,omitempty" ts_doc:"Origin bridge asset standard for a Syscoin SPT."` + OriginDecimals *int `json:"originDecimals,omitempty" ts_doc:"Decimals registered by the bridge for the origin asset."` + TokenID string `json:"tokenId,omitempty" ts_doc:"Origin token ID for bridged ERC721 and ERC1155 assets."` Protocols TokenProtocols `json:"protocols,omitempty" ts_type:"string[]" ts_doc:"Protocol identifiers the contract participates in (e.g., \"erc4626\"); for fresh per-vault data, use getContractInfo."` ContractIndex string `json:"-"` } diff --git a/api/worker.go b/api/worker.go index bb9b6c5bc9..82f6abcbf2 100644 --- a/api/worker.go +++ b/api/worker.go @@ -78,6 +78,32 @@ type syscoinAssetMaskParser interface { GetAssetsMaskFromVersion(nVersion int32) bchain.AssetsMask } +type syscoinBridgeAssetMetadata struct { + Description string `json:"description"` + AssetType string `json:"assetType"` + OriginDecimals *int `json:"originDecimals,omitempty"` + TokenID string `json:"tokenId,omitempty"` +} + +func decodeSyscoinBridgeAssetMetadata(assetGuid string, raw []byte) syscoinBridgeAssetMetadata { + metadata := syscoinBridgeAssetMetadata{Description: string(raw)} + if len(raw) == 0 { + if assetGuid == "123456" { + metadata.AssetType = "SYSX" + } + return metadata + } + + var decoded syscoinBridgeAssetMetadata + if err := json.Unmarshal(raw, &decoded); err != nil || decoded.AssetType == "" { + if assetGuid == "123456" { + metadata.AssetType = "SYSX" + } + return metadata + } + return decoded +} + // SYSCOIN func assetTypeFromVersion(parser bchain.BlockChainParser, version int32) *bchain.TokenType { p, ok := parser.(syscoinAssetTypeParser) @@ -200,6 +226,11 @@ func (w *Worker) getSyscoinAddressAssetTokens(address string, balances map[uint6 dbAsset.AssetObj.Symbol = []byte(assetGuid) dbAsset.AssetObj.Precision = 8 } + metadata := decodeSyscoinBridgeAssetMetadata(assetGuid, dbAsset.MetaData) + contract := "" + if len(dbAsset.AssetObj.Contract) > 0 { + contract = ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex() + } totalReceived := new(big.Int).Add(balance.BalanceSat, balance.SentSat) var unconfirmed *Amount unconfirmedTransfers := 0 @@ -220,6 +251,10 @@ func (w *Worker) getSyscoinAddressAssetTokens(address string, balances map[uint6 TotalReceivedSat: (*Amount)(totalReceived), TotalSentSat: (*Amount)(balance.SentSat), AssetGuid: assetGuid, + AssetType: metadata.AssetType, + Contract: contract, + OriginDecimals: metadata.OriginDecimals, + TokenID: metadata.TokenID, Transfers: int(balance.Transfers), }) } @@ -238,6 +273,11 @@ func (w *Worker) getSyscoinAddressAssetTokens(address string, balances map[uint6 dbAsset.AssetObj.Precision = 8 } zero := Amount(*new(big.Int)) + metadata := decodeSyscoinBridgeAssetMetadata(assetGuid, dbAsset.MetaData) + contract := "" + if len(dbAsset.AssetObj.Contract) > 0 { + contract = ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex() + } tokens = append(tokens, Token{ Type: bchain.SPTTokenType, Standard: bchain.SPTTokenType, @@ -250,6 +290,10 @@ func (w *Worker) getSyscoinAddressAssetTokens(address string, balances map[uint6 TotalReceivedSat: &zero, TotalSentSat: &zero, AssetGuid: assetGuid, + AssetType: metadata.AssetType, + Contract: contract, + OriginDecimals: metadata.OriginDecimals, + TokenID: metadata.TokenID, }) } sort.Sort(tokens) @@ -1236,6 +1280,8 @@ func (w *Worker) FindAssetsFromFilter(filter string) []*AssetsSpecific { filterLower := strings.ToLower(strings.ReplaceAll(filter, "0x", "")) assetDetails := make([]*AssetsSpecific, 0) for guid, assetCached := range *w.db.GetAssetCache() { + assetGuid := strconv.FormatUint(guid, 10) + metadata := decodeSyscoinBridgeAssetMetadata(assetGuid, assetCached.MetaData) symbol := string(assetCached.AssetObj.Symbol) symbolLower := strings.ToLower(symbol) foundAsset := strings.Contains(symbolLower, filterLower) @@ -1251,13 +1297,16 @@ func (w *Worker) FindAssetsFromFilter(filter string) []*AssetsSpecific { } txs := int(assetCached.Transactions) assetDetails = append(assetDetails, &AssetsSpecific{ - AssetGuid: strconv.FormatUint(guid, 10), - Symbol: symbol, - Contract: contract, - TotalSupply: (*Amount)(big.NewInt(assetCached.AssetObj.TotalSupply)), - Decimals: int(assetCached.AssetObj.Precision), - Txs: txs, - MetaData: string(assetCached.MetaData), + AssetGuid: assetGuid, + AssetType: metadata.AssetType, + Symbol: symbol, + Contract: contract, + TotalSupply: (*Amount)(big.NewInt(assetCached.AssetObj.TotalSupply)), + Decimals: int(assetCached.AssetObj.Precision), + Txs: txs, + OriginDecimals: metadata.OriginDecimals, + TokenID: metadata.TokenID, + MetaData: metadata.Description, }) } sort.Slice(assetDetails, func(i, j int) bool { @@ -1431,15 +1480,19 @@ func (w *Worker) GetAsset(asset string, page int, txsOnPage int, option AccountD if len(dbAsset.AssetObj.Contract) > 0 { contract = ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex() } + metadata := decodeSyscoinBridgeAssetMetadata(asset, dbAsset.MetaData) r := &Asset{ AssetDetails: &AssetSpecific{ - AssetGuid: asset, - Symbol: string(dbAsset.AssetObj.Symbol), - Contract: contract, - TotalSupply: (*Amount)(big.NewInt(dbAsset.AssetObj.TotalSupply)), - MaxSupply: (*Amount)(big.NewInt(dbAsset.AssetObj.MaxSupply)), - Decimals: int(dbAsset.AssetObj.Precision), - MetaData: string(dbAsset.MetaData), + AssetGuid: asset, + AssetType: metadata.AssetType, + Symbol: string(dbAsset.AssetObj.Symbol), + Contract: contract, + TotalSupply: (*Amount)(big.NewInt(dbAsset.AssetObj.TotalSupply)), + MaxSupply: (*Amount)(big.NewInt(dbAsset.AssetObj.MaxSupply)), + Decimals: int(dbAsset.AssetObj.Precision), + OriginDecimals: metadata.OriginDecimals, + TokenID: metadata.TokenID, + MetaData: metadata.Description, }, Paging: pg, UnconfirmedBalanceSat: (*Amount)(&uBalSat), diff --git a/bchain/coins/sys/nevm.go b/bchain/coins/sys/nevm.go index c7ee7bc16b..1638739834 100644 --- a/bchain/coins/sys/nevm.go +++ b/bchain/coins/sys/nevm.go @@ -2,10 +2,12 @@ package syscoin import ( "context" + "encoding/json" "fmt" "math/big" "strings" "time" + "unicode" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -17,11 +19,39 @@ import ( "github.com/trezor/blockbook/bchain/coins/btc" ) +type bridgeAssetMetadata struct { + Description string `json:"description"` + AssetType string `json:"assetType"` + OriginDecimals *uint8 `json:"originDecimals,omitempty"` + TokenID string `json:"tokenId,omitempty"` +} + +func encodeBridgeAssetMetadata(metadata bridgeAssetMetadata) ([]byte, error) { + return json.Marshal(metadata) +} + const ( - vaultManagerAddress = "0x7904299b3D3dC1b03d1DdEb45E9fDF3576aCBd5f" - defaultNEVMRPCTimeoutSec = 15 + // Syscoin 5 Bridge V2 registry. Legacy pre-cutover SPT metadata is not indexed. + vaultManagerAddress = "0x28bD37C0926575f2568ea8f297c0745EF16174Ab" + defaultNEVMRPCTimeoutSec = 15 + maxBridgeTokenSymbolRunes = 32 ) +func sanitizeBridgeTokenSymbol(symbol string) string { + sanitized := strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + return -1 + } + return r + }, symbol) + sanitized = strings.TrimSpace(sanitized) + runes := []rune(sanitized) + if len(runes) > maxBridgeTokenSymbolRunes { + runes = runes[:maxBridgeTokenSymbolRunes] + } + return string(runes) +} + type NEVMClient struct { rpcClient *ethclient.Client backupClient *ethclient.Client @@ -151,11 +181,18 @@ func (c *NEVMClient) getTokenSymbol(contractAddr common.Address) (string, error) return "", err } - return unpacked[0].(string), nil + return sanitizeBridgeTokenSymbol(unpacked[0].(string)), nil } func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, error) { if assetGuid == 123456 { + metadata, err := encodeBridgeAssetMetadata(bridgeAssetMetadata{ + Description: "Syscoin Native Asset", + AssetType: "SYSX", + }) + if err != nil { + return nil, err + } return &bchain.Asset{ Transactions: 0, AssetObj: wire.AssetType{ @@ -165,7 +202,7 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err TotalSupply: 0, MaxSupply: 0, }, - MetaData: []byte("Syscoin Native Asset"), + MetaData: metadata, }, nil } @@ -195,7 +232,8 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err return nil, err } - var symbol, metadata string + var symbol string + metadata := bridgeAssetMetadata{} contractAddr := registry.AssetContract precision := registry.Precision switch registry.AssetType { @@ -204,7 +242,9 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err if err != nil || symbol == "" { symbol = fmt.Sprintf("ERC20-%d", assetId) } - metadata = "ERC20 Token" + metadata.Description = "ERC20 Token" + metadata.AssetType = "ERC20" + metadata.OriginDecimals = ®istry.Precision // SYSCOIN: UTXO-side SPT values are stored and exposed as CAmount/COIN // units, matching Core's 8-decimal asset_value formatting. precision = 8 @@ -218,7 +258,11 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err if err != nil || symbol == "" { symbol = fmt.Sprintf("ERC721-%d", assetId) } - metadata = fmt.Sprintf("ERC721 NFT Token ID %s", realTokenId.String()) + metadata.Description = fmt.Sprintf("ERC721 NFT Token ID %s", realTokenId.String()) + metadata.AssetType = "ERC721" + metadata.TokenID = realTokenId.String() + originDecimals := uint8(0) + metadata.OriginDecimals = &originDecimals precision = 0 case 4: // ERC1155 @@ -227,13 +271,22 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err return nil, err } symbol = fmt.Sprintf("ERC1155-%d", assetId) - metadata = fmt.Sprintf("ERC1155 Token ID %s", realTokenId.String()) + metadata.Description = fmt.Sprintf("ERC1155 Token ID %s", realTokenId.String()) + metadata.AssetType = "ERC1155" + metadata.TokenID = realTokenId.String() + originDecimals := uint8(0) + metadata.OriginDecimals = &originDecimals precision = 0 default: return nil, fmt.Errorf("unsupported NEVM asset type %d for asset %d", registry.AssetType, assetId) } + metadataJSON, err := encodeBridgeAssetMetadata(metadata) + if err != nil { + return nil, err + } + return &bchain.Asset{ Transactions: 0, AssetObj: wire.AssetType{ @@ -243,6 +296,6 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err TotalSupply: 0, MaxSupply: 0, }, - MetaData: []byte(metadata), + MetaData: metadataJSON, }, nil } diff --git a/bchain/coins/sys/nevm_test.go b/bchain/coins/sys/nevm_test.go new file mode 100644 index 0000000000..5dd0ebb52e --- /dev/null +++ b/bchain/coins/sys/nevm_test.go @@ -0,0 +1,70 @@ +package syscoin + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestVaultManagerAddressUsesBridgeV2(t *testing.T) { + want := common.HexToAddress("0x28bD37C0926575f2568ea8f297c0745EF16174Ab") + if got := common.HexToAddress(vaultManagerAddress); got != want { + t.Fatalf("vault manager address = %s, want Bridge V2 %s", got.Hex(), want.Hex()) + } +} + +func TestFetchNEVMAssetDetailsSYSXDoesNotCallVault(t *testing.T) { + asset, err := (&NEVMClient{}).FetchNEVMAssetDetails(123456) + if err != nil { + t.Fatal(err) + } + if got := string(asset.AssetObj.Symbol); got != "SYSX" { + t.Fatalf("SYSX symbol = %q, want SYSX", got) + } + if got := asset.AssetObj.Precision; got != 8 { + t.Fatalf("SYSX precision = %d, want 8", got) + } + var metadata bridgeAssetMetadata + if err := json.Unmarshal(asset.MetaData, &metadata); err != nil { + t.Fatal(err) + } + if metadata.AssetType != "SYSX" { + t.Fatalf("SYSX asset type = %q, want SYSX", metadata.AssetType) + } +} + +func TestEncodeBridgeAssetMetadataPreservesZeroOriginDecimals(t *testing.T) { + originDecimals := uint8(0) + encoded, err := encodeBridgeAssetMetadata(bridgeAssetMetadata{ + Description: "ERC1155 Token ID 7", + AssetType: "ERC1155", + OriginDecimals: &originDecimals, + TokenID: "7", + }) + if err != nil { + t.Fatal(err) + } + + var decoded bridgeAssetMetadata + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.OriginDecimals == nil || *decoded.OriginDecimals != 0 { + t.Fatalf("origin decimals = %v, want explicit zero", decoded.OriginDecimals) + } + if decoded.AssetType != "ERC1155" || decoded.TokenID != "7" { + t.Fatalf("decoded metadata = %+v", decoded) + } +} + +func TestSanitizeBridgeTokenSymbolRemovesControlsAndBoundsLength(t *testing.T) { + got := sanitizeBridgeTokenSymbol(" SYS\u202eFAKE\x00" + "1234567890123456789012345678901234567890") + want := "SYSFAKE1234567890123456789012345" + if got != want { + t.Fatalf("sanitized symbol = %q, want %q", got, want) + } + if len([]rune(got)) != maxBridgeTokenSymbolRunes { + t.Fatalf("sanitized symbol length = %d, want %d", len([]rune(got)), maxBridgeTokenSymbolRunes) + } +} diff --git a/blockbook-api.ts b/blockbook-api.ts index 1325f10562..427201f8b9 100644 --- a/blockbook-api.ts +++ b/blockbook-api.ts @@ -357,6 +357,18 @@ export interface Token { totalReceived?: string; /** Total amount of tokens sent. */ totalSent?: string; + /** Unconfirmed token balance delta. */ + unconfirmedBalance?: string; + /** Number of unconfirmed Syscoin SPT transfers. */ + unconfirmedTransfers?: number; + /** Syscoin SPT asset GUID. */ + assetGuid?: string; + /** Origin bridge asset standard for a Syscoin SPT. */ + assetType?: string; + /** Decimals registered by the bridge for the origin asset. */ + originDecimals?: number; + /** Origin token ID for bridged ERC721 and ERC1155 assets. */ + tokenId?: string; /** Protocol identifiers the contract participates in (e.g., "erc4626"); for fresh per-vault data, use getContractInfo. */ protocols?: string[]; } diff --git a/openapi.yaml b/openapi.yaml index 63270c6a6d..f289b590ac 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2284,6 +2284,24 @@ components: $ref: "#/components/schemas/AmountString" totalSent: $ref: "#/components/schemas/AmountString" + unconfirmedBalance: + description: Unconfirmed token balance delta. + $ref: "#/components/schemas/AmountString" + unconfirmedTransfers: + description: Number of unconfirmed Syscoin SPT transfers. + type: integer + assetGuid: + description: Syscoin SPT asset GUID. + type: string + assetType: + description: Origin bridge asset standard for a Syscoin SPT. + type: string + originDecimals: + description: Decimals registered by the bridge for the origin asset. + type: integer + tokenId: + description: Origin token ID for bridged ERC721 and ERC1155 assets. + type: string protocols: type: array description: Indexed protocol identifiers such as erc4626.