mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
237 lines
7.0 KiB
Go
237 lines
7.0 KiB
Go
package fireblocks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"maps"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/currency"
|
|
"github.com/hanzo-fi/payments/ee/plugins/fireblocks/client"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
)
|
|
|
|
type PeerType string
|
|
|
|
const (
|
|
// Fireblocks peer types - internal vs external
|
|
peerTypeVaultAccount PeerType = "VAULT_ACCOUNT"
|
|
peerTypeExternalWallet PeerType = "EXTERNAL_WALLET"
|
|
peerTypeOneTimeAddress PeerType = "ONE_TIME_ADDRESS"
|
|
peerTypeNetworkConn PeerType = "NETWORK_CONNECTION"
|
|
peerTypeFiatAccount PeerType = "FIAT_ACCOUNT"
|
|
peerTypeEndUserWallet PeerType = "END_USER_WALLET"
|
|
peerTypeUnknown PeerType = "UNKNOWN"
|
|
)
|
|
|
|
type paymentsState struct {
|
|
LastCreatedAt int64 `json:"lastCreatedAt"`
|
|
LastTxID string `json:"lastTxId"`
|
|
}
|
|
|
|
func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) {
|
|
var oldState paymentsState
|
|
if req.State != nil {
|
|
if err := json.Unmarshal(req.State, &oldState); err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
}
|
|
|
|
// Fireblocks "after" expects a Unix ms timestamp and is exclusive ("created after").
|
|
transactions, err := p.client.ListTransactions(ctx, oldState.LastCreatedAt, int(req.PageSize))
|
|
if err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
|
|
payments := make([]models.PSPPayment, 0, len(transactions))
|
|
newState := paymentsState{
|
|
LastCreatedAt: oldState.LastCreatedAt,
|
|
LastTxID: oldState.LastTxID,
|
|
}
|
|
|
|
advanceState := func(tx client.Transaction) {
|
|
if tx.CreatedAt > newState.LastCreatedAt {
|
|
newState.LastCreatedAt = tx.CreatedAt
|
|
newState.LastTxID = tx.ID
|
|
return
|
|
}
|
|
if tx.CreatedAt == newState.LastCreatedAt {
|
|
newState.LastTxID = tx.ID
|
|
}
|
|
}
|
|
|
|
for _, tx := range transactions {
|
|
advanceState(tx)
|
|
|
|
// Deduplication: skip transactions we've already processed.
|
|
// The Fireblocks API uses an "after" timestamp; we only drop the exact last
|
|
// transaction to guard against inclusive implementations without relying on
|
|
// ID ordering (IDs may be non-sequential).
|
|
if tx.CreatedAt < oldState.LastCreatedAt {
|
|
continue
|
|
}
|
|
if tx.CreatedAt == oldState.LastCreatedAt && tx.ID == oldState.LastTxID {
|
|
continue
|
|
}
|
|
|
|
info, ok := p.lookupAsset(tx.AssetID)
|
|
if !ok {
|
|
p.logger.Errorf("skipping transaction %s: unknown asset %q", tx.ID, tx.AssetID)
|
|
continue
|
|
}
|
|
|
|
amount, err := currency.GetAmountWithPrecisionFromString(tx.AmountInfo.Amount, info.Precision)
|
|
if err != nil {
|
|
p.logger.Errorf("skipping transaction %s: unparseable amount %q for asset %q",
|
|
tx.ID, tx.AmountInfo.Amount, tx.AssetID)
|
|
continue
|
|
}
|
|
|
|
raw, err := json.Marshal(tx)
|
|
if err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
|
|
payment := models.PSPPayment{
|
|
Reference: tx.ID,
|
|
CreatedAt: time.UnixMilli(tx.CreatedAt),
|
|
Type: matchPaymentType(tx),
|
|
Amount: amount,
|
|
Asset: info.Asset,
|
|
Scheme: models.PAYMENT_SCHEME_OTHER,
|
|
Status: matchPaymentStatus(tx.Status),
|
|
Raw: raw,
|
|
Metadata: buildPaymentMetadata(tx, info),
|
|
}
|
|
|
|
if tx.Source.ID != "" && isPeerType(tx.Source.Type, peerTypeVaultAccount) {
|
|
sourceRef := tx.Source.ID
|
|
payment.SourceAccountReference = &sourceRef
|
|
}
|
|
|
|
if len(tx.Destinations) > 0 {
|
|
if len(tx.Destinations) == 1 && tx.Destinations[0].ID != "" {
|
|
if isPeerType(tx.Destinations[0].Type, peerTypeVaultAccount) {
|
|
destRef := tx.Destinations[0].ID
|
|
payment.DestinationAccountReference = &destRef
|
|
}
|
|
} else {
|
|
ids := make([]string, 0, len(tx.Destinations))
|
|
for _, dest := range tx.Destinations {
|
|
if dest.ID != "" {
|
|
ids = append(ids, dest.ID)
|
|
}
|
|
}
|
|
if len(ids) > 0 {
|
|
payment.Metadata[MetadataPrefix+"destination_ids"] = strings.Join(ids, ",")
|
|
}
|
|
}
|
|
} else if tx.Destination.ID != "" && isPeerType(tx.Destination.Type, peerTypeVaultAccount) {
|
|
destRef := tx.Destination.ID
|
|
payment.DestinationAccountReference = &destRef
|
|
}
|
|
|
|
if err := payment.Validate(); err != nil {
|
|
p.logger.Infof("dropping invalid payment %s: %s", tx.ID, err)
|
|
continue
|
|
}
|
|
payments = append(payments, payment)
|
|
}
|
|
|
|
payload, err := json.Marshal(newState)
|
|
if err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
|
|
hasMore := len(transactions) == int(req.PageSize)
|
|
|
|
return models.FetchNextPaymentsResponse{
|
|
Payments: payments,
|
|
NewState: payload,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// buildPaymentMetadata seeds the payment metadata with the per-asset slice
|
|
// from the cache and layers tx-level details under MetadataPrefix. Always
|
|
// returns a non-nil map so downstream writes can happen unconditionally.
|
|
func buildPaymentMetadata(tx client.Transaction, info assetInfo) map[string]string {
|
|
out := make(map[string]string, len(info.Metadata)+4)
|
|
maps.Copy(out, info.Metadata)
|
|
if tx.TxHash != "" {
|
|
out[MetadataPrefix+"tx_hash"] = tx.TxHash
|
|
}
|
|
if tx.FeeInfo.NetworkFee != "" {
|
|
out[MetadataPrefix+"network_fee"] = tx.FeeInfo.NetworkFee
|
|
}
|
|
if tx.Note != "" {
|
|
out[MetadataPrefix+"note"] = tx.Note
|
|
}
|
|
if tx.SubStatus != "" {
|
|
out[MetadataPrefix+"sub_status"] = tx.SubStatus
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isPeerType checks if a peer type string matches the expected PeerType (case-insensitive).
|
|
func isPeerType(t string, expected PeerType) bool {
|
|
return strings.ToUpper(t) == string(expected)
|
|
}
|
|
|
|
// isExternalPeerType returns true for peer types representing endpoints
|
|
// outside the user's Fireblocks workspace.
|
|
func isExternalPeerType(peerType string) bool {
|
|
switch strings.ToUpper(peerType) {
|
|
case string(peerTypeExternalWallet), string(peerTypeOneTimeAddress), string(peerTypeUnknown),
|
|
string(peerTypeNetworkConn), string(peerTypeFiatAccount), string(peerTypeEndUserWallet):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// resolveDestinationType returns the peer type of the effective destination.
|
|
func resolveDestinationType(tx client.Transaction) string {
|
|
if len(tx.Destinations) > 0 {
|
|
return tx.Destinations[0].Type
|
|
}
|
|
return tx.Destination.Type
|
|
}
|
|
|
|
func matchPaymentType(tx client.Transaction) models.PaymentType {
|
|
switch tx.Operation {
|
|
case "TRANSFER", "INTERNAL_TRANSFER":
|
|
srcExternal := isExternalPeerType(tx.Source.Type)
|
|
dstExternal := isExternalPeerType(resolveDestinationType(tx))
|
|
switch {
|
|
case srcExternal && !dstExternal:
|
|
return models.PAYMENT_TYPE_PAYIN
|
|
case !srcExternal && dstExternal:
|
|
return models.PAYMENT_TYPE_PAYOUT
|
|
default:
|
|
return models.PAYMENT_TYPE_TRANSFER
|
|
}
|
|
default:
|
|
return models.PAYMENT_TYPE_OTHER
|
|
}
|
|
}
|
|
|
|
func matchPaymentStatus(status string) models.PaymentStatus {
|
|
switch status {
|
|
case "COMPLETED":
|
|
return models.PAYMENT_STATUS_SUCCEEDED
|
|
case "SUBMITTED", "PENDING_AML_SCREENING", "PENDING_ENRICHMENT",
|
|
"PENDING_AUTHORIZATION", "QUEUED", "PENDING_SIGNATURE",
|
|
"PENDING_3RD_PARTY_MANUAL_APPROVAL", "PENDING_3RD_PARTY",
|
|
"CONFIRMING", "BROADCASTING":
|
|
return models.PAYMENT_STATUS_PENDING
|
|
case "CANCELLING", "CANCELLED":
|
|
return models.PAYMENT_STATUS_CANCELLED
|
|
case "FAILED", "BLOCKED", "REJECTED":
|
|
return models.PAYMENT_STATUS_FAILED
|
|
default:
|
|
return models.PAYMENT_STATUS_OTHER
|
|
}
|
|
}
|