diff --git a/cmd/rpc/query.go b/cmd/rpc/query.go index bf5a07c14d..7373f12e31 100644 --- a/cmd/rpc/query.go +++ b/cmd/rpc/query.go @@ -37,11 +37,15 @@ func (s *Server) Transaction(w http.ResponseWriter, r *http.Request, _ httproute // Transactions handles multiple transactions in a single request func (s *Server) Transactions(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { // create a slice to hold the incoming transactions - var txs []lib.TransactionI + var transactions []*lib.Transaction // unmarshal the HTTP request body into the transactions slice - if ok := unmarshal(w, r, &txs); !ok { + if ok := unmarshal(w, r, &transactions); !ok { return } + txs := make([]lib.TransactionI, len(transactions)) + for i := range transactions { + txs[i] = transactions[i] + } // submit transactions to RPC server s.submitTxs(w, txs) } diff --git a/fsm/dex.go b/fsm/dex.go index 0ce97664eb..89e022ee2c 100644 --- a/fsm/dex.go +++ b/fsm/dex.go @@ -5,10 +5,18 @@ import ( "github.com/canopy-network/canopy/lib" "github.com/canopy-network/canopy/lib/crypto" "math/big" + "slices" "sort" "strings" ) +const ( + // dexLPEvictionIDDomain separates replacement withdrawal IDs from other deterministic DEX IDs. + dexLPEvictionIDDomain = "dex-lp-eviction-v1" + // dexLPMigrationIDDomain separates migration withdrawal IDs from other deterministic DEX IDs. + dexLPMigrationIDDomain = "dex-lp-migration-v1" +) + /* Dex.go implements logic to handle AMM style atomic exchanges between root & nested chains not to be confused with 1 way order book swaps implemented in swap.go */ @@ -398,15 +406,29 @@ func (s *StateMachine) HandleDexBatchOrders(remoteBatch *lib.DexBatch, x, y *uin // HandleBatchWithdraw() handles local/remote liquidity withdraw requests. // local=true: x=local pool, y=counter mirror; local=false: x=counter mirror, y=local pool. func (s *StateMachine) HandleBatchWithdraw(batch *lib.DexBatch, counterChainId uint64, x, y *uint64, local bool) lib.ErrorI { + return s.handleBatchWithdraw(batch, counterChainId, x, y, local, nil, true) +} + +func (s *StateMachine) handleBatchWithdraw(batch *lib.DexBatch, counterChainId uint64, x, y *uint64, local bool, p *Pool, persist bool) lib.ErrorI { if len(batch.Withdrawals) == 0 { return nil } // initialize vars var totalPointsToRemove uint64 // get liquidity pool - p, err := s.GetPool(counterChainId + LiquidityPoolAddend) - if err != nil { - return err + var err lib.ErrorI + if p == nil { + p, err = s.GetPool(counterChainId + LiquidityPoolAddend) + if err != nil { + return err + } + } + // removes legacy zero-point “ghost” LPs + p.Points = slices.DeleteFunc(p.Points, func(point *lib.PoolPoints) bool { return point.Points == 0 }) + // builds an O(1) address lookup containing pointers to the actual pool entries + pointsByAddress := make(map[string]*lib.PoolPoints, len(p.Points)) + for _, point := range p.Points { + pointsByAddress[string(point.Address)] = point } // collect withdrawals for _, w := range batch.Withdrawals { @@ -414,13 +436,13 @@ func (s *StateMachine) HandleBatchWithdraw(batch *lib.DexBatch, counterChainId u s.log.Warnf("an error occurred retrieving the pool points for: %x, nil withdrawal", []byte{}) continue // defensive } - initialPoints, e := p.GetPointsFor(w.Address) - if e != nil { - s.log.Errorf("an error occurred retrieving the pool points for: %x, %s", w.Address, e.Error()) + holder := pointsByAddress[string(w.Address)] + if holder == nil { + s.log.Errorf("an error occurred retrieving the pool points for: %x", w.Address) continue // defensive } // update the total points to remove - pointsToRemove := lib.SafeMulDiv(initialPoints, w.Percent, 100) + pointsToRemove := lib.SafeMulDiv(holder.Points, w.Percent, 100) var overflow bool totalPointsToRemove, overflow = lib.AddUint64(totalPointsToRemove, pointsToRemove) if overflow { @@ -428,6 +450,9 @@ func (s *StateMachine) HandleBatchWithdraw(batch *lib.DexBatch, counterChainId u } } if totalPointsToRemove == 0 || p.TotalPoolPoints == 0 { + if persist { + return s.SetPool(p) + } return nil } // compute totals; actual paid amounts are tracked below to avoid burning rounding dust @@ -446,21 +471,20 @@ func (s *StateMachine) HandleBatchWithdraw(batch *lib.DexBatch, counterChainId u s.log.Warnf("an error occurred retrieving the pool points for: %x, nil withdrawal", []byte{}) continue // defensive } - initialPoints, e := p.GetPointsFor(w.Address) - if e != nil { - s.log.Warnf("an error occurred retrieving the pool points for: %x, %s", w.Address, e.Error()) + holder := pointsByAddress[string(w.Address)] + if holder == nil { + s.log.Warnf("an error occurred retrieving the pool points for: %x", w.Address) continue // defensive } // calculate points from percent - points := lib.SafeMulDiv(initialPoints, w.Percent, 100) + points := lib.SafeMulDiv(holder.Points, w.Percent, 100) // calculate share yShare := lib.SafeMulDiv(totalYWithdrawal, points, totalPointsToRemove) // calculate virtual share xShare := lib.SafeMulDiv(totalXWithdraw, points, totalPointsToRemove) // remove points from pool - if err = p.RemovePoints(w.Address, points); err != nil { - return err - } + p.TotalPoolPoints -= points + holder.Points -= points payout, counter := yShare, xShare if local { payout, counter = xShare, yShare @@ -472,10 +496,16 @@ func (s *StateMachine) HandleBatchWithdraw(batch *lib.DexBatch, counterChainId u return err } // emit withdraw event - if err = s.EventDexLiquidityWithdraw(w.Address, w.OrderId, payout, counter, points, counterChainId); err != nil { + if err = s.EventDexLiquidityWithdraw(w.Address, w.OrderId, payout, counter, points, w.Percent, counterChainId); err != nil { return err } } + // second call removes providers whose points became zero during the current withdrawal + p.Points = slices.DeleteFunc(p.Points, func(point *lib.PoolPoints) bool { return point.Points == 0 }) + // unreachable while the permanent dead address retains its non-withdrawable points + if p.TotalPoolPoints == 0 { + return lib.ErrZeroLiquidityPool() + } // update the remote and local pool size ledgers using actual paid amounts (avoid burning rounding dust) *y -= paidY *x -= paidX @@ -486,19 +516,36 @@ func (s *StateMachine) HandleBatchWithdraw(batch *lib.DexBatch, counterChainId u p.Amount = *y } // set the pool in state - return s.SetPool(p) + if persist { + return s.SetPool(p) + } + return nil } // HandleBatchDeposit() handles local/remote liquidity deposits. // local=true: x=local pool (actual token movement), y=counter mirror. local=false: x=counter mirror, y=local pool. func (s *StateMachine) HandleBatchDeposit(batch *lib.DexBatch, chainId uint64, x, y *uint64, local bool) lib.ErrorI { + return s.handleBatchDeposit(batch, chainId, x, y, local, true, nil, true) +} + +// handleBatchDeposit() is a helper function for handling the batch deposits +func (s *StateMachine) handleBatchDeposit(batch *lib.DexBatch, chainId uint64, x, y *uint64, local, checkCap bool, p *Pool, persist bool) lib.ErrorI { if len(batch.Deposits) == 0 { return nil } // get the liquidity pool - p, err := s.GetPool(chainId + LiquidityPoolAddend) - if err != nil { - return err + var err lib.ErrorI + if p == nil { + p, err = s.GetPool(chainId + LiquidityPoolAddend) + if err != nil { + return err + } + } + // if capacity should be checked (recursive func artifact) + if checkCap { + if handled, e := s.handleCappedBatchDeposit(batch, p, chainId, x, y, local); handled { + return e + } } // x = the initial 'deposit' pool balance // y = the 'counter' pool balance @@ -575,26 +622,14 @@ func (s *StateMachine) HandleBatchDeposit(batch *lib.DexBatch, chainId uint64, x return err } } - // using integer math and geometric mean of reserves: - // deltaPoolPoints = L * ( √((x + totalDeposit) * y) - √(x * y) ) / √(x * y) or simplified as: - // deltaPoolPoints = L * (newK - oldK) / oldK (in a 1 sided deposit scenario dY=0 thus this formula) - oldK := lib.SqrtProductUint64(*x, *y) - if oldK == 0 { - return ErrInvalidLiquidityPool() - } - // only accepted deposits enter the reserves - xAfterTotalDeposit, overflow := lib.AddUint64(*x, totalDeposit) - if overflow { - return ErrInvalidLiquidityPool() - } - newK := lib.SqrtProductUint64(xAfterTotalDeposit, *y) - if newK < oldK { - return ErrInvalidLiquidityPool() + // calculate points as if all accepted deposits are one deposit + totalDL, err := liquidityDepositPoints(L, *x, *y, totalDeposit) + if err != nil { + return err } - // totalDL is calculated as if all accepted deposits are just 1 big deposit - totalDL := lib.SafeMulDiv(L, newK-oldK, oldK) // PASS 2: distribute points for the accepted deposits var distributed uint64 + var overflow bool for i, deposit := range batch.Deposits { if !accepted[i] { continue @@ -632,7 +667,203 @@ func (s *StateMachine) HandleBatchDeposit(batch *lib.DexBatch, chainId uint64, x return err } // update the pool - return s.SetPool(p) + if persist { + return s.SetPool(p) + } + return nil +} + +// handleCappedBatchDeposit migrates legacy pools and deterministically admits the best-funded newcomers. +// MaxLiquidityProviders bounds serialized point entries, so the permanent dead address consumes one slot. +func (s *StateMachine) handleCappedBatchDeposit(batch *lib.DexBatch, p *Pool, chainId uint64, x, y *uint64, local bool) (bool, lib.ErrorI) { + // execute the one-time legacy migration before processing newcomers + if err := s.migrateLegacyLPs(batch, p, chainId, x, y, local); err != nil { + return true, err + } + // initialize vars + var err lib.ErrorI + type candidate struct { + amount uint64 + deposits []*lib.DexLiquidityDeposit + } + // index current providers before classifying the batch + providers := make(map[string]bool, len(p.Points)) + for _, point := range p.Points { + providers[string(point.Address)] = true + } + // determine incumbents vs newcomers + var incumbents []*lib.DexLiquidityDeposit + var newcomers []*candidate + byAddress := make(map[string]*candidate) + for _, deposit := range batch.Deposits { + address := string(deposit.Address) + // zero deposits cannot create a provider and follow the ordinary deposit path + if providers[address] || deposit.Amount == 0 { + incumbents = append(incumbents, deposit) + } else { + // aggregate split deposits so capacity and ranking operate per provider + newcomer := byAddress[address] + if newcomer == nil { + newcomer = new(candidate) + byAddress[address], newcomers = newcomer, append(newcomers, newcomer) + } + var overflow bool + newcomer.amount, overflow = lib.AddUint64(newcomer.amount, deposit.Amount) + if overflow { + return true, ErrInvalidLiquidityPool() + } + newcomer.deposits = append(newcomer.deposits, deposit) + } + } + // if provider count doesn't exceed max liquidity providers, exit + if len(p.Points)+len(newcomers) <= lib.MaxLiquidityProviders { + return false, nil + } + // otherwise; update all incumbent deposits first + if err := s.handleBatchDeposit(&lib.DexBatch{Deposits: incumbents}, chainId, x, y, local, false, p, false); err != nil { + return true, err + } + // sort the newcomers by total deposit amount then receiptHash + address + sort.SliceStable(newcomers, func(i, j int) bool { + if newcomers[i].amount != newcomers[j].amount { + return newcomers[i].amount > newcomers[j].amount + } + return bytes.Compare(liquidityProviderTieBreakHash(batch.ReceiptHash, newcomers[i].deposits[0].Address), liquidityProviderTieBreakHash(batch.ReceiptHash, newcomers[j].deposits[0].Address)) < 0 + }) + // create an apply deposit callback + apply := func(newcomer *candidate) lib.ErrorI { + return s.handleBatchDeposit(&lib.DexBatch{Deposits: newcomer.deposits}, chainId, x, y, local, false, p, false) + } + // for each newcomer + var lowest *lib.PoolPoints + for _, newcomer := range newcomers { + // if there's a free slot + if len(p.Points) < lib.MaxLiquidityProviders { + if err = apply(newcomer); err != nil { + return true, err + } + continue + } + // determine the lowest holder who may be force-evicted + if lowest == nil { + for _, point := range p.Points { + if !bytes.Equal(point.Address, deadAddr.Bytes()) && (lowest == nil || point.Points < lowest.Points) { + lowest = point + } + } + } + if lowest == nil { + return true, ErrInvalidLiquidityPool() + } + // determine the xOut and yOut for the lowest holder + xOut := lib.SafeMulDiv(*x, lowest.Points, p.TotalPoolPoints) + yOut := lib.SafeMulDiv(*y, lowest.Points, p.TotalPoolPoints) + // calculate the newcomer's share assuming the lowest LP has already been removed + totalShare, e := liquidityDepositPoints(p.TotalPoolPoints-lowest.GetPoints(), *x-xOut, *y-yOut, newcomer.amount) + if e != nil { + return true, e + } + var share uint64 + for _, deposit := range newcomer.deposits { + share += lib.SafeMulDiv(totalShare, deposit.Amount, newcomer.amount) + } + // if the newcomer is less than the lowest points + if share <= lowest.Points { + // reject the newcomer - returning his amount in escrow (holding pool) back to his account + if local { + if err = s.PoolSub(chainId+HoldingPoolAddend, newcomer.amount); err != nil { + return true, err + } + if err = s.AccountAdd(crypto.NewAddress(newcomer.deposits[0].Address), newcomer.amount); err != nil { + return true, err + } + } + // continue to the next newcomer + continue + } + // auto withdraw the lowest + evictionId := crypto.ShortHash(lib.JoinLenPrefix([]byte(dexLPEvictionIDDomain), batch.ReceiptHash, lowest.Address, newcomer.deposits[0].Address)) + if err = s.handleBatchWithdraw(&lib.DexBatch{Withdrawals: []*lib.DexLiquidityWithdraw{{ + Address: lowest.Address, Percent: 100, OrderId: evictionId, + }}}, chainId, x, y, local, p, false); err != nil { + return true, err + } + // apply the newcomer's deposit + if err = apply(newcomer); err != nil { + return true, err + } + lowest = nil + } + return true, s.SetPool(p) +} + +// liquidityProviderTieBreakHash() deterministically ranks equal-stake providers using the batch receipt hash. +func liquidityProviderTieBreakHash(receiptHash, address []byte) []byte { + return crypto.Hash(append(bytes.Clone(receiptHash), address...)) +} + +// liquidityDepositPoints() calculates points minted by a one-sided deposit against the geometric-mean invariant. +func liquidityDepositPoints(totalPoints, x, y, amount uint64) (uint64, lib.ErrorI) { + xAfter, overflow := lib.AddUint64(x, amount) + if overflow { + return 0, ErrInvalidLiquidityPool() + } + oldK, newK := lib.SqrtProductUint64(x, y), lib.SqrtProductUint64(xAfter, y) + if oldK == 0 || newK < oldK { + return 0, ErrInvalidLiquidityPool() + } + return lib.SafeMulDiv(totalPoints, newK-oldK, oldK), nil +} + +// migrateLegacyLPs() performs the one-time migration of a pool above the provider limit. +func (s *StateMachine) migrateLegacyLPs(batch *lib.DexBatch, p *Pool, chainId uint64, x, y *uint64, local bool) lib.ErrorI { + // calculate how many legacy providers must be removed to reach the limit + excess := len(p.Points) - lib.MaxLiquidityProviders + if excess <= 0 { + return nil + } + // rank the providers from lowest to highest points + sort.Slice(p.Points, func(i, j int) bool { + // rank by points first + if p.Points[i].Points != p.Points[j].Points { + return p.Points[i].Points < p.Points[j].Points + } + // break equal-point ties deterministically using the receipt hash + return bytes.Compare(liquidityProviderTieBreakHash(batch.ReceiptHash, p.Points[i].Address), liquidityProviderTieBreakHash(batch.ReceiptHash, p.Points[j].Address)) < 0 + }) + // preallocate one full withdrawal for each excess provider + withdrawals := make([]*lib.DexLiquidityWithdraw, 0, excess) + // select the lowest-ranked providers + for _, point := range p.Points { + // the permanent dead address cannot be evicted + if !bytes.Equal(point.Address, deadAddr.Bytes()) { + // create a full withdrawal with a deterministic migration order ID + withdrawals = append(withdrawals, &lib.DexLiquidityWithdraw{Address: point.Address, Percent: 100, + OrderId: crypto.ShortHash(lib.JoinLenPrefix([]byte(dexLPMigrationIDDomain), batch.ReceiptHash, point.Address))}) + } + // stop after selecting exactly the excess providers + if len(withdrawals) == excess { + break + } + } + // ensure the pool contained enough removable providers + if len(withdrawals) != excess { + return ErrInvalidLiquidityPool() + } + // execute and persist the migration through the standard withdrawal path + if err := s.HandleBatchWithdraw(&lib.DexBatch{Withdrawals: withdrawals}, chainId, x, y, local); err != nil { + return err + } + // reload the pool updated by HandleBatchWithdraw() + migrated, err := s.GetPool(chainId + LiquidityPoolAddend) + if err != nil { + return err + } + // refresh the working pool before processing the incoming deposits + p.Amount = migrated.Amount + p.Points = migrated.Points + p.TotalPoolPoints = migrated.TotalPoolPoints + return nil } // RotateDexBatches() sets 'next batch' as 'locked batch' and deletes reference for 'next batch' diff --git a/fsm/dex_test.go b/fsm/dex_test.go index 4f23d28fde..c8a5462a6f 100644 --- a/fsm/dex_test.go +++ b/fsm/dex_test.go @@ -2606,7 +2606,6 @@ func TestHandleDexBatchOrdersEnforcesSettlementCap(t *testing.T) { const extra = 5 total := lib.MaxOrdersSettledPerBlock + extra - // pool large enough that every *attempted* order succeeds (RequestedAmount 0) pool := uint64(1_000_000_000_000) require.NoError(t, sm.SetPool(&Pool{Id: chainId + LiquidityPoolAddend, Amount: pool})) @@ -2614,31 +2613,19 @@ func TestHandleDexBatchOrdersEnforcesSettlementCap(t *testing.T) { for i := range orders { addr := make([]byte, crypto.AddressSize) addr[0], addr[1] = byte(i+1), byte((i+1)>>8) - orders[i] = &lib.DexLimitOrder{ - AmountForSale: 1_000, - RequestedAmount: 0, - Address: addr, - OrderId: addr, - } + orders[i] = &lib.DexLimitOrder{AmountForSale: 1_000, Address: addr, OrderId: addr} } - batch := &lib.DexBatch{Committee: chainId, Orders: orders} - x, y := pool, pool - receipts, err := sm.HandleDexBatchOrders(batch, &x, &y, chainId) + receipts, err := sm.HandleDexBatchOrders(&lib.DexBatch{Committee: chainId, Orders: orders}, &x, &y, chainId) require.NoError(t, err) require.Len(t, receipts, total) - - var settled, refunded int - for _, r := range receipts { - if r == 0 { - refunded++ - } else { + var settled int + for _, receipt := range receipts { + if receipt != 0 { settled++ } } - // exactly the cap is settled; the remainder is left with a zero receipt for refund on the origin chain require.Equal(t, lib.MaxOrdersSettledPerBlock, settled) - require.Equal(t, extra, refunded) } // Proves withdraw->redeposit cannot increase LP points (no gain loop), allowing only rounding loss. @@ -2787,152 +2774,266 @@ func TestHandleBatchDepositZeroShareDoesNotCreateGhostProvider(t *testing.T) { require.Equal(t, lib.CodePointHolderNotFound, pointErr.Code()) } -// A remote (local=false) deposit from a brand-new LP at the holder cap must NOT error the whole batch -// (doing so would brick the nested chain). It is skipped without issuing points and without token movement. -func TestHandleBatchDepositRemoteAtCapSkipsWithoutError(t *testing.T) { +func TestHandleBatchDepositRemoteReplacesLowestProvider(t *testing.T) { sm := newTestStateMachine(t) chainId := uint64(2) newProvider := newTestAddress(t, 2) + receiptHash, depositOrderId := []byte("receipt"), []byte{0x22} - points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders) + points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders+1) + totalPoints := uint64(100) for i := range points { - points[i] = &lib.PoolPoints{Address: deadAddr.Bytes(), Points: 1} + address, amount := make([]byte, crypto.AddressSize), uint64(1) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + if i == 0 { + address, amount = deadAddr.Bytes(), 100 + } else if i < lib.MaxLiquidityProviders { + totalPoints++ + } else { + amount = 0 // legacy zero-point holder + } + points[i] = &lib.PoolPoints{Address: address, Points: amount} } require.NoError(t, sm.SetPool(&Pool{ Id: chainId + LiquidityPoolAddend, Amount: 100, Points: points, - TotalPoolPoints: lib.MaxLiquidityProviders, + TotalPoolPoints: totalPoints, })) x, y := uint64(100), uint64(100) err := sm.HandleBatchDeposit(&lib.DexBatch{ - Committee: chainId, + Committee: chainId, + ReceiptHash: receiptHash, Deposits: []*lib.DexLiquidityDeposit{{ Address: newProvider.Bytes(), Amount: 100, - OrderId: []byte{0x22}, + OrderId: depositOrderId, }}, }, chainId, &x, &y, false) - // no error: the poison deposit is skipped, not fatal to the batch require.NoError(t, err) - - // the new provider was not added as an LP and the holder count is unchanged pool, err := sm.GetPool(chainId + LiquidityPoolAddend) require.NoError(t, err) require.Len(t, pool.Points, lib.MaxLiquidityProviders) - _, pointErr := pool.GetPointsFor(newProvider.Bytes()) - require.Error(t, pointErr) - require.Equal(t, lib.CodePointHolderNotFound, pointErr.Code()) - // remote side does not move tokens, so the reserve mirror is unchanged - require.Equal(t, uint64(100), x) + _, err = pool.GetPointsFor(points[1].Address) + require.Error(t, err) + newPoints, err := pool.GetPointsFor(newProvider.Bytes()) + require.NoError(t, err) + require.Greater(t, newPoints, uint64(1)) + require.Len(t, sm.events.Events, 2) + withdrawal := sm.events.Events[0].GetDexLiquidityWithdrawal() + require.NotNil(t, withdrawal) + require.Equal(t, uint64(100), withdrawal.Percent) + require.NotEqual(t, depositOrderId, withdrawal.OrderId) + require.Equal(t, crypto.ShortHash(lib.JoinLenPrefix([]byte("dex-lp-eviction-v1"), receiptHash, points[1].Address, newProvider.Bytes())), withdrawal.OrderId) } -// A local (local=true) deposit from a brand-new LP at the holder cap must be refunded to the depositor -// from the holding pool instead of erroring the batch, and the existing holders/reserves stay intact. -func TestHandleBatchDepositLocalAtCapRefunds(t *testing.T) { +func TestHandleBatchDepositSplitNewProviderUsesOneSlot(t *testing.T) { sm := newTestStateMachine(t) - chainId := uint64(2) - newProvider := newTestAddress(t, 2) - depositAmt := uint64(1_000_000) - - points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders) + chainID := uint64(2) + newProvider := newTestAddress(t, 3) + points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders-1) + totalPoints := uint64(100) for i := range points { - points[i] = &lib.PoolPoints{Address: deadAddr.Bytes(), Points: 1} + address, amount := make([]byte, crypto.AddressSize), uint64(2) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + if i == 0 { + address, amount = deadAddr.Bytes(), 100 + } else { + totalPoints += amount + } + points[i] = &lib.PoolPoints{Address: address, Points: amount} } - require.NoError(t, sm.SetPool(&Pool{ - Id: chainId + LiquidityPoolAddend, - Amount: 100, - Points: points, - TotalPoolPoints: lib.MaxLiquidityProviders, - })) - // the deposit was previously escrowed into the holding pool at enqueue time - require.NoError(t, sm.SetPool(&Pool{Id: chainId + HoldingPoolAddend, Amount: depositAmt})) - require.NoError(t, sm.SetAccount(&Account{Address: newProvider.Bytes(), Amount: 0})) + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: totalPoints})) - x, y := uint64(100), uint64(100) - err := sm.HandleBatchDeposit(&lib.DexBatch{ - Committee: chainId, - Deposits: []*lib.DexLiquidityDeposit{{ - Address: newProvider.Bytes(), - Amount: depositAmt, - OrderId: []byte{0x23}, - }}, - }, chainId, &x, &y, true) - require.NoError(t, err) + x, y := uint64(1_000_000), uint64(1_000_000) + require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{Deposits: []*lib.DexLiquidityDeposit{ + {Address: newProvider.Bytes(), Amount: 1_000, OrderId: []byte{1}}, + {Address: newProvider.Bytes(), Amount: 1_000, OrderId: []byte{2}}, + }}, chainID, &x, &y, false)) - // funds refunded to the depositor and drained from the holding pool - acc, err := sm.GetAccount(newProvider) + pool, err := sm.GetPool(chainID + LiquidityPoolAddend) require.NoError(t, err) - require.Equal(t, depositAmt, acc.Amount) - holding, err := sm.GetPool(chainId + HoldingPoolAddend) + require.Len(t, pool.Points, lib.MaxLiquidityProviders) + _, err = pool.GetPointsFor(points[1].Address) require.NoError(t, err) - require.Zero(t, holding.Amount) - - // no new LP, liquidity pool balance and reserve mirror untouched - pool, err := sm.GetPool(chainId + LiquidityPoolAddend) + _, err = pool.GetPointsFor(newProvider.Bytes()) require.NoError(t, err) - require.Len(t, pool.Points, lib.MaxLiquidityProviders) - require.Equal(t, uint64(100), pool.Amount) - require.Equal(t, uint64(100), x) } -// A batch that mixes an accepted (existing LP) deposit with a rejected (new LP at cap) deposit must -// process the accepted one and refund the rejected one, without leaking the rejected share to dust. -func TestHandleBatchDepositMixedAtCapAcceptsExistingRefundsNew(t *testing.T) { +func TestHandleBatchDepositRanksNewcomersByProviderTotal(t *testing.T) { sm := newTestStateMachine(t) - chainId := uint64(2) - existing := newTestAddress(t, 1) - newProvider := newTestAddress(t, 2) - depositAmt := uint64(1_000_000) - - points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders) - points[0] = &lib.PoolPoints{Address: existing.Bytes(), Points: 1} - for i := 1; i < len(points); i++ { - points[i] = &lib.PoolPoints{Address: deadAddr.Bytes(), Points: 1} + chainID := uint64(2) + points, total := make([]*lib.PoolPoints, lib.MaxLiquidityProviders), uint64(0) + for i := range points { + address, amount := make([]byte, crypto.AddressSize), uint64(1_000) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + if i == 0 { + address = deadAddr.Bytes() + } else if i == 1 { + amount = 200 + } + points[i], total = &lib.PoolPoints{Address: address, Points: amount}, total+amount } - require.NoError(t, sm.SetPool(&Pool{ - Id: chainId + LiquidityPoolAddend, - Amount: 1_000_000, - Points: points, - TotalPoolPoints: lib.MaxLiquidityProviders, - })) - require.NoError(t, sm.SetPool(&Pool{Id: chainId + HoldingPoolAddend, Amount: 2 * depositAmt})) - require.NoError(t, sm.SetAccount(&Account{Address: newProvider.Bytes(), Amount: 0})) + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: total})) - existingBefore, err := (&Pool{Points: points}).GetPointsFor(existing.Bytes()) + split, single := newTestAddress(t, 4), newTestAddress(t, 5) + x, y := uint64(1_000_000), uint64(1_000_000) + require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{Deposits: []*lib.DexLiquidityDeposit{ + {Address: split.Bytes(), Amount: 60}, + {Address: single.Bytes(), Amount: 100}, + {Address: split.Bytes(), Amount: 60}, + }}, chainID, &x, &y, false)) + + pool, err := sm.GetPool(chainID + LiquidityPoolAddend) + require.NoError(t, err) + _, err = pool.GetPointsFor(split.Bytes()) require.NoError(t, err) + _, err = pool.GetPointsFor(single.Bytes()) + require.Error(t, err) + _, err = pool.GetPointsFor(points[1].Address) + require.Error(t, err) +} + +func TestHandleBatchDepositMigratesPoolBeforeDeposits(t *testing.T) { + sm := newTestStateMachine(t) + chainID := uint64(2) + points := make([]*lib.PoolPoints, 50_000) + var total uint64 + for i := range points { + address := make([]byte, crypto.AddressSize) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + amount := uint64(i + 1) + if i == 0 { + address, amount = deadAddr.Bytes(), 100 + } + points[i], total = &lib.PoolPoints{Address: address, Points: amount}, total+amount + } + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: total})) x, y := uint64(1_000_000), uint64(1_000_000) - err = sm.HandleBatchDeposit(&lib.DexBatch{ - Committee: chainId, - Deposits: []*lib.DexLiquidityDeposit{ - {Address: existing.Bytes(), Amount: depositAmt, OrderId: []byte{0x24}}, - {Address: newProvider.Bytes(), Amount: depositAmt, OrderId: []byte{0x25}}, - }, - }, chainId, &x, &y, true) + require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{Deposits: []*lib.DexLiquidityDeposit{{ + Address: points[len(points)-1].Address, Amount: 100, + }}}, chainID, &x, &y, false)) + + pool, err := sm.GetPool(chainID + LiquidityPoolAddend) require.NoError(t, err) + require.Len(t, pool.Points, lib.MaxLiquidityProviders) + for _, evicted := range points[1:3] { + _, err = pool.GetPointsFor(evicted.Address) + require.Error(t, err) + } +} - pool, err := sm.GetPool(chainId + LiquidityPoolAddend) +func TestHandleBatchDepositUsesReceiptHashForEqualStakeTie(t *testing.T) { + sm := newTestStateMachine(t) + chainID, seed := uint64(2), []byte("receipt") + points, total := make([]*lib.PoolPoints, lib.MaxLiquidityProviders), uint64(0) + for i := range points { + address, amount := make([]byte, crypto.AddressSize), uint64(100) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + if i == 0 { + address = deadAddr.Bytes() + } else if i == 1 { + amount = 1 + } + points[i], total = &lib.PoolPoints{Address: address, Points: amount}, total+amount + } + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: total})) + + a, b := newTestAddress(t, 6), newTestAddress(t, 7) + winner, loser := a, b + if bytes.Compare(crypto.Hash(append(bytes.Clone(seed), a.Bytes()...)), crypto.Hash(append(bytes.Clone(seed), b.Bytes()...))) > 0 { + winner, loser = b, a + } + x, y := uint64(1_000_000), uint64(1_000_000) + require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{ReceiptHash: seed, Deposits: []*lib.DexLiquidityDeposit{ + {Address: loser.Bytes(), Amount: 100}, + {Address: winner.Bytes(), Amount: 100}, + }}, chainID, &x, &y, false)) + + pool, err := sm.GetPool(chainID + LiquidityPoolAddend) require.NoError(t, err) - // existing LP gained points; new LP was never added - existingAfter, err := pool.GetPointsFor(existing.Bytes()) + _, err = pool.GetPointsFor(winner.Bytes()) require.NoError(t, err) - require.Greater(t, existingAfter, existingBefore) - require.Len(t, pool.Points, lib.MaxLiquidityProviders) - _, pointErr := pool.GetPointsFor(newProvider.Bytes()) - require.Error(t, pointErr) - require.Equal(t, lib.CodePointHolderNotFound, pointErr.Code()) + _, err = pool.GetPointsFor(loser.Bytes()) + require.Error(t, err) +} + +func TestHandleBatchDepositRejectedEntryDoesNotChangeIncumbents(t *testing.T) { + chainID := uint64(2) + a, b, rejected := newTestAddress(t, 4), newTestAddress(t, 5), newTestAddress(t, 6) + run := func(includeRejected bool) (*Pool, uint64) { + sm := newTestStateMachine(t) + points, total := make([]*lib.PoolPoints, lib.MaxLiquidityProviders), uint64(100) + for i := range points { + address := make([]byte, crypto.AddressSize) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + if i == 0 { + address = deadAddr.Bytes() + } else { + total += 10 + } + if i == 1 { + address = a.Bytes() + } else if i == 2 { + address = b.Bytes() + } + points[i] = &lib.PoolPoints{Address: address, Points: 10} + } + points[0].Points = 100 + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: total})) + deposits := []*lib.DexLiquidityDeposit{{Address: a.Bytes(), Amount: 1_000}, {Address: b.Bytes(), Amount: 1_000}} + if includeRejected { + deposits = append(deposits, &lib.DexLiquidityDeposit{Address: rejected.Bytes(), Amount: 1}) + } + x, y := uint64(1_000_000), uint64(1_000_000) + require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{Deposits: deposits}, chainID, &x, &y, false)) + pool, err := sm.GetPool(chainID + LiquidityPoolAddend) + require.NoError(t, err) + return pool, x + } + pool, x := run(true) + control, controlX := run(false) + for _, address := range [][]byte{a.Bytes(), b.Bytes()} { + points, err := pool.GetPointsFor(address) + require.NoError(t, err) + controlPoints, err := control.GetPointsFor(address) + require.NoError(t, err) + require.Equal(t, controlPoints, points) + } + _, err := pool.GetPointsFor(rejected.Bytes()) + require.Error(t, err) + require.Equal(t, controlX, x) +} + +func TestHandleBatchDepositRefundsRejectedLocalNewcomer(t *testing.T) { + sm := newTestStateMachine(t) + chainID, total := uint64(2), uint64(0) + points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders) + for i := range points { + address := make([]byte, crypto.AddressSize) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + if i == 0 { + address = deadAddr.Bytes() + } + points[i], total = &lib.PoolPoints{Address: address, Points: 100}, total+100 + } + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: total})) + require.NoError(t, sm.SetPool(&Pool{Id: chainID + HoldingPoolAddend, Amount: 1})) + newcomer := newTestAddress(t, 3) + x, y := uint64(1_000_000), uint64(1_000_000) + require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{Deposits: []*lib.DexLiquidityDeposit{{ + Address: newcomer.Bytes(), Amount: 1, + }}}, chainID, &x, &y, true)) - // new LP deposit refunded; only the accepted deposit entered the liquidity pool - acc, err := sm.GetAccount(newProvider) + balance, err := sm.GetAccountBalance(newcomer) require.NoError(t, err) - require.Equal(t, depositAmt, acc.Amount) - require.Equal(t, uint64(1_000_000)+depositAmt, pool.Amount) - // holding started with 2 deposits: one moved into the pool, the other refunded, leaving zero - holding, err := sm.GetPool(chainId + HoldingPoolAddend) + require.Equal(t, uint64(1), balance) + holding, err := sm.GetPoolBalance(chainID + HoldingPoolAddend) require.NoError(t, err) - require.Zero(t, holding.Amount) + require.Zero(t, holding) + require.Equal(t, uint64(1_000_000), x) } func TestHandleBatchWithdrawNilWithdrawalEntryDoesNotPanic(t *testing.T) { diff --git a/fsm/event.go b/fsm/event.go index 30072f9f81..efc8467e2a 100644 --- a/fsm/event.go +++ b/fsm/event.go @@ -45,7 +45,7 @@ func (s *StateMachine) EventOrderBookSwap(order *lib.SellOrder) lib.ErrorI { // EventOrderBookLock() adds an order book lock event to the indexer when an order is reserved func (s *StateMachine) EventOrderBookLock(order *lib.SellOrder) lib.ErrorI { return s.addEvent(lib.EventTypeOrderBookLock, &lib.EventOrderBookLock{ - OrderId: order.Id, + OrderId: order.Id, BuyerReceiveAddress: order.BuyerReceiveAddress, BuyerSendAddress: order.BuyerSendAddress, BuyerChainDeadline: order.BuyerChainDeadline, @@ -81,12 +81,13 @@ func (s *StateMachine) EventDexLiquidityDeposit(address, orderId []byte, amount, } // EventDexLiquidityWithdraw() adds a liquidity withdraw event to the indexer -func (s *StateMachine) EventDexLiquidityWithdraw(address, orderId []byte, localAmount, remoteAmount, pointsBurned, chainId uint64) lib.ErrorI { +func (s *StateMachine) EventDexLiquidityWithdraw(address, orderId []byte, localAmount, remoteAmount, pointsBurned, percent, chainId uint64) lib.ErrorI { return s.addEvent(lib.EventTypeDexLiquidityWithdraw, &lib.EventDexLiquidityWithdrawal{ LocalAmount: localAmount, RemoteAmount: remoteAmount, OrderId: orderId, PointsBurned: pointsBurned, + Percent: percent, }, address, chainId) } diff --git a/fsm/event_test.go b/fsm/event_test.go index 5b3c1a0364..044452997b 100644 --- a/fsm/event_test.go +++ b/fsm/event_test.go @@ -394,7 +394,7 @@ func TestEventDexLiquidityWithdraw(t *testing.T) { // create a state machine instance with default parameters sm := newTestStateMachine(t) // execute the function call - err := sm.EventDexLiquidityWithdraw(test.address, test.orderId, test.localAmount, test.remoteAmount, test.points, test.chainId) + err := sm.EventDexLiquidityWithdraw(test.address, test.orderId, test.localAmount, test.remoteAmount, test.points, 100, test.chainId) // validate the expected error require.Equal(t, test.error != "", err != nil, err) if err != nil { @@ -407,6 +407,7 @@ func TestEventDexLiquidityWithdraw(t *testing.T) { require.Equal(t, test.expected, events[0].EventType) require.Equal(t, test.address, events[0].Address) require.Equal(t, test.chainId, events[0].ChainId) + require.Equal(t, uint64(100), events[0].GetDexLiquidityWithdrawal().Percent) }) } } diff --git a/fsm/message.go b/fsm/message.go index f27fbd0cc8..342a39dad8 100644 --- a/fsm/message.go +++ b/fsm/message.go @@ -561,7 +561,7 @@ func (s *StateMachine) HandleMessageDexLiquidityDeposit(msg *MessageDexLiquidity return err } // ensure there's some liquidity in the pool - if lPool.Amount == 0 || s.Config.ChainId == msg.ChainId || len(lPool.Points) >= lib.MaxLiquidityProviders { + if lPool.Amount == 0 || s.Config.ChainId == msg.ChainId { return ErrInvalidLiquidityPool() } // hard limit ops to 5K per batch to prevent unchecked state growth diff --git a/fsm/message_test.go b/fsm/message_test.go index 67405be12d..b67a845b81 100644 --- a/fsm/message_test.go +++ b/fsm/message_test.go @@ -2225,6 +2225,28 @@ func TestHandleMessageCertificateResults(t *testing.T) { } } +func TestHandleMessageDexLiquidityDepositQueuesAtProviderCap(t *testing.T) { + sm := newTestStateMachine(t) + chainID := sm.Config.ChainId + 1 + points := make([]*lib.PoolPoints, lib.MaxLiquidityProviders) + for i := range points { + address := make([]byte, crypto.AddressSize) + address[len(address)-2], address[len(address)-1] = byte(i>>8), byte(i) + points[i] = &lib.PoolPoints{Address: address, Points: 1} + } + require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 100, Points: points, TotalPoolPoints: lib.MaxLiquidityProviders})) + user := newTestAddress(t, 1) + require.NoError(t, sm.AccountAdd(user, 10)) + + require.NoError(t, sm.HandleMessageDexLiquidityDeposit(&MessageDexLiquidityDeposit{ + ChainId: chainID, Address: user.Bytes(), Amount: 10, + })) + batch, err := sm.GetDexBatch(chainID, false) + require.NoError(t, err) + require.Len(t, batch.Deposits, 1) + require.Equal(t, uint64(10), batch.Deposits[0].Amount) +} + func TestMessageSubsidy(t *testing.T) { tests := []struct { name string diff --git a/lib/.proto/event.proto b/lib/.proto/event.proto index a812173ed1..277368b174 100644 --- a/lib/.proto/event.proto +++ b/lib/.proto/event.proto @@ -69,6 +69,8 @@ message EventDexLiquidityWithdrawal { bytes order_id = 3; // @gotags: json:"orderId" // pointsBurned: the amount of points burned uint64 points_burned = 4; // @gotags: json:"pointsBurned" + // percent: the percentage of the provider's points withdrawn + uint64 percent = 5; } message EventDexSwap { diff --git a/lib/certificate.go b/lib/certificate.go index 3344532cc4..f0a5a085b9 100644 --- a/lib/certificate.go +++ b/lib/certificate.go @@ -23,7 +23,7 @@ const ( MaxWithdrawsPerDexBatch = 5_000 MaxOrdersPerDexBatch = 10_000 MaxReceipts = MaxOrdersPerDexBatch - MaxLiquidityProviders = 50_000 + MaxLiquidityProviders = 5_000 // MaxOrdersSettledPerBlock caps DEX orders settled per block so begin_block can't exceed the consensus // round budget; orders beyond the cap are failed (receipt 0) and refunded to the seller on the origin chain MaxOrdersSettledPerBlock = 250 @@ -913,9 +913,9 @@ func (x *DexBatch) CheckBasic() (err ErrorI) { if len(x.Receipts) > MaxReceipts { return ErrTooManyDexReceipts() } - // ensure there's not too many receipts + // ensure there's not too many liquidity providers if len(x.PoolPoints) > MaxLiquidityProviders { - return ErrTooManyDexReceipts() + return ErrTooManyLiquidityProviders() } // ensure each pool point is valid for _, point := range x.PoolPoints { diff --git a/lib/dex_test.go b/lib/dex_test.go index 25e0528252..1c32bb700a 100644 --- a/lib/dex_test.go +++ b/lib/dex_test.go @@ -3,6 +3,8 @@ package lib import ( "encoding/json" "testing" + + "github.com/canopy-network/canopy/lib/crypto" ) func TestDexBatch_Hash(t *testing.T) { @@ -268,6 +270,21 @@ func TestDexBatch_CheckBasic_InvalidPoolPointAddress(t *testing.T) { } } +func TestDexBatch_CheckBasic_RejectsOverLimitFallbackPoints(t *testing.T) { + points := make([]*PoolPoints, MaxLiquidityProviders+1) + for i := range points { + points[i] = &PoolPoints{Address: make([]byte, crypto.AddressSize), Points: 1} + } + batch := &DexBatch{PoolPoints: points} + if err := batch.CheckBasic(); err == nil || err.Code() != CodeTooManyLiquidityProviders { + t.Fatalf("expected too many liquidity providers error, got %v", err) + } + batch.LivenessFallback = true + if err := batch.CheckBasic(); err == nil || err.Code() != CodeTooManyLiquidityProviders { + t.Fatalf("expected fallback batch to reject excess provider count, got %v", err) + } +} + func TestPoolPoints_MarshalJSON(t *testing.T) { points := PoolPoints{ Address: []byte("test"), diff --git a/lib/error.go b/lib/error.go index 9074794f90..14bdc3a2b5 100644 --- a/lib/error.go +++ b/lib/error.go @@ -282,6 +282,7 @@ const ( CodeNoPluginQueryProvider ErrorCode = 111 CodeNilPluginQueryRead ErrorCode = 112 CodeNoCommittedState ErrorCode = 113 + CodeTooManyLiquidityProviders ErrorCode = 114 // P2P Module P2PModule ErrorModule = "p2p" @@ -869,6 +870,10 @@ func ErrTooManyDexReceipts() ErrorI { return NewError(CodeTooManyDexReceiptsError, StateMachineModule, "too many dex receipts") } +func ErrTooManyLiquidityProviders() ErrorI { + return NewError(CodeTooManyLiquidityProviders, StateMachineModule, "too many liquidity providers") +} + func ErrNonNilPoolPoints() ErrorI { return NewError(CodeNonNilPoolPointsError, StateMachineModule, "non nil pool points") } diff --git a/lib/event.go b/lib/event.go index 987159f45b..7098ec4ca6 100644 --- a/lib/event.go +++ b/lib/event.go @@ -457,6 +457,7 @@ type eventDexLiquidityWithdrawal struct { RemoteAmount uint64 `json:"remoteAmount"` OrderId HexBytes `json:"orderId"` PointsBurned uint64 `json:"pointsBurned"` + Percent uint64 `json:"percent"` } // MarshalJSON() implements custom JSON marshalling for EventDexLiquidityWithdrawal, converting []byte fields to HexBytes @@ -466,6 +467,7 @@ func (e EventDexLiquidityWithdrawal) MarshalJSON() ([]byte, error) { RemoteAmount: e.RemoteAmount, OrderId: e.OrderId, PointsBurned: e.PointsBurned, + Percent: e.Percent, } return json.Marshal(temp) } @@ -481,6 +483,7 @@ func (e *EventDexLiquidityWithdrawal) UnmarshalJSON(b []byte) error { RemoteAmount: temp.RemoteAmount, OrderId: temp.OrderId, PointsBurned: temp.PointsBurned, + Percent: temp.Percent, } return nil } diff --git a/lib/event.pb.go b/lib/event.pb.go index e8b2794970..d10731dfc4 100644 --- a/lib/event.pb.go +++ b/lib/event.pb.go @@ -502,7 +502,9 @@ type EventDexLiquidityWithdrawal struct { // order_id: the unique identifier of the order OrderId []byte `protobuf:"bytes,3,opt,name=order_id,json=orderId,proto3" json:"orderId"` // @gotags: json:"orderId" // pointsBurned: the amount of points burned - PointsBurned uint64 `protobuf:"varint,4,opt,name=points_burned,json=pointsBurned,proto3" json:"pointsBurned"` // @gotags: json:"pointsBurned" + PointsBurned uint64 `protobuf:"varint,4,opt,name=points_burned,json=pointsBurned,proto3" json:"pointsBurned"` // @gotags: json:"pointsBurned" + // percent: the percentage of the provider's points withdrawn + Percent uint64 `protobuf:"varint,5,opt,name=percent,proto3" json:"percent,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -565,6 +567,13 @@ func (x *EventDexLiquidityWithdrawal) GetPointsBurned() uint64 { return 0 } +func (x *EventDexLiquidityWithdrawal) GetPercent() uint64 { + if x != nil { + return x.Percent + } + return 0 +} + type EventDexSwap struct { state protoimpl.MessageState `protogen:"open.v1"` // sold_amount: amount sold @@ -1055,12 +1064,13 @@ const file_event_proto_rawDesc = "" + "\x06amount\x18\x01 \x01(\x04R\x06amount\x12!\n" + "\flocal_origin\x18\x02 \x01(\bR\vlocalOrigin\x12\x19\n" + "\border_id\x18\x03 \x01(\fR\aorderId\x12\x16\n" + - "\x06points\x18\x04 \x01(\x04R\x06points\"\xa5\x01\n" + + "\x06points\x18\x04 \x01(\x04R\x06points\"\xbf\x01\n" + "\x1bEventDexLiquidityWithdrawal\x12!\n" + "\flocal_amount\x18\x01 \x01(\x04R\vlocalAmount\x12#\n" + "\rremote_amount\x18\x02 \x01(\x04R\fremoteAmount\x12\x19\n" + "\border_id\x18\x03 \x01(\fR\aorderId\x12#\n" + - "\rpoints_burned\x18\x04 \x01(\x04R\fpointsBurned\"\xac\x01\n" + + "\rpoints_burned\x18\x04 \x01(\x04R\fpointsBurned\x12\x18\n" + + "\apercent\x18\x05 \x01(\x04R\apercent\"\xac\x01\n" + "\fEventDexSwap\x12\x1f\n" + "\vsold_amount\x18\x01 \x01(\x04R\n" + "soldAmount\x12#\n" + diff --git a/lib/mempool.go b/lib/mempool.go index f9a930d526..4e0370f7cb 100644 --- a/lib/mempool.go +++ b/lib/mempool.go @@ -61,13 +61,7 @@ func (f *FeeMempool) AddTransactions(txs ...[]byte) (recheck bool, err ErrorI) { batchTxs := make(map[string]struct{}, len(txs)) txsBytes := 0 for _, tx := range txs { - // ensure the size of the Transaction doesn't exceed the individual limit txBytes := len(tx) - // if the transaction bytes is larger than the max size - if uint32(txBytes) > f.config.IndividualMaxTxSize { - // exit with error - return false, ErrMaxTxSize() - } // check if the mempool already contains the transaction hash := crypto.HashString(tx) if _, found := f.pool.m[hash]; found { @@ -87,6 +81,10 @@ func (f *FeeMempool) AddTransactions(txs ...[]byte) (recheck bool, err ErrorI) { if err = transaction.CheckBasic(); err != nil { return false, err } + // certificate results may contain a full DEX batch and remain bounded by MaxTotalBytes + if uint32(txBytes) > f.config.IndividualMaxTxSize && transaction.MessageType != "certificateResults" { + return false, ErrMaxTxSize() + } // extract the fee from the transaction result fee := transaction.Fee // if the transaction is a special type: 'certificate result' diff --git a/lib/mempool_test.go b/lib/mempool_test.go index 1da7beb9d5..323194494c 100644 --- a/lib/mempool_test.go +++ b/lib/mempool_test.go @@ -76,6 +76,21 @@ func TestAddTransactionFeeOrdering(t *testing.T) { } } +func TestCertificateResultsMayExceedIndividualMaxTxSize(t *testing.T) { + sig := &Signature{PublicKey: newTestPublicKeyBytes(t), Signature: newTestPublicKeyBytes(t)} + msg, err := NewAny(sig) + require.NoError(t, err) + tx, err := Marshal(&Transaction{MessageType: "certificateResults", Msg: msg, Signature: sig, + CreatedHeight: 1, Time: uint64(time.Now().UnixMicro()), NetworkId: 1, ChainId: 1}) + require.NoError(t, err) + mempool := NewMempool(MempoolConfig{MaxTotalBytes: math.MaxUint64, MaxTransactionCount: 10, + IndividualMaxTxSize: 1, DropPercentage: 10}) + + _, err = mempool.AddTransactions(tx) + require.NoError(t, err) + require.Equal(t, 1, mempool.TxCount()) +} + func TestRejectedBatchDoesNotChangeMempoolBytes(t *testing.T) { sig := &Signature{PublicKey: newTestPublicKeyBytes(t), Signature: newTestPublicKeyBytes(t)} msg, err := NewAny(sig)