diff --git a/fsm/dex.go b/fsm/dex.go index db97c287d2..0ce97664eb 100644 --- a/fsm/dex.go +++ b/fsm/dex.go @@ -335,8 +335,12 @@ func (s *StateMachine) HandleDexBatchOrders(remoteBatch *lib.DexBatch, x, y *uin if *x == 0 || *y == 0 { return nil, ErrInvalidLiquidityPool() } - // for each order - for _, order := range sorted { + // for each order (pseudorandomly ordered above so the settlement cap selects fairly) + for i, order := range sorted { + // per-block settlement cap: remaining orders keep a zero receipt and get refunded on the origin chain + if i >= lib.MaxOrdersSettledPerBlock { + break + } // set up 'deltaX' dX := order.AmountForSale // 'deltaY' = (dX * y) / (x + dX) diff --git a/fsm/dex_test.go b/fsm/dex_test.go index 3631f8b0d5..4f23d28fde 100644 --- a/fsm/dex_test.go +++ b/fsm/dex_test.go @@ -2597,6 +2597,50 @@ func TestHandleDexBatchOrdersRejectsReserveOverflow(t *testing.T) { require.Equal(t, y0, y) } +// An oversized batch must not settle more than the per-block cap; overflow orders keep a zero receipt +// (refunded to the seller on the origin chain) so the whole batch still resolves in a single block. +func TestHandleDexBatchOrdersEnforcesSettlementCap(t *testing.T) { + sm := newTestStateMachine(t) + sm.Config.ChainId = 1 + chainId := uint64(2) + + 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})) + + orders := make([]*lib.DexLimitOrder, total) + 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, + } + } + batch := &lib.DexBatch{Committee: chainId, Orders: orders} + + x, y := pool, pool + receipts, err := sm.HandleDexBatchOrders(batch, &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 { + 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. func TestWithdrawThenRedeploy_NoPointGain(t *testing.T) { sm := newTestStateMachine(t) diff --git a/lib/certificate.go b/lib/certificate.go index 8e39c81e13..3344532cc4 100644 --- a/lib/certificate.go +++ b/lib/certificate.go @@ -24,6 +24,9 @@ const ( MaxOrdersPerDexBatch = 10_000 MaxReceipts = MaxOrdersPerDexBatch MaxLiquidityProviders = 50_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 ) // MaxBlockHeaderSize is a consensus breaking change because it affects how the state machine