Files

239 lines
7.6 KiB
Go

package fireblocks
import (
"time"
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
"github.com/hanzo-fi/payments/ee/plugins/fireblocks/client"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("Fireblocks assets helpers", func() {
Describe("sanitizeSymbol", func() {
DescribeTable("returns a Ledger-valid base or empty",
func(in, want string) {
Expect(sanitizeSymbol(in)).To(Equal(want))
},
Entry("uppercase passthrough", "USDT", "USDT"),
Entry("lowercase uppercased", "xDAI", "XDAI"),
Entry("digit-first prefix stripped", "1INCH", "INCH"),
Entry("punctuation dropped", "BQ5R MY TOKEN", "BQ5RMYTOKEN"),
Entry("hyphens & underscores dropped", "ETH-AETH_SEPOLIA", "ETHAETHSEPOLIA"),
Entry("truncated at 17 chars", "ABCDEFGHIJKLMNOPQRSTUV", "ABCDEFGHIJKLMNOPQ"),
Entry("only digits yields empty", "123", ""),
Entry("empty input", "", ""),
)
})
Describe("canonicalAsset", func() {
It("appends precision when non-zero", func() {
Expect(canonicalAsset("USDT", 6, false)).To(Equal("USDT/6"))
})
It("omits suffix when precision is zero", func() {
Expect(canonicalAsset("JPY", 0, false)).To(Equal("JPY"))
})
It("returns empty when sanitisation fails", func() {
Expect(canonicalAsset("...", 2, false)).To(BeEmpty())
})
It("appends _TEST for testnet assets", func() {
Expect(canonicalAsset("ETH", 18, true)).To(Equal("ETH_TEST/18"))
})
It("preserves the full 17-char base when appending _TEST", func() {
Expect(canonicalAsset("ABCDEFGHIJKLMNOPQ", 18, true)).To(Equal("ABCDEFGHIJKLMNOPQ_TEST/18"))
})
})
Describe("buildAssetInfo", func() {
It("uses onchain.decimals for fungible tokens", func() {
info, ok := buildAssetInfo(client.Asset{
LegacyID: "USDT_ERC20",
DisplaySymbol: "USDT",
BlockchainID: "chain-eth",
AssetClass: client.AssetClassFT,
Onchain: &client.AssetOnchain{
Symbol: "USDT",
Address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
Decimals: 6,
Standards: []string{"ERC20"},
},
}, false)
Expect(ok).To(BeTrue())
Expect(info.Asset).To(Equal("USDT/6"))
Expect(info.Precision).To(Equal(6))
Expect(info.BlockchainID).To(Equal("chain-eth"))
Expect(info.Metadata[MetadataPrefix+"legacy_id"]).To(Equal("USDT_ERC20"))
Expect(info.Metadata[MetadataPrefix+"contract_address"]).To(Equal("0xdAC17F958D2ee523a2206206994597C13D831ec7"))
Expect(info.Metadata[MetadataPrefix+"token_standard"]).To(Equal("ERC20"))
Expect(info.Metadata[MetadataPrefix+"blockchain_id"]).To(Equal("chain-eth"))
})
It("uses top-level decimals for FIAT", func() {
d := 2
info, ok := buildAssetInfo(client.Asset{
LegacyID: "USD",
DisplaySymbol: "USD",
AssetClass: client.AssetClassFiat,
Decimals: &d,
}, false)
Expect(ok).To(BeTrue())
Expect(info.Asset).To(Equal("USD/2"))
})
It("falls back to onchain.symbol when displaySymbol is empty", func() {
info, ok := buildAssetInfo(client.Asset{
LegacyID: "USDC_NEW",
AssetClass: client.AssetClassFT,
Onchain: &client.AssetOnchain{
Symbol: "USDC",
Decimals: 6,
},
}, false)
Expect(ok).To(BeTrue())
Expect(info.Asset).To(Equal("USDC/6"))
})
It("sanitises a digit-first displaySymbol", func() {
info, ok := buildAssetInfo(client.Asset{
LegacyID: "1INCH",
DisplaySymbol: "1INCH",
AssetClass: client.AssetClassFT,
Onchain: &client.AssetOnchain{Decimals: 18},
}, false)
Expect(ok).To(BeTrue())
Expect(info.Asset).To(Equal("INCH/18"))
})
It("emits verified+features metadata flags", func() {
info, ok := buildAssetInfo(client.Asset{
LegacyID: "USDC",
DisplaySymbol: "USDC",
AssetClass: client.AssetClassFT,
Onchain: &client.AssetOnchain{Decimals: 6},
Metadata: &client.AssetSpecMetadata{
Verified: true,
Features: []string{"STABLECOIN"},
},
}, false)
Expect(ok).To(BeTrue())
Expect(info.Metadata[MetadataPrefix+"verified"]).To(Equal("true"))
Expect(info.Metadata[MetadataPrefix+"features"]).To(Equal("STABLECOIN"))
Expect(info.Metadata).ToNot(HaveKey(MetadataPrefix + "testnet"))
})
It("segregates testnet assets and stamps the testnet metadata flag", func() {
info, ok := buildAssetInfo(client.Asset{
LegacyID: "ETH_TEST5",
DisplaySymbol: "ETH",
BlockchainID: "chain-eth-sepolia",
AssetClass: client.AssetClassNative,
Onchain: &client.AssetOnchain{Decimals: 18},
}, true)
Expect(ok).To(BeTrue())
Expect(info.Asset).To(Equal("ETH_TEST/18"))
Expect(info.Metadata).To(HaveKeyWithValue(MetadataPrefix+"testnet", "true"))
})
It("skips deprecated assets", func() {
_, ok := buildAssetInfo(client.Asset{
LegacyID: "OLD",
DisplaySymbol: "OLD",
AssetClass: client.AssetClassFT,
Onchain: &client.AssetOnchain{Decimals: 18},
Metadata: &client.AssetSpecMetadata{Deprecated: true},
}, false)
Expect(ok).To(BeFalse())
})
DescribeTable("skips non-fungible / virtual classes",
func(class string) {
_, ok := buildAssetInfo(client.Asset{
LegacyID: "X",
DisplaySymbol: "X",
AssetClass: class,
Onchain: &client.AssetOnchain{Decimals: 0},
}, false)
Expect(ok).To(BeFalse())
},
Entry("NFT", client.AssetClassNFT),
Entry("SFT", client.AssetClassSFT),
Entry("VIRTUAL", client.AssetClassVirtual),
)
It("skips when no decimals can be derived", func() {
_, ok := buildAssetInfo(client.Asset{
LegacyID: "X",
DisplaySymbol: "X",
AssetClass: client.AssetClassFT,
}, false)
Expect(ok).To(BeFalse())
})
It("skips when sanitisation yields empty symbol", func() {
_, ok := buildAssetInfo(client.Asset{
LegacyID: "123",
DisplaySymbol: "123",
AssetClass: client.AssetClassFT,
Onchain: &client.AssetOnchain{Decimals: 0},
}, false)
Expect(ok).To(BeFalse())
})
})
})
var _ = Describe("Fireblocks ensureAssetsFresh", func() {
var (
ctrl *gomock.Controller
m *client.MockClient
plg *Plugin
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
m = client.NewMockClient(ctrl)
plg = &Plugin{
logger: logging.NewDefaultLogger(GinkgoWriter, true, false, false),
client: m,
}
})
AfterEach(func() {
ctrl.Finish()
})
It("is a no-op when the cache is populated within the TTL", func(ctx SpecContext) {
plg.assets = map[string]assetInfo{"USD": {Asset: "USD/2", Precision: 2}}
plg.assetsLastSync = time.Now()
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
})
It("loads assets on first call when the cache is empty", func(ctx SpecContext) {
m.EXPECT().ListBlockchains(gomock.Any()).Return(nil, nil)
m.EXPECT().ListAssets(gomock.Any()).Return([]client.Asset{
{LegacyID: "USD", DisplaySymbol: "USD", AssetClass: client.AssetClassFiat, Decimals: pointerInt(2)},
}, nil)
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
Expect(plg.assets).To(HaveKey("USD"))
Expect(plg.assetsLastSync.IsZero()).To(BeFalse())
})
It("refreshes when assetsLastSync is older than the TTL", func(ctx SpecContext) {
plg.assets = map[string]assetInfo{"USD": {Asset: "USD/2", Precision: 2}}
plg.assetsLastSync = time.Now().Add(-2 * assetRefreshInterval)
m.EXPECT().ListBlockchains(gomock.Any()).Return(nil, nil)
m.EXPECT().ListAssets(gomock.Any()).Return([]client.Asset{
{LegacyID: "EUR", DisplaySymbol: "EUR", AssetClass: client.AssetClassFiat, Decimals: pointerInt(2)},
}, nil)
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
Expect(plg.assets).To(HaveKey("EUR"))
Expect(plg.assets).ToNot(HaveKey("USD"))
})
})
func pointerInt(v int) *int { return &v }