Skip to content

Commit 4e823dd

Browse files
fix(staking): report minimum deposit when commitment would decrease
Pre-check manageStake against the non-decreasing commitment rule so small deposits fail with HTTP 400 and the required amount instead of a misleading gas error. Expose the minimum on GET /stake. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a93fb7a commit 4e823dd

8 files changed

Lines changed: 496 additions & 34 deletions

File tree

openapi/Swarm.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
openapi: 3.0.3
22

33
info:
4-
version: 8.1.0
4+
version: 8.2.0
55
title: Bee API
66
description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management"
77

@@ -2567,7 +2567,7 @@ paths:
25672567
"/stake":
25682568
get:
25692569
summary: Get the staked amount.
2570-
description: This endpoint fetches the total staked amount from the blockchain.
2570+
description: This endpoint fetches the total staked amount and the minimum additional deposit from the blockchain. The first deposit is at least 0.1 BZZ times 2^height. Subsequent deposits are at least 1 PLUR, or more if the price oracle has increased since the last deposit.
25712571
tags:
25722572
- Staking
25732573
responses:

openapi/SwarmCommon.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,8 @@ components:
681681
properties:
682682
stakedAmount:
683683
$ref: "#/components/schemas/BigInt"
684+
minimumDeposit:
685+
$ref: "#/components/schemas/BigInt"
684686

685687
GetWithdrawableResponse:
686688
type: object

pkg/api/staking.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ package api
66

77
import (
88
"errors"
9+
"fmt"
910
"math/big"
1011
"net/http"
1112

12-
"github.com/ethersphere/bee/v2/pkg/bigint"
13+
"github.com/gorilla/mux"
1314

15+
"github.com/ethersphere/bee/v2/pkg/bigint"
1416
"github.com/ethersphere/bee/v2/pkg/jsonhttp"
1517
"github.com/ethersphere/bee/v2/pkg/storageincentives/staking"
16-
"github.com/gorilla/mux"
1718
)
1819

1920
func (s *Service) stakingAccessHandler(h http.Handler) http.Handler {
@@ -31,7 +32,8 @@ func (s *Service) stakingAccessHandler(h http.Handler) http.Handler {
3132
}
3233

3334
type getStakeResponse struct {
34-
StakedAmount *bigint.BigInt `json:"stakedAmount"`
35+
StakedAmount *bigint.BigInt `json:"stakedAmount"`
36+
MinimumDeposit *bigint.BigInt `json:"minimumDeposit"`
3537
}
3638

3739
type getWithdrawableResponse struct {
@@ -55,9 +57,15 @@ func (s *Service) stakingDepositHandler(w http.ResponseWriter, r *http.Request)
5557
txHash, err := s.stakingContract.DepositStake(r.Context(), paths.Amount)
5658
if err != nil {
5759
if errors.Is(err, staking.ErrInsufficientStakeAmount) {
58-
logger.Debug("insufficient stake amount", "minimum_stake", staking.MinimumStakeAmount, "error", err)
60+
minDeposit := staking.MinimumStakeAmount
61+
var minErr *staking.MinDepositError
62+
if errors.As(err, &minErr) && minErr.Minimum != nil {
63+
minDeposit = minErr.Minimum
64+
}
65+
msg := fmt.Sprintf("insufficient stake amount, minimum is %s", minDeposit)
66+
logger.Debug("insufficient stake amount", "minimum_stake", minDeposit, "error", err)
5967
logger.Error(nil, "insufficient stake amount")
60-
jsonhttp.BadRequest(w, "insufficient stake amount")
68+
jsonhttp.BadRequest(w, msg)
6169
return
6270
}
6371
if errors.Is(err, staking.ErrNotImplemented) {
@@ -105,7 +113,18 @@ func (s *Service) getPotentialStake(w http.ResponseWriter, r *http.Request) {
105113
return
106114
}
107115

108-
jsonhttp.OK(w, getStakeResponse{StakedAmount: bigint.Wrap(stakedAmount)})
116+
minDeposit, err := s.stakingContract.GetMinDeposit(r.Context())
117+
if err != nil {
118+
logger.Debug("get minimum deposit failed", "overlayAddr", s.overlay, "error", err)
119+
logger.Error(nil, "get minimum deposit failed")
120+
jsonhttp.InternalServerError(w, "get minimum deposit failed")
121+
return
122+
}
123+
124+
jsonhttp.OK(w, getStakeResponse{
125+
StakedAmount: bigint.Wrap(stakedAmount),
126+
MinimumDeposit: bigint.Wrap(minDeposit),
127+
})
109128
}
110129

111130
func (s *Service) getWithdrawableStakeHandler(w http.ResponseWriter, r *http.Request) {

pkg/api/staking_test.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import (
1212
"testing"
1313

1414
"github.com/ethereum/go-ethereum/common"
15-
"github.com/ethersphere/bee/v2/pkg/bigint"
1615

1716
"github.com/ethersphere/bee/v2/pkg/api"
17+
"github.com/ethersphere/bee/v2/pkg/bigint"
1818
"github.com/ethersphere/bee/v2/pkg/jsonhttp"
1919
"github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
2020
"github.com/ethersphere/bee/v2/pkg/sctx"
@@ -54,7 +54,21 @@ func TestDepositStake(t *testing.T) {
5454
)
5555
ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract})
5656
jsonhttptest.Request(t, ts, http.MethodPost, depositStake(invalidMinStake), http.StatusBadRequest,
57-
jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount"}))
57+
jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount, minimum is 100000000000000000"}))
58+
})
59+
60+
t.Run("with insufficient amount reports minimum", func(t *testing.T) {
61+
t.Parallel()
62+
63+
minDeposit := big.NewInt(123)
64+
contract := stakingContractMock.New(
65+
stakingContractMock.WithDepositStake(func(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) {
66+
return common.Hash{}, &staking.MinDepositError{Minimum: minDeposit}
67+
}),
68+
)
69+
ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract})
70+
jsonhttptest.Request(t, ts, http.MethodPost, depositStake("1"), http.StatusBadRequest,
71+
jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount, minimum is 123"}))
5872
})
5973

6074
t.Run("out of funds", func(t *testing.T) {
@@ -134,7 +148,10 @@ func TestGetStakeCommitted(t *testing.T) {
134148
)
135149
ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract})
136150
jsonhttptest.Request(t, ts, http.MethodGet, "/stake", http.StatusOK,
137-
jsonhttptest.WithExpectedJSONResponse(&api.GetStakeResponse{StakedAmount: bigint.Wrap(big.NewInt(1))}))
151+
jsonhttptest.WithExpectedJSONResponse(&api.GetStakeResponse{
152+
StakedAmount: bigint.Wrap(big.NewInt(1)),
153+
MinimumDeposit: bigint.Wrap(big.NewInt(1)),
154+
}))
138155
})
139156

140157
t.Run("with error", func(t *testing.T) {
@@ -149,6 +166,22 @@ func TestGetStakeCommitted(t *testing.T) {
149166
jsonhttptest.Request(t, ts, http.MethodGet, "/stake", http.StatusInternalServerError,
150167
jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusInternalServerError, Message: "get staked amount failed"}))
151168
})
169+
170+
t.Run("minimum deposit error", func(t *testing.T) {
171+
t.Parallel()
172+
173+
contractWithError := stakingContractMock.New(
174+
stakingContractMock.WithGetStake(func(ctx context.Context) (*big.Int, error) {
175+
return big.NewInt(1), nil
176+
}),
177+
stakingContractMock.WithGetMinDeposit(func(ctx context.Context) (*big.Int, error) {
178+
return nil, fmt.Errorf("get minimum deposit failed")
179+
}),
180+
)
181+
ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contractWithError})
182+
jsonhttptest.Request(t, ts, http.MethodGet, "/stake", http.StatusInternalServerError,
183+
jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusInternalServerError, Message: "get minimum deposit failed"}))
184+
})
152185
}
153186

154187
func TestGetStakeWithdrawable(t *testing.T) {

pkg/storageincentives/staking/contract.go

Lines changed: 135 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ import (
1313
"github.com/ethereum/go-ethereum/accounts/abi"
1414
"github.com/ethereum/go-ethereum/common"
1515
"github.com/ethereum/go-ethereum/core/types"
16+
"github.com/ethersphere/go-sw3-abi/sw3abi"
17+
1618
"github.com/ethersphere/bee/v2/pkg/sctx"
1719
"github.com/ethersphere/bee/v2/pkg/transaction"
1820
"github.com/ethersphere/bee/v2/pkg/util/abiutil"
19-
"github.com/ethersphere/go-sw3-abi/sw3abi"
2021
)
2122

2223
var (
2324
MinimumStakeAmount = big.NewInt(100000000000000000)
2425

25-
erc20ABI = abiutil.MustParseABI(sw3abi.ERC20ABIv0_6_9)
26+
erc20ABI = abiutil.MustParseABI(sw3abi.ERC20ABIv0_6_9)
27+
priceOracleABI = abiutil.MustParseABI(`[{"inputs":[],"name":"currentPrice","outputs":[{"internalType":"uint32","name":"","type":"uint32"}],"stateMutability":"view","type":"function"}]`)
2628

2729
ErrInsufficientStakeAmount = errors.New("insufficient stake amount")
2830
ErrInsufficientFunds = errors.New("insufficient token balance")
@@ -37,10 +39,25 @@ var (
3739
migrateStakeDescription = "Migrate stake"
3840
)
3941

42+
// MinDepositError is returned when a deposit is below the amount required by the
43+
// staking contract, including the non-decreasing commitment rule.
44+
type MinDepositError struct {
45+
Minimum *big.Int
46+
}
47+
48+
func (e *MinDepositError) Error() string {
49+
return fmt.Sprintf("insufficient stake amount: minimum %s", e.Minimum)
50+
}
51+
52+
func (e *MinDepositError) Unwrap() error {
53+
return ErrInsufficientStakeAmount
54+
}
55+
4056
type Contract interface {
4157
DepositStake(ctx context.Context, stakedAmount *big.Int) (common.Hash, error)
4258
ChangeStakeOverlay(ctx context.Context, nonce common.Hash) (common.Hash, error)
4359
GetPotentialStake(ctx context.Context) (*big.Int, error)
60+
GetMinDeposit(ctx context.Context) (*big.Int, error)
4461
GetWithdrawableStake(ctx context.Context) (*big.Int, error)
4562
WithdrawStake(ctx context.Context) (common.Hash, error)
4663
MigrateStake(ctx context.Context) (common.Hash, error)
@@ -86,19 +103,13 @@ func New(
86103
}
87104

88105
func (c *contract) DepositStake(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) {
89-
prevStakedAmount, err := c.GetPotentialStake(ctx)
106+
minDeposit, err := c.GetMinDeposit(ctx)
90107
if err != nil {
91108
return common.Hash{}, err
92109
}
93110

94-
if len(prevStakedAmount.Bits()) == 0 {
95-
if stakedAmount.Cmp(MinimumStakeAmount) == -1 {
96-
return common.Hash{}, ErrInsufficientStakeAmount
97-
}
98-
}
99-
100-
if big.NewInt(0).Add(prevStakedAmount, stakedAmount).Cmp(big.NewInt(0).Mul(big.NewInt(1<<c.height), MinimumStakeAmount)) < 0 {
101-
return common.Hash{}, fmt.Errorf("stake amount does not sufficiently cover the additional reserve capacity: %w", ErrInsufficientStakeAmount)
111+
if stakedAmount.Cmp(minDeposit) < 0 {
112+
return common.Hash{}, &MinDepositError{Minimum: minDeposit}
102113
}
103114

104115
balance, err := c.getBalance(ctx)
@@ -154,11 +165,59 @@ func (c *contract) UpdateHeight(ctx context.Context) (common.Hash, bool, error)
154165
}
155166

156167
func (c *contract) GetPotentialStake(ctx context.Context) (*big.Int, error) {
157-
stakedAmount, err := c.getPotentialStake(ctx)
168+
_, potential, err := c.getStake(ctx)
158169
if err != nil {
159170
return nil, fmt.Errorf("staking contract: failed to get stake: %w", err)
160171
}
161-
return stakedAmount, nil
172+
return potential, nil
173+
}
174+
175+
func (c *contract) GetMinDeposit(ctx context.Context) (*big.Int, error) {
176+
committed, potential, err := c.getStake(ctx)
177+
if err != nil {
178+
return nil, fmt.Errorf("staking contract: failed to get stake: %w", err)
179+
}
180+
181+
var price uint32
182+
if committed.Sign() > 0 {
183+
price, err = c.getCurrentPrice(ctx)
184+
if err != nil {
185+
return nil, fmt.Errorf("staking contract: failed to get oracle price: %w", err)
186+
}
187+
}
188+
189+
return calculateMinDeposit(potential, committed, price, c.height), nil
190+
}
191+
192+
// calculateMinDeposit returns the minimum additional deposit in PLUR that
193+
// manageStake will accept. The first deposit must cover 2^height * MIN_STAKE.
194+
// Later deposits must keep committed stake from decreasing after a price
195+
// increase; if that constraint is already satisfied the minimum is 1 PLUR.
196+
func calculateMinDeposit(potential, committed *big.Int, price uint32, height uint8) *big.Int {
197+
minAdd := new(big.Int)
198+
199+
minTotal := new(big.Int).Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height))
200+
if gap := new(big.Int).Sub(minTotal, potential); gap.Sign() > 0 {
201+
minAdd.Set(gap)
202+
}
203+
204+
if price != 0 && committed.Sign() > 0 {
205+
required := new(big.Int).SetUint64(uint64(price))
206+
required.Lsh(required, uint(height))
207+
required.Mul(required, committed)
208+
if gap := new(big.Int).Sub(required, potential); gap.Cmp(minAdd) > 0 {
209+
minAdd.Set(gap)
210+
}
211+
}
212+
213+
if minAdd.Sign() == 0 {
214+
if potential.Sign() > 0 {
215+
return big.NewInt(1)
216+
}
217+
return new(big.Int).Set(MinimumStakeAmount)
218+
}
219+
220+
return minAdd
162221
}
163222

164223
func (c *contract) GetWithdrawableStake(ctx context.Context) (*big.Int, error) {
@@ -327,17 +386,17 @@ func (c *contract) sendManageStakeTransaction(ctx context.Context, stakedAmount
327386
return receipt, nil
328387
}
329388

330-
func (c *contract) getPotentialStake(ctx context.Context) (*big.Int, error) {
389+
func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, err error) {
331390
callData, err := c.stakingContractABI.Pack("stakes", c.owner)
332391
if err != nil {
333-
return nil, err
392+
return nil, nil, err
334393
}
335394
result, err := c.transactionService.Call(ctx, &transaction.TxRequest{
336395
To: &c.stakingContractAddress,
337396
Data: callData,
338397
})
339398
if err != nil {
340-
return nil, fmt.Errorf("get potential stake: %w", err)
399+
return nil, nil, fmt.Errorf("get potential stake: %w", err)
341400
}
342401

343402
// overlay bytes32,
@@ -346,14 +405,71 @@ func (c *contract) getPotentialStake(ctx context.Context) (*big.Int, error) {
346405
// lastUpdatedBlockNumber uint256,
347406
results, err := c.stakingContractABI.Unpack("stakes", result)
348407
if err != nil {
349-
return nil, err
408+
return nil, nil, err
350409
}
351410

352411
if len(results) < 4 {
353-
return nil, ErrUnexpectedLength
412+
return nil, nil, ErrUnexpectedLength
413+
}
414+
415+
committed = abi.ConvertType(results[1], new(big.Int)).(*big.Int)
416+
potential = abi.ConvertType(results[2], new(big.Int)).(*big.Int)
417+
return committed, potential, nil
418+
}
419+
420+
func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) {
421+
callData, err := c.stakingContractABI.Pack("OracleContract")
422+
if err != nil {
423+
return 0, err
424+
}
425+
426+
result, err := c.transactionService.Call(ctx, &transaction.TxRequest{
427+
To: &c.stakingContractAddress,
428+
Data: callData,
429+
})
430+
if err != nil {
431+
return 0, fmt.Errorf("get oracle address: %w", err)
432+
}
433+
434+
results, err := c.stakingContractABI.Unpack("OracleContract", result)
435+
if err != nil {
436+
return 0, err
437+
}
438+
439+
if len(results) == 0 {
440+
return 0, errors.New("unexpected empty results")
441+
}
442+
443+
oracleAddr := *abi.ConvertType(results[0], new(common.Address)).(*common.Address)
444+
445+
callData, err = priceOracleABI.Pack("currentPrice")
446+
if err != nil {
447+
return 0, err
448+
}
449+
450+
result, err = c.transactionService.Call(ctx, &transaction.TxRequest{
451+
To: &oracleAddr,
452+
Data: callData,
453+
})
454+
if err != nil {
455+
return 0, fmt.Errorf("get current price: %w", err)
456+
}
457+
458+
results, err = priceOracleABI.Unpack("currentPrice", result)
459+
if err != nil {
460+
return 0, err
461+
}
462+
463+
if len(results) == 0 {
464+
return 0, errors.New("unexpected empty results")
465+
}
466+
467+
price, ok := results[0].(uint32)
468+
if !ok {
469+
return 0, fmt.Errorf("unexpected oracle price type %T", results[0])
354470
}
355471

356-
return abi.ConvertType(results[2], new(big.Int)).(*big.Int), nil
472+
return price, nil
357473
}
358474

359475
func (c *contract) getWithdrawableStake(ctx context.Context) (*big.Int, error) {

0 commit comments

Comments
 (0)