diff --git a/ocp/balance/calculator.go b/ocp/balance/calculator.go index 70cebbd..a54f0be 100644 --- a/ocp/balance/calculator.go +++ b/ocp/balance/calculator.go @@ -2,16 +2,20 @@ package balance import ( "context" + "math/big" "time" "github.com/pkg/errors" + "github.com/code-payments/ocp-server/metrics" "github.com/code-payments/ocp-server/ocp/common" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/balance" + "github.com/code-payments/ocp-server/ocp/data/currency" + "github.com/code-payments/ocp-server/ocp/data/swap" "github.com/code-payments/ocp-server/ocp/data/timelock" - "github.com/code-payments/ocp-server/metrics" "github.com/code-payments/ocp-server/solana" + "github.com/code-payments/ocp-server/solana/currencycreator" ) type Source uint8 @@ -388,3 +392,221 @@ func (s Source) String() string { } return "unknown" } + +// PendingSwapBalance represents the pending balance and the swaps used to compute it. +type PendingSwapBalance struct { + TargetMint *common.Account + DeltaQuarks uint64 + Swaps []*swap.Record +} + +// Assumes a USD stable coin core mint that must be involved in each swap +func (b *PendingSwapBalance) GetUsdMarketValue() float64 { + // Selling a launchpad currency for a USD stable coin + coreMintQuarksPerUnitBig := big.NewFloat(float64(common.GetMintQuarksPerUnit(common.CoreMintAccount))).SetPrec(128) + if common.IsCoreMint(b.TargetMint) { + deltaQuarksBig := big.NewFloat(float64(b.DeltaQuarks)).SetPrec(128) + usdMarketValue, _ := new(big.Float).Quo(deltaQuarksBig, coreMintQuarksPerUnitBig).Float64() + return usdMarketValue + } + + // Buying a launchpad currency with a USD stable coin + var coreMintQuarksUsedInBuys uint64 + for _, swap := range b.Swaps { + coreMintQuarksUsedInBuys += swap.Amount + } + coreMintQuarksUsedInBuysBig := big.NewFloat(float64(coreMintQuarksUsedInBuys)).SetPrec(128) + usdMarketValue, _ := new(big.Float).Quo(coreMintQuarksUsedInBuysBig, coreMintQuarksPerUnitBig).Float64() + return usdMarketValue +} + +// GetDeltaQuarksFromPendingSwaps returns a mapping of mint to pending swap balance that +// represents the simulated result of executing pending swaps for an owner account. +// Only swaps funded via SubmitIntent in the Funding, Funded, or Submitting states +// are included. +// +// The returned deltas represent the expected output amounts for each ToMint, +// calculated by simulating each swap execution using the current reserve state. +func GetDeltaQuarksFromPendingSwaps(ctx context.Context, data ocp_data.Provider, owner string) (map[string]*PendingSwapBalance, error) { + tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "GetDeltaQuarksFromPendingSwaps") + tracer.AddAttribute("owner", owner) + defer tracer.End() + + if !common.IsCoreMintUsdStableCoin() { + return nil, errors.New("core mint must be a usd stable coin") + } + + pendingSwaps, err := data.GetAllSwapsByOwnerAndStates( + ctx, + owner, + swap.StateFunding, + swap.StateFunded, + swap.StateSubmitting, + ) + if err == swap.ErrNotFound { + return make(map[string]*PendingSwapBalance), nil + } else if err != nil { + tracer.OnError(err) + return nil, errors.Wrap(err, "error getting pending swaps") + } + + // Collect all unique launchpad mints that need reserve state + launchpadMints := make(map[string]struct{}) + for _, swapRecord := range pendingSwaps { + if swapRecord.FundingSource != swap.FundingSourceSubmitIntent { + continue + } + + fromMint, err := common.NewAccountFromPublicKeyString(swapRecord.FromMint) + if err != nil { + tracer.OnError(err) + return nil, err + } + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + tracer.OnError(err) + return nil, err + } + + if !common.IsCoreMint(fromMint) && !common.IsCoreMint(toMint) { + return nil, errors.New("core mint must be involved in swap") + } + + if !common.IsCoreMint(fromMint) { + launchpadMints[swapRecord.FromMint] = struct{}{} + } + if !common.IsCoreMint(toMint) { + launchpadMints[swapRecord.ToMint] = struct{}{} + } + } + + // Fetch all reserve states upfront + reserveByMint := make(map[string]*currency.ReserveRecord) + now := time.Now() + for mint := range launchpadMints { + reserveRecord, err := data.GetCurrencyReserveAtTime(ctx, mint, now) + if err == currency.ErrNotFound { + // If the reserve cannot be found, try something a bit further in the past. + // + // todo: Fix bug in Postgres implementation + reserveRecord, err = data.GetCurrencyReserveAtTime(ctx, mint, now.Add(-15*time.Minute)) + if err != nil { + tracer.OnError(err) + return nil, errors.Wrapf(err, "error getting reserve for mint %s", mint) + } + } else if err != nil { + tracer.OnError(err) + return nil, errors.Wrapf(err, "error getting reserve for mint %s", mint) + } + reserveByMint[mint] = reserveRecord + } + + // Simulate each swap and accumulate output by destination mint + deltaByMint := make(map[string]*PendingSwapBalance) + for _, swapRecord := range pendingSwaps { + if swapRecord.FundingSource != swap.FundingSourceSubmitIntent { + continue + } + + outputQuarks, err := simulateSwapOutput(swapRecord, reserveByMint) + if err != nil { + tracer.OnError(err) + return nil, errors.Wrap(err, "error simulating swap output") + } + + if deltaByMint[swapRecord.ToMint] == nil { + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + tracer.OnError(err) + return nil, err + } + deltaByMint[swapRecord.ToMint] = &PendingSwapBalance{ + TargetMint: toMint, + } + } + deltaByMint[swapRecord.ToMint].DeltaQuarks += outputQuarks + deltaByMint[swapRecord.ToMint].Swaps = append(deltaByMint[swapRecord.ToMint].Swaps, swapRecord) + } + + return deltaByMint, nil +} + +// simulateSwapOutput calculates the expected output amount for a swap by simulating +// its execution using the provided reserve states. +func simulateSwapOutput(swapRecord *swap.Record, reserveByMint map[string]*currency.ReserveRecord) (uint64, error) { + fromMint, err := common.NewAccountFromPublicKeyString(swapRecord.FromMint) + if err != nil { + return 0, err + } + + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + return 0, err + } + + isFromCoreMint := common.IsCoreMint(fromMint) + isToCoreMint := common.IsCoreMint(toMint) + + switch { + case isFromCoreMint && !isToCoreMint: + // Buy: core mint -> launchpad currency + outputQuarks, err := simulateBuy(reserveByMint[swapRecord.ToMint], swapRecord.Amount) + if err != nil { + return 0, errors.Wrapf(err, "failed to simulate buy for mint %s", swapRecord.ToMint) + } + return outputQuarks, nil + + case !isFromCoreMint && isToCoreMint: + // Sell: launchpad currency -> core mint + outputQuarks, err := simulateSell(reserveByMint[swapRecord.FromMint], swapRecord.Amount) + if err != nil { + return 0, errors.Wrapf(err, "failed to simulate sell for mint %s", swapRecord.FromMint) + } + return outputQuarks, nil + + case !isFromCoreMint && !isToCoreMint: + // Buy-Sell: launchpad currency -> launchpad currency + // First sell FromMint to get core mint, then buy ToMint + coreMintQuarks, err := simulateSell(reserveByMint[swapRecord.FromMint], swapRecord.Amount) + if err != nil { + return 0, errors.Wrapf(err, "failed to simulate sell for mint %s", swapRecord.FromMint) + } + outputQuarks, err := simulateBuy(reserveByMint[swapRecord.ToMint], coreMintQuarks) + if err != nil { + return 0, errors.Wrapf(err, "failed to simulate buy for mint %s", swapRecord.ToMint) + } + return outputQuarks, nil + + default: + // core mint -> core mint is not a valid swap + return 0, errors.New("invalid swap: both mints are core mint") + } +} + +// simulateBuy simulates buying a launchpad currency with core mint quarks. +func simulateBuy(reserveRecord *currency.ReserveRecord, coreMintQuarks uint64) (uint64, error) { + if reserveRecord == nil { + return 0, errors.New("reserve record is nil") + } + + return currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ + CurrentSupplyInQuarks: reserveRecord.SupplyFromBonding, + BuyAmountInQuarks: coreMintQuarks, + ValueMintDecimals: uint8(common.CoreMintDecimals), + }), nil +} + +// simulateSell simulates selling a launchpad currency for core mint quarks. +func simulateSell(reserveRecord *currency.ReserveRecord, sellQuarks uint64) (uint64, error) { + if reserveRecord == nil { + return 0, errors.New("reserve record is nil") + } + + outputQuarks, _ := currencycreator.EstimateSell(¤cycreator.EstimateSellArgs{ + CurrentSupplyInQuarks: reserveRecord.SupplyFromBonding, + SellAmountInQuarks: sellQuarks, + ValueMintDecimals: uint8(common.CoreMintDecimals), + SellFeeBps: currencycreator.DefaultSellFeeBps, + }) + return outputQuarks, nil +} diff --git a/ocp/balance/calculator_test.go b/ocp/balance/calculator_test.go index 93fba32..7e42a07 100644 --- a/ocp/balance/calculator_test.go +++ b/ocp/balance/calculator_test.go @@ -16,9 +16,12 @@ import ( ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/action" + "github.com/code-payments/ocp-server/ocp/data/currency" "github.com/code-payments/ocp-server/ocp/data/deposit" "github.com/code-payments/ocp-server/ocp/data/intent" + "github.com/code-payments/ocp-server/ocp/data/swap" "github.com/code-payments/ocp-server/ocp/data/transaction" + "github.com/code-payments/ocp-server/solana/currencycreator" timelock_token_v1 "github.com/code-payments/ocp-server/solana/timelock/v1" "github.com/code-payments/ocp-server/testutil" ) @@ -449,3 +452,399 @@ func setupBalanceTestData(t *testing.T, env balanceTestEnv, data *balanceTestDat } } } + +func TestGetDeltaQuarksFromPendingSwaps_NoPendingSwaps(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + assert.Empty(t, deltaByMint) +} + +func TestGetDeltaQuarksFromPendingSwaps_FiltersByFundingSource(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMint := testutil.NewRandomAccount(t) + + // Create reserve for the launchpad mint + reserveRecord := ¤cy.ReserveRecord{ + Mint: launchpadMint.PublicKey().ToBase58(), + SupplyFromBonding: 1_000_000_000_000, // 100 tokens at 10 decimals + Time: time.Now(), + } + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, reserveRecord)) + + // Create swap with ExternalWallet funding source (should be filtered out) + swapRecord := &swap.Record{ + SwapId: "swap1", + Owner: owner.PublicKey().ToBase58(), + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: launchpadMint.PublicKey().ToBase58(), + Amount: 1_000_000, // 1 core mint unit + FundingId: "funding1", + FundingSource: swap.FundingSourceExternalWallet, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: "blockhash1", + ProofSignature: "proofsig1", + TransactionSignature: "txsig1", + State: swap.StateFunding, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecord)) + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + assert.Empty(t, deltaByMint) +} + +func TestGetDeltaQuarksFromPendingSwaps_FiltersByState(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMint := testutil.NewRandomAccount(t) + + // Create reserve for the launchpad mint + reserveRecord := ¤cy.ReserveRecord{ + Mint: launchpadMint.PublicKey().ToBase58(), + SupplyFromBonding: 1_000_000_000_000, + Time: time.Now(), + } + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, reserveRecord)) + + // Create swaps in non-pending states (should not be included) + nonPendingStates := []swap.State{ + swap.StateCreated, + swap.StateFinalized, + swap.StateFailed, + swap.StateCancelling, + swap.StateCancelled, + } + + for i, state := range nonPendingStates { + swapRecord := &swap.Record{ + SwapId: fmt.Sprintf("swap%d", i), + Owner: owner.PublicKey().ToBase58(), + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: launchpadMint.PublicKey().ToBase58(), + Amount: 1_000_000, + FundingId: fmt.Sprintf("funding%d", i), + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: fmt.Sprintf("blockhash%d", i), + ProofSignature: fmt.Sprintf("proofsig%d", i), + TransactionSignature: fmt.Sprintf("txsig%d", i), + State: state, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecord)) + } + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + assert.Empty(t, deltaByMint) +} + +func TestGetDeltaQuarksFromPendingSwaps_BuySwap(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMint := testutil.NewRandomAccount(t) + + supplyFromBonding := uint64(1_000_000_000_000) // 100 tokens + coreMintAmount := uint64(1_000_000) // 1 core mint unit + + // Create reserve for the launchpad mint + reserveRecord := ¤cy.ReserveRecord{ + Mint: launchpadMint.PublicKey().ToBase58(), + SupplyFromBonding: supplyFromBonding, + Time: time.Now(), + } + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, reserveRecord)) + + // Create buy swap: core mint -> launchpad currency + swapRecord := &swap.Record{ + SwapId: "swap1", + Owner: owner.PublicKey().ToBase58(), + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: launchpadMint.PublicKey().ToBase58(), + Amount: coreMintAmount, + FundingId: "funding1", + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: "blockhash1", + ProofSignature: "proofsig1", + TransactionSignature: "txsig1", + State: swap.StateFunding, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecord)) + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + require.Len(t, deltaByMint, 1) + + // Calculate expected output + expectedOutput := currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ + CurrentSupplyInQuarks: supplyFromBonding, + BuyAmountInQuarks: coreMintAmount, + ValueMintDecimals: uint8(common.CoreMintDecimals), + }) + + pendingBalance := deltaByMint[launchpadMint.PublicKey().ToBase58()] + assert.Equal(t, expectedOutput, pendingBalance.DeltaQuarks) + assert.Equal(t, launchpadMint.PublicKey().ToBase58(), pendingBalance.TargetMint.PublicKey().ToBase58()) + require.Len(t, pendingBalance.Swaps, 1) + assert.Equal(t, swapRecord.SwapId, pendingBalance.Swaps[0].SwapId) +} + +func TestGetDeltaQuarksFromPendingSwaps_SellSwap(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMint := testutil.NewRandomAccount(t) + + supplyFromBonding := uint64(1_000_000_000_000) // 100 tokens + sellAmount := uint64(10_000_000_000) // 1 token + + // Create reserve for the launchpad mint + reserveRecord := ¤cy.ReserveRecord{ + Mint: launchpadMint.PublicKey().ToBase58(), + SupplyFromBonding: supplyFromBonding, + Time: time.Now(), + } + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, reserveRecord)) + + // Create sell swap: launchpad currency -> core mint + swapRecord := &swap.Record{ + SwapId: "swap1", + Owner: owner.PublicKey().ToBase58(), + FromMint: launchpadMint.PublicKey().ToBase58(), + ToMint: common.CoreMintAccount.PublicKey().ToBase58(), + Amount: sellAmount, + FundingId: "funding1", + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: "blockhash1", + ProofSignature: "proofsig1", + TransactionSignature: "txsig1", + State: swap.StateFunded, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecord)) + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + require.Len(t, deltaByMint, 1) + + // Calculate expected output (with fees) + expectedOutput, _ := currencycreator.EstimateSell(¤cycreator.EstimateSellArgs{ + CurrentSupplyInQuarks: supplyFromBonding, + SellAmountInQuarks: sellAmount, + ValueMintDecimals: uint8(common.CoreMintDecimals), + SellFeeBps: currencycreator.DefaultSellFeeBps, + }) + + pendingBalance := deltaByMint[common.CoreMintAccount.PublicKey().ToBase58()] + assert.Equal(t, expectedOutput, pendingBalance.DeltaQuarks) + assert.Equal(t, common.CoreMintAccount.PublicKey().ToBase58(), pendingBalance.TargetMint.PublicKey().ToBase58()) + require.Len(t, pendingBalance.Swaps, 1) + assert.Equal(t, swapRecord.SwapId, pendingBalance.Swaps[0].SwapId) +} + +func TestGetDeltaQuarksFromPendingSwaps_BuySellSwap(t *testing.T) { + t.Skip("cross-token swaps not supported") + + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMintA := testutil.NewRandomAccount(t) + launchpadMintB := testutil.NewRandomAccount(t) + + supplyA := uint64(1_000_000_000_000) // 100 tokens + supplyB := uint64(2_000_000_000_000) // 200 tokens + sellAmount := uint64(10_000_000_000) // 1 token + + // Create reserves for both launchpad mints + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, ¤cy.ReserveRecord{ + Mint: launchpadMintA.PublicKey().ToBase58(), + SupplyFromBonding: supplyA, + Time: time.Now(), + })) + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, ¤cy.ReserveRecord{ + Mint: launchpadMintB.PublicKey().ToBase58(), + SupplyFromBonding: supplyB, + Time: time.Now(), + })) + + // Create buy-sell swap: launchpad A -> launchpad B + swapRecord := &swap.Record{ + SwapId: "swap1", + Owner: owner.PublicKey().ToBase58(), + FromMint: launchpadMintA.PublicKey().ToBase58(), + ToMint: launchpadMintB.PublicKey().ToBase58(), + Amount: sellAmount, + FundingId: "funding1", + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: "blockhash1", + ProofSignature: "proofsig1", + TransactionSignature: "txsig1", + State: swap.StateSubmitting, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecord)) + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + require.Len(t, deltaByMint, 1) + + // Calculate expected output: sell A -> core mint -> buy B + coreMintQuarks, _ := currencycreator.EstimateSell(¤cycreator.EstimateSellArgs{ + CurrentSupplyInQuarks: supplyA, + SellAmountInQuarks: sellAmount, + ValueMintDecimals: uint8(common.CoreMintDecimals), + SellFeeBps: currencycreator.DefaultSellFeeBps, + }) + expectedOutput := currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ + CurrentSupplyInQuarks: supplyB, + BuyAmountInQuarks: coreMintQuarks, + ValueMintDecimals: uint8(common.CoreMintDecimals), + }) + + pendingBalance := deltaByMint[launchpadMintB.PublicKey().ToBase58()] + assert.Equal(t, expectedOutput, pendingBalance.DeltaQuarks) + assert.Equal(t, launchpadMintB.PublicKey().ToBase58(), pendingBalance.TargetMint.PublicKey().ToBase58()) + require.Len(t, pendingBalance.Swaps, 1) + assert.Equal(t, swapRecord.SwapId, pendingBalance.Swaps[0].SwapId) +} + +func TestGetDeltaQuarksFromPendingSwaps_MultipleSwapsSameDestination(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMint := testutil.NewRandomAccount(t) + + supplyFromBonding := uint64(1_000_000_000_000) + coreMintAmount := uint64(1_000_000) + + // Create reserve for the launchpad mint + reserveRecord := ¤cy.ReserveRecord{ + Mint: launchpadMint.PublicKey().ToBase58(), + SupplyFromBonding: supplyFromBonding, + Time: time.Now(), + } + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, reserveRecord)) + + // Create multiple buy swaps to the same destination + pendingStates := []swap.State{swap.StateFunding, swap.StateFunded, swap.StateSubmitting} + for i, state := range pendingStates { + swapRecord := &swap.Record{ + SwapId: fmt.Sprintf("swap%d", i), + Owner: owner.PublicKey().ToBase58(), + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: launchpadMint.PublicKey().ToBase58(), + Amount: coreMintAmount, + FundingId: fmt.Sprintf("funding%d", i), + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: fmt.Sprintf("blockhash%d", i), + ProofSignature: fmt.Sprintf("proofsig%d", i), + TransactionSignature: fmt.Sprintf("txsig%d", i), + State: state, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecord)) + } + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + require.Len(t, deltaByMint, 1) + + // Calculate expected output for a single swap + singleSwapOutput := currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ + CurrentSupplyInQuarks: supplyFromBonding, + BuyAmountInQuarks: coreMintAmount, + ValueMintDecimals: uint8(common.CoreMintDecimals), + }) + + // Total should be 3x the single swap output + expectedTotal := singleSwapOutput * 3 + pendingBalance := deltaByMint[launchpadMint.PublicKey().ToBase58()] + assert.Equal(t, expectedTotal, pendingBalance.DeltaQuarks) + assert.Equal(t, launchpadMint.PublicKey().ToBase58(), pendingBalance.TargetMint.PublicKey().ToBase58()) + require.Len(t, pendingBalance.Swaps, 3) +} + +func TestGetDeltaQuarksFromPendingSwaps_MultipleDestinations(t *testing.T) { + env := setupBalanceTestEnv(t) + owner := testutil.NewRandomAccount(t) + launchpadMintA := testutil.NewRandomAccount(t) + launchpadMintB := testutil.NewRandomAccount(t) + + supplyA := uint64(1_000_000_000_000) + supplyB := uint64(2_000_000_000_000) + coreMintAmount := uint64(1_000_000) + + // Create reserves for both launchpad mints + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, ¤cy.ReserveRecord{ + Mint: launchpadMintA.PublicKey().ToBase58(), + SupplyFromBonding: supplyA, + Time: time.Now(), + })) + require.NoError(t, env.data.PutCurrencyReserve(env.ctx, ¤cy.ReserveRecord{ + Mint: launchpadMintB.PublicKey().ToBase58(), + SupplyFromBonding: supplyB, + Time: time.Now(), + })) + + // Create buy swap to mint A + swapRecordA := &swap.Record{ + SwapId: "swapA", + Owner: owner.PublicKey().ToBase58(), + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: launchpadMintA.PublicKey().ToBase58(), + Amount: coreMintAmount, + FundingId: "fundingA", + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: "blockhashA", + ProofSignature: "proofsigA", + TransactionSignature: "txsigA", + State: swap.StateFunding, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecordA)) + + // Create buy swap to mint B + swapRecordB := &swap.Record{ + SwapId: "swapB", + Owner: owner.PublicKey().ToBase58(), + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: launchpadMintB.PublicKey().ToBase58(), + Amount: coreMintAmount, + FundingId: "fundingB", + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + Blockhash: "blockhashB", + ProofSignature: "proofsigB", + TransactionSignature: "txsigB", + State: swap.StateFunded, + } + require.NoError(t, env.data.SaveSwap(env.ctx, swapRecordB)) + + deltaByMint, err := GetDeltaQuarksFromPendingSwaps(env.ctx, env.data, owner.PublicKey().ToBase58()) + require.NoError(t, err) + require.Len(t, deltaByMint, 2) + + expectedOutputA := currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ + CurrentSupplyInQuarks: supplyA, + BuyAmountInQuarks: coreMintAmount, + ValueMintDecimals: uint8(common.CoreMintDecimals), + }) + expectedOutputB := currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ + CurrentSupplyInQuarks: supplyB, + BuyAmountInQuarks: coreMintAmount, + ValueMintDecimals: uint8(common.CoreMintDecimals), + }) + + pendingBalanceA := deltaByMint[launchpadMintA.PublicKey().ToBase58()] + assert.Equal(t, expectedOutputA, pendingBalanceA.DeltaQuarks) + assert.Equal(t, launchpadMintA.PublicKey().ToBase58(), pendingBalanceA.TargetMint.PublicKey().ToBase58()) + require.Len(t, pendingBalanceA.Swaps, 1) + assert.Equal(t, swapRecordA.SwapId, pendingBalanceA.Swaps[0].SwapId) + + pendingBalanceB := deltaByMint[launchpadMintB.PublicKey().ToBase58()] + assert.Equal(t, expectedOutputB, pendingBalanceB.DeltaQuarks) + assert.Equal(t, launchpadMintB.PublicKey().ToBase58(), pendingBalanceB.TargetMint.PublicKey().ToBase58()) + require.Len(t, pendingBalanceB.Swaps, 1) + assert.Equal(t, swapRecordB.SwapId, pendingBalanceB.Swaps[0].SwapId) +} diff --git a/ocp/data/internal.go b/ocp/data/internal.go index 4031b5a..7727e39 100644 --- a/ocp/data/internal.go +++ b/ocp/data/internal.go @@ -203,7 +203,7 @@ type DatabaseData interface { SaveSwap(ctx context.Context, record *swap.Record) error GetSwapById(ctx context.Context, id string) (*swap.Record, error) GetSwapByFundingId(ctx context.Context, fundingId string) (*swap.Record, error) - GetAllSwapsByOwnerAndState(ctx context.Context, owner string, state swap.State) ([]*swap.Record, error) + GetAllSwapsByOwnerAndStates(ctx context.Context, owner string, states ...swap.State) ([]*swap.Record, error) GetAllSwapsByState(ctx context.Context, state swap.State, opts ...query.Option) ([]*swap.Record, error) GetSwapCountByState(ctx context.Context, state swap.State) (uint64, error) @@ -711,8 +711,8 @@ func (dp *DatabaseProvider) GetSwapById(ctx context.Context, id string) (*swap.R func (dp *DatabaseProvider) GetSwapByFundingId(ctx context.Context, fundingId string) (*swap.Record, error) { return dp.swaps.GetByFundingId(ctx, fundingId) } -func (dp *DatabaseProvider) GetAllSwapsByOwnerAndState(ctx context.Context, owner string, state swap.State) ([]*swap.Record, error) { - return dp.swaps.GetAllByOwnerAndState(ctx, owner, state) +func (dp *DatabaseProvider) GetAllSwapsByOwnerAndStates(ctx context.Context, owner string, states ...swap.State) ([]*swap.Record, error) { + return dp.swaps.GetAllByOwnerAndStates(ctx, owner, states...) } func (dp *DatabaseProvider) GetAllSwapsByState(ctx context.Context, state swap.State, opts ...query.Option) ([]*swap.Record, error) { req, err := query.DefaultPaginationHandler(opts...) diff --git a/ocp/data/swap/memory/store.go b/ocp/data/swap/memory/store.go index e9a00dd..6350ba8 100644 --- a/ocp/data/swap/memory/store.go +++ b/ocp/data/swap/memory/store.go @@ -87,12 +87,12 @@ func (s *store) GetByFundingId(_ context.Context, fundingId string) (*swap.Recor return &cloned, nil } -func (s *store) GetAllByOwnerAndState(_ context.Context, owner string, state swap.State) ([]*swap.Record, error) { +func (s *store) GetAllByOwnerAndStates(_ context.Context, owner string, states ...swap.State) ([]*swap.Record, error) { s.mu.RLock() defer s.mu.RUnlock() items := s.findByOwner(owner) - items = s.filterByState(items, state) + items = s.filterByStates(items, states) if len(items) == 0 { return nil, swap.ErrNotFound @@ -207,10 +207,15 @@ func (s *store) filter(items []*swap.Record, cursor query.Cursor, limit uint64, return res } -func (s *store) filterByState(items []*swap.Record, state swap.State) []*swap.Record { +func (s *store) filterByStates(items []*swap.Record, states []swap.State) []*swap.Record { + stateSet := make(map[swap.State]struct{}, len(states)) + for _, state := range states { + stateSet[state] = struct{}{} + } + var res []*swap.Record for _, item := range items { - if item.State == state { + if _, ok := stateSet[item.State]; ok { res = append(res, item) } } diff --git a/ocp/data/swap/postgres/model.go b/ocp/data/swap/postgres/model.go index 26fb8a8..2bc9afd 100644 --- a/ocp/data/swap/postgres/model.go +++ b/ocp/data/swap/postgres/model.go @@ -3,6 +3,8 @@ package postgres import ( "context" "database/sql" + "fmt" + "strings" "time" "github.com/jmoiron/sqlx" @@ -155,14 +157,21 @@ func dbGetByFundingId(ctx context.Context, db *sqlx.DB, fundingId string) (*mode return res, nil } -func dbGetAllByOwnerAndState(ctx context.Context, db *sqlx.DB, owner string, state swap.State) ([]*model, error) { +func dbGetAllByOwnerAndStates(ctx context.Context, db *sqlx.DB, owner string, states []swap.State) ([]*model, error) { res := []*model{} - query := `SELECT id, swap_id, owner, from_mint, to_mint, amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at - FROM ` + tableName + ` - WHERE owner = $1 AND state = $2` + stateStrings := make([]string, len(states)) + for i, s := range states { + stateStrings[i] = fmt.Sprintf("%d", s) + } + + query := fmt.Sprintf(`SELECT id, swap_id, owner, from_mint, to_mint, amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at + FROM `+tableName+` + WHERE owner = $1 AND state IN (%s)`, + strings.Join(stateStrings, ", "), + ) - err := db.SelectContext(ctx, &res, query, owner, state) + err := db.SelectContext(ctx, &res, query, owner) if err != nil { return nil, pgutil.CheckNoRows(err, swap.ErrNotFound) } diff --git a/ocp/data/swap/postgres/store.go b/ocp/data/swap/postgres/store.go index 102e9f8..58cf889 100644 --- a/ocp/data/swap/postgres/store.go +++ b/ocp/data/swap/postgres/store.go @@ -53,8 +53,8 @@ func (s *store) GetByFundingId(ctx context.Context, id string) (*swap.Record, er return fromModel(obj), nil } -func (s *store) GetAllByOwnerAndState(ctx context.Context, owner string, state swap.State) ([]*swap.Record, error) { - models, err := dbGetAllByOwnerAndState(ctx, s.db, owner, state) +func (s *store) GetAllByOwnerAndStates(ctx context.Context, owner string, states ...swap.State) ([]*swap.Record, error) { + models, err := dbGetAllByOwnerAndStates(ctx, s.db, owner, states) if err != nil { return nil, err } diff --git a/ocp/data/swap/store.go b/ocp/data/swap/store.go index ce870ac..9b8b9f4 100644 --- a/ocp/data/swap/store.go +++ b/ocp/data/swap/store.go @@ -23,8 +23,8 @@ type Store interface { // GetByFundingId gets a swap by the funding ID GetByFundingId(ctx context.Context, fundingId string) (*Record, error) - // GetAllByOwnerAndState gets all swaps for an owner in a state - GetAllByOwnerAndState(ctx context.Context, owner string, state State) ([]*Record, error) + // GetAllByOwnerAndStates gets all swaps for an owner in any of the provided states + GetAllByOwnerAndStates(ctx context.Context, owner string, states ...State) ([]*Record, error) // GetAllByState gets all swaps by state GetAllByState(ctx context.Context, state State, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*Record, error) diff --git a/ocp/data/swap/tests/tests.go b/ocp/data/swap/tests/tests.go index d9b7807..56f1610 100644 --- a/ocp/data/swap/tests/tests.go +++ b/ocp/data/swap/tests/tests.go @@ -18,7 +18,7 @@ func RunTests(t *testing.T, s swap.Store, teardown func()) { testRoundTrip, testUpdateHappyPath, testUpdateStaleRecord, - testGetAllByOwnerAndState, + testGetAllByOwnerAndStates, testGetAllByState, } { tf(t, s) @@ -185,11 +185,11 @@ func testUpdateStaleRecord(t *testing.T, s swap.Store) { }) } -func testGetAllByOwnerAndState(t *testing.T, s swap.Store) { - t.Run("testGetAllByOwnerAndState", func(t *testing.T) { +func testGetAllByOwnerAndStates(t *testing.T, s swap.Store) { + t.Run("testGetAllByOwnerAndStates", func(t *testing.T) { ctx := context.Background() - _, err := s.GetAllByOwnerAndState(ctx, "test_owner_0", swap.StateFinalized) + _, err := s.GetAllByOwnerAndStates(ctx, "test_owner_0", swap.StateFinalized) assert.Equal(t, swap.ErrNotFound, err) var records []*swap.Record @@ -223,9 +223,10 @@ func testGetAllByOwnerAndState(t *testing.T, s swap.Store) { records = append(records, record) } + // Test with single state for _, owner := range []string{"test_owner_0", "test_owner_1"} { for state := range swap.StateCancelled + 1 { - allActual, err := s.GetAllByOwnerAndState(ctx, owner, state) + allActual, err := s.GetAllByOwnerAndStates(ctx, owner, state) require.NoError(t, err) require.NotEmpty(t, allActual) @@ -244,6 +245,25 @@ func testGetAllByOwnerAndState(t *testing.T, s swap.Store) { } } } + + // Test with multiple states + allActual, err := s.GetAllByOwnerAndStates(ctx, "test_owner_0", swap.StateCreated, swap.StateFunding, swap.StateFunded) + require.NoError(t, err) + require.NotEmpty(t, allActual) + + for _, actual := range allActual { + assert.Equal(t, "test_owner_0", actual.Owner) + assert.True(t, actual.State == swap.StateCreated || actual.State == swap.StateFunding || actual.State == swap.StateFunded) + } + + // Verify count matches expected records + expectedCount := 0 + for _, record := range records { + if record.Owner == "test_owner_0" && (record.State == swap.StateCreated || record.State == swap.StateFunding || record.State == swap.StateFunded) { + expectedCount++ + } + } + assert.Len(t, allActual, expectedCount) }) } diff --git a/ocp/rpc/account/server.go b/ocp/rpc/account/server.go index ab8aa36..b2425d9 100644 --- a/ocp/rpc/account/server.go +++ b/ocp/rpc/account/server.go @@ -31,8 +31,9 @@ var ( ) type balanceMetadata struct { - value uint64 - source accountpb.TokenAccountInfo_BalanceSource + realValue uint64 // The real value of a token account's balance + additionalPendingSwapValue *balance.PendingSwapBalance // Estimated additional pending value of a token account's balance from swaps + source accountpb.TokenAccountInfo_BalanceSource } type server struct { @@ -231,9 +232,9 @@ func (s *server) GetTokenAccountInfos(ctx context.Context, req *accountpb.GetTok } // Fetch balances - balanceMetadataByTokenAccount, err := s.fetchBalances(ctx, filteredRecords) + balanceMetadataByTokenAccount, err := s.fetchBalances(ctx, owner, filteredRecords) if err != nil { - log.With(zap.Error(err)).Warn("failure fetching balances") + log.With(zap.Error(err)).Warn("failure getting balances") return nil, status.Error(codes.Internal, "") } @@ -282,9 +283,10 @@ func (s *server) GetTokenAccountInfos(ctx context.Context, req *accountpb.GetTok // fetchBalances optimally routes and batches balance calculations for a set of // account records. -func (s *server) fetchBalances(ctx context.Context, allAccountRecords []*common.AccountRecords) (map[string]*balanceMetadata, error) { +func (s *server) fetchBalances(ctx context.Context, owner *common.Account, allAccountRecords []*common.AccountRecords) (map[string]*balanceMetadata, error) { balanceMetadataByTokenAccount := make(map[string]*balanceMetadata) + // Fetch cached balances var mangedByCodeRecords []*common.AccountRecords for _, accountRecords := range allAccountRecords { if accountRecords.IsManagedByCode(ctx) { @@ -293,8 +295,8 @@ func (s *server) fetchBalances(ctx context.Context, allAccountRecords []*common. // Don't calculate a balance for now, since the caching strategy // is not possible. balanceMetadataByTokenAccount[accountRecords.General.TokenAccount] = &balanceMetadata{ - value: 0, - source: accountpb.TokenAccountInfo_BALANCE_SOURCE_UNKNOWN, + realValue: 0, + source: accountpb.TokenAccountInfo_BALANCE_SOURCE_UNKNOWN, } } } @@ -304,11 +306,29 @@ func (s *server) fetchBalances(ctx context.Context, allAccountRecords []*common. } for tokenAccount, quarks := range balancesByTokenAccount { balanceMetadataByTokenAccount[tokenAccount] = &balanceMetadata{ - value: quarks, - source: accountpb.TokenAccountInfo_BALANCE_SOURCE_CACHE, + realValue: quarks, + source: accountpb.TokenAccountInfo_BALANCE_SOURCE_CACHE, } } + // Fetch and add any pending balances from swaps + pendingSwapBalances, err := balance.GetDeltaQuarksFromPendingSwaps(ctx, s.data, owner.PublicKey().ToBase58()) + if err != nil { + return nil, err + } + for _, accountRecords := range mangedByCodeRecords { + if accountRecords.General.AccountType != commonpb.AccountType_PRIMARY { + continue + } + + pendingSwapBalance, ok := pendingSwapBalances[accountRecords.General.MintAccount] + if !ok { + continue + } + + balanceMetadataByTokenAccount[accountRecords.General.TokenAccount].additionalPendingSwapValue = pendingSwapBalance + } + // Any accounts that aren't Timelock can be deferred to the blockchain // // todo: support batching @@ -336,8 +356,8 @@ func (s *server) fetchBalances(ctx context.Context, allAccountRecords []*common. protoBalanceSource = accountpb.TokenAccountInfo_BALANCE_SOURCE_UNKNOWN } balanceMetadataByTokenAccount[tokenAccount.PublicKey().ToBase58()] = &balanceMetadata{ - value: quarks, - source: protoBalanceSource, + realValue: quarks, + source: protoBalanceSource, } } @@ -417,7 +437,7 @@ func (s *server) getProtoAccountInfo(ctx context.Context, records *common.Accoun } // Otherwise, check whether it looks like the gift card was claimed. - if prefetchedBalanceMetadata.source == accountpb.TokenAccountInfo_BALANCE_SOURCE_CACHE && prefetchedBalanceMetadata.value == 0 { + if prefetchedBalanceMetadata.source == accountpb.TokenAccountInfo_BALANCE_SOURCE_CACHE && prefetchedBalanceMetadata.realValue == 0 { claimState = accountpb.TokenAccountInfo_CLAIM_STATE_CLAIMED } else if records.Timelock.IsClosed() { claimState = accountpb.TokenAccountInfo_CLAIM_STATE_CLAIMED @@ -445,7 +465,7 @@ func (s *server) getProtoAccountInfo(ctx context.Context, records *common.Accoun // If the gift card account is claimed or expired, force the balance to zero. if claimState == accountpb.TokenAccountInfo_CLAIM_STATE_CLAIMED || claimState == accountpb.TokenAccountInfo_CLAIM_STATE_EXPIRED { prefetchedBalanceMetadata.source = accountpb.TokenAccountInfo_BALANCE_SOURCE_CACHE - prefetchedBalanceMetadata.value = 0 + prefetchedBalanceMetadata.realValue = 0 } } @@ -457,9 +477,15 @@ func (s *server) getProtoAccountInfo(ctx context.Context, records *common.Accoun } } + estimatedTotalBalance := prefetchedBalanceMetadata.realValue + if prefetchedBalanceMetadata.additionalPendingSwapValue != nil { + estimatedTotalBalance += prefetchedBalanceMetadata.additionalPendingSwapValue.DeltaQuarks + } + + // todo: USD cost basis requires tests var usdCostBasis float64 if common.IsCoreMint(mintAccount) && common.IsCoreMintUsdStableCoin() { - usdCostBasis = float64(prefetchedBalanceMetadata.value) / float64(common.CoreMintQuarksPerUnit) + usdCostBasis = float64(estimatedTotalBalance) / float64(common.CoreMintQuarksPerUnit) } else { switch records.General.AccountType { case commonpb.AccountType_PRIMARY: @@ -468,6 +494,10 @@ func (s *server) getProtoAccountInfo(ctx context.Context, records *common.Accoun if err != nil { return nil, err } + + if prefetchedBalanceMetadata.additionalPendingSwapValue != nil { + usdCostBasis += prefetchedBalanceMetadata.additionalPendingSwapValue.GetUsdMarketValue() + } default: usdCostBasis = 0 // Account type not supported } @@ -480,7 +510,7 @@ func (s *server) getProtoAccountInfo(ctx context.Context, records *common.Accoun AccountType: records.General.AccountType, Index: records.General.Index, BalanceSource: prefetchedBalanceMetadata.source, - Balance: prefetchedBalanceMetadata.value, + Balance: estimatedTotalBalance, UsdCostBasis: usdCostBasis, ManagementState: managementState, BlockchainState: blockchainState, diff --git a/ocp/rpc/transaction/swap.go b/ocp/rpc/transaction/swap.go index 3b3c2fe..2a99cb3 100644 --- a/ocp/rpc/transaction/swap.go +++ b/ocp/rpc/transaction/swap.go @@ -581,7 +581,7 @@ func (s *transactionServer) GetPendingSwaps(ctx context.Context, req *transactio } // Swap is created, but requires client to initiate the funding - createdSwaps, err := s.data.GetAllSwapsByOwnerAndState(ctx, owner.PublicKey().ToBase58(), swap.StateCreated) + createdSwaps, err := s.data.GetAllSwapsByOwnerAndStates(ctx, owner.PublicKey().ToBase58(), swap.StateCreated) if err != nil && err != swap.ErrNotFound { log.With(zap.Error(err)).Warn("failure getting swaps in CREATED state") return nil, status.Error(codes.Internal, "")