github.com/stellar/stellar-etl@v1.0.1-0.20240312145900-4874b6bf2b89/internal/transform/trade.go (about)

     1  package transform
     2  
     3  import (
     4  	"fmt"
     5  	"math"
     6  	"time"
     7  
     8  	"github.com/guregu/null"
     9  	"github.com/pkg/errors"
    10  
    11  	"github.com/stellar/go/exp/orderbook"
    12  	"github.com/stellar/go/ingest"
    13  	"github.com/stellar/go/support/log"
    14  	"github.com/stellar/go/xdr"
    15  	"github.com/stellar/stellar-etl/internal/toid"
    16  	"github.com/stellar/stellar-etl/internal/utils"
    17  )
    18  
    19  // TransformTrade converts a relevant operation from the history archive ingestion system into a form suitable for BigQuery
    20  func TransformTrade(operationIndex int32, operationID int64, transaction ingest.LedgerTransaction, ledgerCloseTime time.Time) ([]TradeOutput, error) {
    21  	operationResults, ok := transaction.Result.OperationResults()
    22  	if !ok {
    23  		return []TradeOutput{}, fmt.Errorf("Could not get any results from this transaction")
    24  	}
    25  
    26  	if !transaction.Result.Successful() {
    27  		return []TradeOutput{}, fmt.Errorf("Transaction failed; no trades")
    28  	}
    29  
    30  	operation := transaction.Envelope.Operations()[operationIndex]
    31  	// operation id is +1 incremented to stay in sync with ingest package
    32  	outputOperationID := operationID + 1
    33  	claimedOffers, BuyingOffer, sellerIsExact, err := extractClaimedOffers(operationResults, operationIndex, operation.Body.Type)
    34  	if err != nil {
    35  		return []TradeOutput{}, err
    36  	}
    37  
    38  	transformedTrades := []TradeOutput{}
    39  
    40  	for claimOrder, claimOffer := range claimedOffers {
    41  		outputOrder := int32(claimOrder)
    42  		outputLedgerClosedAt := ledgerCloseTime
    43  
    44  		var outputSellingAssetType, outputSellingAssetCode, outputSellingAssetIssuer string
    45  		err = claimOffer.AssetSold().Extract(&outputSellingAssetType, &outputSellingAssetCode, &outputSellingAssetIssuer)
    46  		if err != nil {
    47  			return []TradeOutput{}, err
    48  		}
    49  		outputSellingAssetID := FarmHashAsset(outputSellingAssetCode, outputSellingAssetIssuer, outputSellingAssetType)
    50  
    51  		outputSellingAmount := claimOffer.AmountSold()
    52  		if outputSellingAmount < 0 {
    53  			return []TradeOutput{}, fmt.Errorf("Amount sold is negative (%d) for operation at index %d", outputSellingAmount, operationIndex)
    54  		}
    55  
    56  		var outputBuyingAssetType, outputBuyingAssetCode, outputBuyingAssetIssuer string
    57  		err = claimOffer.AssetBought().Extract(&outputBuyingAssetType, &outputBuyingAssetCode, &outputBuyingAssetIssuer)
    58  		if err != nil {
    59  			return []TradeOutput{}, err
    60  		}
    61  		outputBuyingAssetID := FarmHashAsset(outputBuyingAssetCode, outputBuyingAssetIssuer, outputBuyingAssetType)
    62  
    63  		outputBuyingAmount := int64(claimOffer.AmountBought())
    64  		if outputBuyingAmount < 0 {
    65  			return []TradeOutput{}, fmt.Errorf("Amount bought is negative (%d) for operation at index %d", outputBuyingAmount, operationIndex)
    66  		}
    67  
    68  		if outputSellingAmount == 0 && outputBuyingAmount == 0 {
    69  			log.Debugf("Both Selling and Buying amount are 0 for operation at index %d", operationIndex)
    70  			continue
    71  		}
    72  
    73  		// Final price should be buy / sell
    74  		outputPriceN, outputPriceD, err := findTradeSellPrice(transaction, operationIndex, claimOffer)
    75  		if err != nil {
    76  			return []TradeOutput{}, err
    77  		}
    78  
    79  		var outputSellingAccountAddress string
    80  		var liquidityPoolID null.String
    81  		var outputPoolFee, roundingSlippageBips null.Int
    82  		var outputSellingOfferID, outputBuyingOfferID null.Int
    83  		var tradeType int32
    84  		if claimOffer.Type == xdr.ClaimAtomTypeClaimAtomTypeLiquidityPool {
    85  			id := claimOffer.MustLiquidityPool().LiquidityPoolId
    86  			liquidityPoolID = null.StringFrom(PoolIDToString(id))
    87  			tradeType = int32(2)
    88  			var fee uint32
    89  			if fee, err = findPoolFee(transaction, operationIndex, id); err != nil {
    90  				return []TradeOutput{}, fmt.Errorf("Cannot parse fee for liquidity pool %v", liquidityPoolID)
    91  			}
    92  			outputPoolFee = null.IntFrom(int64(fee))
    93  
    94  			change, err := liquidityPoolChange(transaction, operationIndex, claimOffer)
    95  			if err != nil {
    96  				return nil, err
    97  			}
    98  			if change != nil {
    99  				roundingSlippageBips, err = roundingSlippage(transaction, operationIndex, claimOffer, change)
   100  				if err != nil {
   101  					return nil, err
   102  				}
   103  			}
   104  		} else {
   105  			outputSellingOfferID = null.IntFrom(int64(claimOffer.OfferId()))
   106  			outputSellingAccountAddress = claimOffer.SellerId().Address()
   107  			tradeType = int32(1)
   108  		}
   109  
   110  		if BuyingOffer != nil {
   111  			outputBuyingOfferID = null.IntFrom(int64(BuyingOffer.OfferId))
   112  		} else {
   113  			outputBuyingOfferID = null.IntFrom(toid.EncodeOfferId(uint64(operationID)+1, toid.TOIDType))
   114  		}
   115  
   116  		var outputBuyingAccountAddress string
   117  		if buyer := operation.SourceAccount; buyer != nil {
   118  			accid := buyer.ToAccountId()
   119  			outputBuyingAccountAddress = accid.Address()
   120  		} else {
   121  			sa := transaction.Envelope.SourceAccount().ToAccountId()
   122  			outputBuyingAccountAddress = sa.Address()
   123  		}
   124  
   125  		trade := TradeOutput{
   126  			Order:                  outputOrder,
   127  			LedgerClosedAt:         outputLedgerClosedAt,
   128  			SellingAccountAddress:  outputSellingAccountAddress,
   129  			SellingAssetType:       outputSellingAssetType,
   130  			SellingAssetCode:       outputSellingAssetCode,
   131  			SellingAssetIssuer:     outputSellingAssetIssuer,
   132  			SellingAssetID:         outputSellingAssetID,
   133  			SellingAmount:          utils.ConvertStroopValueToReal(outputSellingAmount),
   134  			BuyingAccountAddress:   outputBuyingAccountAddress,
   135  			BuyingAssetType:        outputBuyingAssetType,
   136  			BuyingAssetCode:        outputBuyingAssetCode,
   137  			BuyingAssetIssuer:      outputBuyingAssetIssuer,
   138  			BuyingAssetID:          outputBuyingAssetID,
   139  			BuyingAmount:           utils.ConvertStroopValueToReal(xdr.Int64(outputBuyingAmount)),
   140  			PriceN:                 outputPriceN,
   141  			PriceD:                 outputPriceD,
   142  			SellingOfferID:         outputSellingOfferID,
   143  			BuyingOfferID:          outputBuyingOfferID,
   144  			SellingLiquidityPoolID: liquidityPoolID,
   145  			LiquidityPoolFee:       outputPoolFee,
   146  			HistoryOperationID:     outputOperationID,
   147  			TradeType:              tradeType,
   148  			RoundingSlippage:       roundingSlippageBips,
   149  			SellerIsExact:          sellerIsExact,
   150  		}
   151  
   152  		transformedTrades = append(transformedTrades, trade)
   153  	}
   154  	return transformedTrades, nil
   155  }
   156  
   157  func extractClaimedOffers(operationResults []xdr.OperationResult, operationIndex int32, operationType xdr.OperationType) (claimedOffers []xdr.ClaimAtom, BuyingOffer *xdr.OfferEntry, sellerIsExact null.Bool, err error) {
   158  	if operationIndex >= int32(len(operationResults)) {
   159  		err = fmt.Errorf("Operation index of %d is out of bounds in result slice (len = %d)", operationIndex, len(operationResults))
   160  		return
   161  	}
   162  
   163  	if operationResults[operationIndex].Tr == nil {
   164  		err = fmt.Errorf("Could not get result Tr for operation at index %d", operationIndex)
   165  		return
   166  	}
   167  
   168  	operationTr, ok := operationResults[operationIndex].GetTr()
   169  	if !ok {
   170  		err = fmt.Errorf("Could not get result Tr for operation at index %d", operationIndex)
   171  		return
   172  	}
   173  	switch operationType {
   174  	case xdr.OperationTypeManageBuyOffer:
   175  		var buyOfferResult xdr.ManageBuyOfferResult
   176  		if buyOfferResult, ok = operationTr.GetManageBuyOfferResult(); !ok {
   177  			err = fmt.Errorf("Could not get ManageBuyOfferResult for operation at index %d", operationIndex)
   178  			return
   179  		}
   180  		if success, ok := buyOfferResult.GetSuccess(); ok {
   181  			claimedOffers = success.OffersClaimed
   182  			BuyingOffer = success.Offer.Offer
   183  			return
   184  		}
   185  
   186  		err = fmt.Errorf("Could not get ManageOfferSuccess for operation at index %d", operationIndex)
   187  
   188  	case xdr.OperationTypeManageSellOffer:
   189  		var sellOfferResult xdr.ManageSellOfferResult
   190  		if sellOfferResult, ok = operationTr.GetManageSellOfferResult(); !ok {
   191  			err = fmt.Errorf("Could not get ManageSellOfferResult for operation at index %d", operationIndex)
   192  			return
   193  		}
   194  
   195  		if success, ok := sellOfferResult.GetSuccess(); ok {
   196  			claimedOffers = success.OffersClaimed
   197  			BuyingOffer = success.Offer.Offer
   198  			return
   199  		}
   200  
   201  		err = fmt.Errorf("Could not get ManageOfferSuccess for operation at index %d", operationIndex)
   202  
   203  	case xdr.OperationTypeCreatePassiveSellOffer:
   204  		// KNOWN ISSUE: stellar-core creates results for CreatePassiveOffer operations
   205  		// with the wrong result arm set.
   206  		if operationTr.Type == xdr.OperationTypeManageSellOffer {
   207  			passiveSellResult := operationTr.MustManageSellOfferResult().MustSuccess()
   208  			claimedOffers = passiveSellResult.OffersClaimed
   209  			BuyingOffer = passiveSellResult.Offer.Offer
   210  			return
   211  		} else {
   212  			passiveSellResult := operationTr.MustCreatePassiveSellOfferResult().MustSuccess()
   213  			claimedOffers = passiveSellResult.OffersClaimed
   214  			BuyingOffer = passiveSellResult.Offer.Offer
   215  			return
   216  		}
   217  
   218  	case xdr.OperationTypePathPaymentStrictSend:
   219  		var pathSendResult xdr.PathPaymentStrictSendResult
   220  		sellerIsExact = null.BoolFrom(false)
   221  		if pathSendResult, ok = operationTr.GetPathPaymentStrictSendResult(); !ok {
   222  			err = fmt.Errorf("Could not get PathPaymentStrictSendResult for operation at index %d", operationIndex)
   223  			return
   224  		}
   225  
   226  		success, ok := pathSendResult.GetSuccess()
   227  		if ok {
   228  			claimedOffers = success.Offers
   229  			return
   230  		}
   231  
   232  		err = fmt.Errorf("Could not get PathPaymentStrictSendSuccess for operation at index %d", operationIndex)
   233  
   234  	case xdr.OperationTypePathPaymentStrictReceive:
   235  		var pathReceiveResult xdr.PathPaymentStrictReceiveResult
   236  		sellerIsExact = null.BoolFrom(true)
   237  		if pathReceiveResult, ok = operationTr.GetPathPaymentStrictReceiveResult(); !ok {
   238  			err = fmt.Errorf("Could not get PathPaymentStrictReceiveResult for operation at index %d", operationIndex)
   239  			return
   240  		}
   241  
   242  		if success, ok := pathReceiveResult.GetSuccess(); ok {
   243  			claimedOffers = success.Offers
   244  			return
   245  		}
   246  
   247  		err = fmt.Errorf("Could not get GetPathPaymentStrictReceiveSuccess for operation at index %d", operationIndex)
   248  
   249  	default:
   250  		err = fmt.Errorf("Operation of type %s at index %d does not result in trades", operationType, operationIndex)
   251  		return
   252  	}
   253  
   254  	return
   255  }
   256  
   257  func findTradeSellPrice(t ingest.LedgerTransaction, operationIndex int32, trade xdr.ClaimAtom) (n, d int64, err error) {
   258  	if trade.Type == xdr.ClaimAtomTypeClaimAtomTypeLiquidityPool {
   259  		return int64(trade.AmountBought()), int64(trade.AmountSold()), nil
   260  	}
   261  
   262  	key := xdr.LedgerKey{}
   263  	if err := key.SetOffer(trade.SellerId(), uint64(trade.OfferId())); err != nil {
   264  		return 0, 0, errors.Wrap(err, "Could not create offer ledger key")
   265  	}
   266  	change, err := findLatestOperationChange(t, operationIndex, key)
   267  	if err != nil {
   268  		return 0, 0, errors.Wrap(err, "could not find change for trade offer")
   269  	}
   270  
   271  	return int64(change.Pre.Data.MustOffer().Price.N), int64(change.Pre.Data.MustOffer().Price.D), nil
   272  }
   273  
   274  func findLatestOperationChange(t ingest.LedgerTransaction, operationIndex int32, key xdr.LedgerKey) (ingest.Change, error) {
   275  	changes, err := t.GetOperationChanges(uint32(operationIndex))
   276  	if err != nil {
   277  		return ingest.Change{}, errors.Wrap(err, "could not determine changes for operation")
   278  	}
   279  
   280  	var change ingest.Change
   281  	// traverse through the slice in reverse order
   282  	for i := len(changes) - 1; i >= 0; i-- {
   283  		change = changes[i]
   284  		if change.Pre != nil {
   285  			preKey, err := change.Pre.LedgerKey()
   286  			if err != nil {
   287  				return ingest.Change{}, errors.Wrap(err, "could not determine ledger key for change")
   288  
   289  			}
   290  			if key.Equals(preKey) {
   291  				return change, nil
   292  			}
   293  		}
   294  
   295  	}
   296  	return ingest.Change{}, errors.Errorf("could not find operation for key %v", key)
   297  }
   298  
   299  func findPoolFee(t ingest.LedgerTransaction, operationIndex int32, poolID xdr.PoolId) (fee uint32, err error) {
   300  	key := xdr.LedgerKey{}
   301  	if err := key.SetLiquidityPool(poolID); err != nil {
   302  		return 0, errors.Wrap(err, "Could not create liquidity pool ledger key")
   303  	}
   304  	change, err := findLatestOperationChange(t, operationIndex, key)
   305  	if err != nil {
   306  		return 0, errors.Wrap(err, "could not find change for liquidity pool")
   307  	}
   308  
   309  	return uint32(change.Pre.Data.MustLiquidityPool().Body.MustConstantProduct().Params.Fee), nil
   310  }
   311  
   312  func liquidityPoolChange(t ingest.LedgerTransaction, operationIndex int32, trade xdr.ClaimAtom) (*ingest.Change, error) {
   313  	if trade.Type != xdr.ClaimAtomTypeClaimAtomTypeLiquidityPool {
   314  		return nil, nil
   315  	}
   316  
   317  	poolID := trade.LiquidityPool.LiquidityPoolId
   318  
   319  	key := xdr.LedgerKey{}
   320  	if err := key.SetLiquidityPool(poolID); err != nil {
   321  		return nil, errors.Wrap(err, "Could not create liquidity pool ledger key")
   322  	}
   323  
   324  	change, err := findLatestOperationChange(t, operationIndex, key)
   325  	if err != nil {
   326  		return nil, errors.Wrap(err, "Could not find change for liquidity pool")
   327  	}
   328  
   329  	return &change, nil
   330  }
   331  
   332  func liquidityPoolReserves(trade xdr.ClaimAtom, change *ingest.Change) (int64, int64) {
   333  	pre := change.Pre.Data.MustLiquidityPool().Body.ConstantProduct
   334  	a := int64(pre.ReserveA)
   335  	b := int64(pre.ReserveB)
   336  	if !trade.AssetSold().Equals(pre.Params.AssetA) {
   337  		a, b = b, a
   338  	}
   339  
   340  	return a, b
   341  }
   342  
   343  func roundingSlippage(t ingest.LedgerTransaction, operationIndex int32, trade xdr.ClaimAtom, change *ingest.Change) (null.Int, error) {
   344  	disbursedReserves, depositedReserves := liquidityPoolReserves(trade, change)
   345  
   346  	pre := change.Pre.Data.MustLiquidityPool().Body.ConstantProduct
   347  
   348  	op, found := t.GetOperation(uint32(operationIndex))
   349  	if !found {
   350  		return null.Int{}, errors.New("Could not find operation")
   351  	}
   352  
   353  	amountDeposited := trade.AmountBought()
   354  	amountDisbursed := trade.AmountSold()
   355  
   356  	switch op.Body.Type {
   357  	case xdr.OperationTypePathPaymentStrictReceive:
   358  		// User specified the disbursed amount
   359  		_, roundingSlippageBips, ok := orderbook.CalculatePoolPayout(
   360  			xdr.Int64(depositedReserves),
   361  			xdr.Int64(disbursedReserves),
   362  			amountDisbursed,
   363  			pre.Params.Fee,
   364  			true,
   365  		)
   366  		if !ok {
   367  			// This is a temporary workaround and will be addressed when
   368  			// https://github.com/stellar/go/issues/4203 is closed
   369  			roundingSlippageBips = xdr.Int64(math.MaxInt64)
   370  		}
   371  		return null.IntFrom(int64(roundingSlippageBips)), nil
   372  	case xdr.OperationTypePathPaymentStrictSend:
   373  		// User specified the deposited amount
   374  		_, roundingSlippageBips, ok := orderbook.CalculatePoolPayout(
   375  			xdr.Int64(depositedReserves),
   376  			xdr.Int64(disbursedReserves),
   377  			amountDeposited,
   378  			pre.Params.Fee,
   379  			true,
   380  		)
   381  		if !ok {
   382  			// Temporary workaround for https://github.com/stellar/go/issues/4203
   383  			// Given strict receives that would overflow here, minimum slippage
   384  			// so they get excluded.
   385  			roundingSlippageBips = xdr.Int64(math.MinInt64)
   386  		}
   387  		return null.IntFrom(int64(roundingSlippageBips)), nil
   388  	default:
   389  		return null.Int{}, fmt.Errorf("Unexpected trade operation type: %v", op.Body.Type)
   390  	}
   391  
   392  }