Files
zeekay 7d727874d5 chore: rebrand Formance Payments -> Hanzo Payments (hanzo-fi)
- go.mod module path + all self-imports: github.com/formancehq/payments -> github.com/hanzo-fi/payments
  (1590 .go files / 3152 refs; 20 modules: root, pkg/client, pkg/domain, 18 ce/plugins).
  External dep kept: formancehq/go-libs.
- plugins: each ce/plugins/* consumed a *published* pkg/domain; added local
  'replace .../pkg/domain => ../../../pkg/domain' (consistent with root) so every
  standalone plugin module builds in-monorepo. compile-plugin.sh MOD var rebranded too.
- branding: README, LICENSE (dual MIT+EE; upstream copyright retained + Hanzo added),
  goreleaser (release images -> registry.hanzo.ai/hanzo-fi, OCI labels, homepage),
  proto go_package, renovate, speakeasy gen.yaml, connector-template *.gotpl.
- CI: removed Numary/Formance workflows + composite action; added canonical hanzo.yml +
  cicd.yml (hanzoai/ci, gated HANZO_CI_ENABLED) + secretless ci.yml (go build/vet).
- kept as data/compat keys: wire capability enums ALLOW_FORMANCE_*, generated SDK type
  client.Formance, domain field FormanceRedirectURL, OTEL_SERVICE_NAME, upstream base image.
- verified: go build ./... (default + -tags ee) + all 20 nested modules green.
2026-07-05 13:08:55 -07:00

244 lines
6.5 KiB
Go

package fireblocks
import (
"context"
"fmt"
"strings"
"time"
"github.com/hanzo-fi/payments/ee/plugins/fireblocks/client"
)
// assetInfo is the per-asset cache entry built from /v1/assets at refresh time.
type assetInfo struct {
Asset string // canonical Formance asset, e.g. "USDT/6"
Precision int
BlockchainID string
LegacyID string
Metadata map[string]string // copied onto every PSPPayment using this asset
}
func (p *Plugin) ensureAssetsFresh(ctx context.Context) error {
p.assetsMu.RLock()
needsRefresh := p.assets == nil || time.Since(p.assetsLastSync) >= assetRefreshInterval
p.assetsMu.RUnlock()
if !needsRefresh {
return nil
}
p.assetsRefreshMu.Lock()
defer p.assetsRefreshMu.Unlock()
p.assetsMu.RLock()
needsRefresh = p.assets == nil || time.Since(p.assetsLastSync) >= assetRefreshInterval
p.assetsMu.RUnlock()
if !needsRefresh {
return nil
}
return p.loadAssets(ctx)
}
// lookupAsset resolves a Fireblocks legacyId (case-insensitive) against the cache.
func (p *Plugin) lookupAsset(legacyID string) (assetInfo, bool) {
p.assetsMu.RLock()
defer p.assetsMu.RUnlock()
info, ok := p.assets[strings.ToUpper(legacyID)]
return info, ok
}
func (p *Plugin) loadAssets(ctx context.Context) error {
blockchains, err := p.client.ListBlockchains(ctx)
if err != nil {
return fmt.Errorf("listing blockchains: %w", err)
}
testnetByBlockchain := make(map[string]bool, len(blockchains))
for _, b := range blockchains {
if b.Onchain != nil && b.Onchain.Test {
testnetByBlockchain[b.ID] = true
}
}
rawAssets, err := p.client.ListAssets(ctx)
if err != nil {
return err
}
out := make(map[string]assetInfo, len(rawAssets))
var skipped int
for _, a := range rawAssets {
info, ok := buildAssetInfo(a, testnetByBlockchain[a.BlockchainID])
if !ok {
p.logger.Infof("skipping fireblocks asset legacyId=%q id=%q class=%q",
a.LegacyID, a.ID, a.AssetClass)
skipped++
continue
}
if a.LegacyID == "" {
// Vaults reference assets by legacyId only.
skipped++
continue
}
out[strings.ToUpper(a.LegacyID)] = info
}
p.assetsMu.Lock()
p.assets = out
p.assetsLastSync = time.Now()
p.assetsMu.Unlock()
if len(out) == 0 {
p.logger.Errorf("loaded 0 fireblocks assets (%d skipped) — Fireblocks may have changed the legacyId surface or the workspace is empty",
skipped)
} else {
p.logger.Infof("loaded %d fireblocks assets (%d skipped, %d testnet blockchains)",
len(out), skipped, len(testnetByBlockchain))
}
return nil
}
// buildAssetInfo turns a Fireblocks Asset into the cached entry. Returns
// ok=false for assets that must not be ingested (deprecated, NFT/SFT/VIRTUAL,
// unknown decimals, sanitization yields empty symbol). isTestnet stamps the
// canonical asset with a `_TEST` suffix so testnet holdings never aggregate
// with their mainnet equivalents.
func buildAssetInfo(a client.Asset, isTestnet bool) (assetInfo, bool) {
if a.Metadata != nil && a.Metadata.Deprecated {
return assetInfo{}, false
}
switch a.AssetClass {
case client.AssetClassNFT, client.AssetClassSFT, client.AssetClassVirtual:
return assetInfo{}, false
}
precision, ok := pickPrecision(a)
if !ok {
return assetInfo{}, false
}
symbol := a.DisplaySymbol
if symbol == "" && a.Onchain != nil {
symbol = a.Onchain.Symbol
}
if symbol == "" {
// Last-resort fallback so we keep some signal; legacyId is the only
// other string guaranteed to identify the asset.
symbol = a.LegacyID
}
asset := canonicalAsset(symbol, precision, isTestnet)
if asset == "" {
return assetInfo{}, false
}
return assetInfo{
Asset: asset,
Precision: precision,
BlockchainID: a.BlockchainID,
LegacyID: a.LegacyID,
Metadata: buildAssetMetadata(a, isTestnet),
}, true
}
// pickPrecision selects decimals based on assetClass: FIAT uses the top-level
// `decimals`, NATIVE/FT/unknown fall through to onchain. Negative or absent
// values cause a skip.
func pickPrecision(a client.Asset) (int, bool) {
if a.AssetClass == client.AssetClassFiat {
if a.Decimals != nil && *a.Decimals >= 0 {
return *a.Decimals, true
}
// Some FIAT assets ship decimals via onchain too; try that next.
}
if a.Onchain != nil && a.Onchain.Decimals >= 0 {
return a.Onchain.Decimals, true
}
if a.Decimals != nil && *a.Decimals >= 0 {
return *a.Decimals, true
}
return 0, false
}
// sanitizeSymbol turns an arbitrary Fireblocks displaySymbol into a Ledger-
// valid base: uppercase ASCII letters/digits only, first char must be a
// letter, capped at 17 chars. Returns "" if no valid prefix can be derived.
func sanitizeSymbol(symbol string) string {
var b strings.Builder
b.Grow(len(symbol))
seenLetter := false
for _, r := range strings.ToUpper(symbol) {
if b.Len() >= 17 {
break
}
switch {
case r >= 'A' && r <= 'Z':
seenLetter = true
b.WriteRune(r)
case r >= '0' && r <= '9':
if seenLetter {
b.WriteRune(r)
}
}
}
return b.String()
}
// canonicalAsset assembles "<sanitized>[_TEST]/<precision>" (or the bare base
// when precision is 0). isTestnet appends a `_TEST` segment so testnet and
// mainnet assets never collide downstream. Ledger's regex permits the base
// (capped at 17 chars by sanitizeSymbol) and the suffix independently.
func canonicalAsset(symbol string, precision int, isTestnet bool) string {
s := sanitizeSymbol(symbol)
if s == "" {
return ""
}
if isTestnet {
s += "_TEST"
}
if precision == 0 {
return s
}
return fmt.Sprintf("%s/%d", s, precision)
}
// buildAssetMetadata captures the Fireblocks-side context that's worth
// surfacing alongside each balance/payment. Optional fields are omitted when
// empty / default so the map stays small.
func buildAssetMetadata(a client.Asset, isTestnet bool) map[string]string {
m := map[string]string{}
setIfPresent := func(key, value string) {
if value != "" {
m[MetadataPrefix+key] = value
}
}
setIfPresent("legacy_id", a.LegacyID)
setIfPresent("asset_uuid", a.ID)
setIfPresent("display_name", a.DisplayName)
setIfPresent("display_symbol", a.DisplaySymbol)
setIfPresent("blockchain_id", a.BlockchainID)
setIfPresent("asset_class", a.AssetClass)
if a.Onchain != nil {
setIfPresent("contract_address", a.Onchain.Address)
if len(a.Onchain.Standards) > 0 {
m[MetadataPrefix+"token_standard"] = strings.Join(a.Onchain.Standards, ",")
}
}
if a.Metadata != nil {
if a.Metadata.Verified {
m[MetadataPrefix+"verified"] = "true"
}
if len(a.Metadata.Features) > 0 {
m[MetadataPrefix+"features"] = strings.Join(a.Metadata.Features, ",")
}
}
if isTestnet {
m[MetadataPrefix+"testnet"] = "true"
}
return m
}