mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
180 lines
5.1 KiB
Go
180 lines
5.1 KiB
Go
package mappers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/currency"
|
|
)
|
|
|
|
func NormalizeCurrency(s string) string {
|
|
return strings.ToUpper(strings.TrimSpace(s))
|
|
}
|
|
|
|
// IsZeroAmount treats empty + unparseable as zero so a malformed row
|
|
// never produces a phantom non-zero amount.
|
|
func IsZeroAmount(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" || s == "0" {
|
|
return true
|
|
}
|
|
f, _, err := new(big.Float).Parse(s, 10)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return f.Sign() == 0
|
|
}
|
|
|
|
// AbsAmount strips the leading '-'. Bitstamp signs withdrawals +
|
|
// outbound transfers negative; PSPPayment.Amount is always positive.
|
|
func AbsAmount(s string) string {
|
|
return strings.TrimPrefix(strings.TrimSpace(s), "-")
|
|
}
|
|
|
|
func IsNegative(s string) bool {
|
|
return strings.HasPrefix(strings.TrimSpace(s), "-")
|
|
}
|
|
|
|
func FormatAsset(currencies map[string]int, symbol string) string {
|
|
return currency.FormatAsset(currencies, symbol)
|
|
}
|
|
|
|
// ParseDecimalAmount converts a Bitstamp decimal string to *big.Int minor units.
|
|
// If Bitstamp returns more decimal places than the currency precision allows,
|
|
// the excess digits are unconditionally truncated. The precision itself comes
|
|
// from Bitstamp's own currency endpoint, so we trust it as the authoritative
|
|
// bound — any digits beyond it are sub-precision noise that can be safely dropped.
|
|
func ParseDecimalAmount(value string, precision int) (*big.Int, error) {
|
|
if value == "" {
|
|
return nil, fmt.Errorf("empty amount string")
|
|
}
|
|
amount, err := currency.GetAmountWithPrecisionFromString(value, precision)
|
|
if err != nil {
|
|
if errors.Is(err, currency.ErrInvalidPrecision) && precision > 0 {
|
|
if idx := strings.IndexByte(value, '.'); idx >= 0 {
|
|
decimalPart := value[idx+1:]
|
|
if len(decimalPart) > precision {
|
|
truncated := value[:idx+1+precision]
|
|
amount, err = currency.GetAmountWithPrecisionFromString(truncated, precision)
|
|
if err == nil {
|
|
return amount, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("parse decimal %q at precision %d: %w", value, precision, err)
|
|
}
|
|
return amount, nil
|
|
}
|
|
|
|
// ResolveSinglePaymentAsset returns the single non-zero known
|
|
// currency on a user_transactions row, or ok=false when the row is
|
|
// not a single-asset payment (0 or 2+ non-zero known currencies —
|
|
// the latter is the conversion shape, handled separately).
|
|
func ResolveSinglePaymentAsset(currencies map[string]int, amounts map[string]string) (asset string, amount *big.Int, ok bool, err error) {
|
|
var (
|
|
selectedSymbol string
|
|
selectedPrec int
|
|
selectedRawAbs string
|
|
nonZeroCount int
|
|
)
|
|
for key, val := range amounts {
|
|
symbol := NormalizeCurrency(key)
|
|
precision, known := currencies[symbol]
|
|
if !known {
|
|
continue
|
|
}
|
|
abs := AbsAmount(val)
|
|
if IsZeroAmount(abs) {
|
|
continue
|
|
}
|
|
nonZeroCount++
|
|
if nonZeroCount > 1 {
|
|
return "", nil, false, nil
|
|
}
|
|
selectedSymbol = symbol
|
|
selectedPrec = precision
|
|
selectedRawAbs = abs
|
|
}
|
|
if nonZeroCount == 0 {
|
|
return "", nil, false, nil
|
|
}
|
|
amount, err = ParseDecimalAmount(selectedRawAbs, selectedPrec)
|
|
if err != nil {
|
|
return "", nil, false, err
|
|
}
|
|
return FormatAsset(currencies, selectedSymbol), amount, true, nil
|
|
}
|
|
|
|
// TwoAssetLeg names one side of a conversion row. The two-asset
|
|
// resolver returns the negative-amount leg as Source (what the user
|
|
// paid with) and the positive-amount leg as Destination.
|
|
type TwoAssetLeg struct {
|
|
Symbol string
|
|
Asset string // "SYMBOL/<precision>"
|
|
Precision int
|
|
Amount *big.Int // non-negative
|
|
}
|
|
|
|
// ResolveTwoAssetConversion returns the (negative, positive) leg
|
|
// pair on a conversion row. Strict: 3+ non-zero currencies or
|
|
// same-sign legs return ok=false.
|
|
func ResolveTwoAssetConversion(currencies map[string]int, amounts map[string]string) (source, destination TwoAssetLeg, ok bool, err error) {
|
|
var (
|
|
neg *TwoAssetLeg
|
|
pos *TwoAssetLeg
|
|
others int
|
|
)
|
|
for key, val := range amounts {
|
|
symbol := NormalizeCurrency(key)
|
|
precision, known := currencies[symbol]
|
|
if !known {
|
|
continue
|
|
}
|
|
raw := strings.TrimSpace(val)
|
|
if IsZeroAmount(raw) {
|
|
continue
|
|
}
|
|
leg := TwoAssetLeg{Symbol: symbol, Precision: precision, Asset: FormatAsset(currencies, symbol)}
|
|
switch {
|
|
case IsNegative(raw):
|
|
if neg != nil {
|
|
return TwoAssetLeg{}, TwoAssetLeg{}, false, nil
|
|
}
|
|
amt, perr := ParseDecimalAmount(AbsAmount(raw), precision)
|
|
if perr != nil {
|
|
return TwoAssetLeg{}, TwoAssetLeg{}, false, perr
|
|
}
|
|
leg.Amount = amt
|
|
neg = &leg
|
|
default:
|
|
if pos != nil {
|
|
return TwoAssetLeg{}, TwoAssetLeg{}, false, nil
|
|
}
|
|
amt, perr := ParseDecimalAmount(raw, precision)
|
|
if perr != nil {
|
|
return TwoAssetLeg{}, TwoAssetLeg{}, false, perr
|
|
}
|
|
leg.Amount = amt
|
|
pos = &leg
|
|
}
|
|
others++
|
|
}
|
|
if neg == nil || pos == nil || others != 2 {
|
|
return TwoAssetLeg{}, TwoAssetLeg{}, false, nil
|
|
}
|
|
return *neg, *pos, true, nil
|
|
}
|
|
|
|
// PrecisionFor errors on unknown currency rather than silently
|
|
// defaulting — callers decide whether to skip or escalate.
|
|
func PrecisionFor(currencies map[string]int, symbol string) (int, error) {
|
|
p, ok := currencies[NormalizeCurrency(symbol)]
|
|
if !ok {
|
|
return 0, fmt.Errorf("unsupported currency %q", symbol)
|
|
}
|
|
return p, nil
|
|
}
|