From 4e823dd7ab0eaaee7952b7dc6736fea7742f7b6f Mon Sep 17 00:00:00 2001 From: sbackend Date: Wed, 19 Aug 2026 01:11:13 +0200 Subject: [PATCH 1/8] 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 --- openapi/Swarm.yaml | 4 +- openapi/SwarmCommon.yaml | 2 + pkg/api/staking.go | 31 +- pkg/api/staking_test.go | 39 ++- pkg/storageincentives/staking/contract.go | 154 ++++++++-- .../staking/contract_test.go | 280 +++++++++++++++++- pkg/storageincentives/staking/export_test.go | 5 +- .../staking/mock/contract.go | 15 + 8 files changed, 496 insertions(+), 34 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 73b326d2cbf..89a3966791a 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: - version: 8.1.0 + version: 8.2.0 title: Bee API description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management" @@ -2567,7 +2567,7 @@ paths: "/stake": get: summary: Get the staked amount. - description: This endpoint fetches the total staked amount from the blockchain. + 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. tags: - Staking responses: diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index ffcbbac3b8e..fd26b047230 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -681,6 +681,8 @@ components: properties: stakedAmount: $ref: "#/components/schemas/BigInt" + minimumDeposit: + $ref: "#/components/schemas/BigInt" GetWithdrawableResponse: type: object diff --git a/pkg/api/staking.go b/pkg/api/staking.go index c9eb32219e4..102a6f6abc4 100644 --- a/pkg/api/staking.go +++ b/pkg/api/staking.go @@ -6,14 +6,15 @@ package api import ( "errors" + "fmt" "math/big" "net/http" - "github.com/ethersphere/bee/v2/pkg/bigint" + "github.com/gorilla/mux" + "github.com/ethersphere/bee/v2/pkg/bigint" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/storageincentives/staking" - "github.com/gorilla/mux" ) func (s *Service) stakingAccessHandler(h http.Handler) http.Handler { @@ -31,7 +32,8 @@ func (s *Service) stakingAccessHandler(h http.Handler) http.Handler { } type getStakeResponse struct { - StakedAmount *bigint.BigInt `json:"stakedAmount"` + StakedAmount *bigint.BigInt `json:"stakedAmount"` + MinimumDeposit *bigint.BigInt `json:"minimumDeposit"` } type getWithdrawableResponse struct { @@ -55,9 +57,15 @@ func (s *Service) stakingDepositHandler(w http.ResponseWriter, r *http.Request) txHash, err := s.stakingContract.DepositStake(r.Context(), paths.Amount) if err != nil { if errors.Is(err, staking.ErrInsufficientStakeAmount) { - logger.Debug("insufficient stake amount", "minimum_stake", staking.MinimumStakeAmount, "error", err) + minDeposit := staking.MinimumStakeAmount + var minErr *staking.MinDepositError + if errors.As(err, &minErr) && minErr.Minimum != nil { + minDeposit = minErr.Minimum + } + msg := fmt.Sprintf("insufficient stake amount, minimum is %s", minDeposit) + logger.Debug("insufficient stake amount", "minimum_stake", minDeposit, "error", err) logger.Error(nil, "insufficient stake amount") - jsonhttp.BadRequest(w, "insufficient stake amount") + jsonhttp.BadRequest(w, msg) return } if errors.Is(err, staking.ErrNotImplemented) { @@ -105,7 +113,18 @@ func (s *Service) getPotentialStake(w http.ResponseWriter, r *http.Request) { return } - jsonhttp.OK(w, getStakeResponse{StakedAmount: bigint.Wrap(stakedAmount)}) + minDeposit, err := s.stakingContract.GetMinDeposit(r.Context()) + if err != nil { + logger.Debug("get minimum deposit failed", "overlayAddr", s.overlay, "error", err) + logger.Error(nil, "get minimum deposit failed") + jsonhttp.InternalServerError(w, "get minimum deposit failed") + return + } + + jsonhttp.OK(w, getStakeResponse{ + StakedAmount: bigint.Wrap(stakedAmount), + MinimumDeposit: bigint.Wrap(minDeposit), + }) } func (s *Service) getWithdrawableStakeHandler(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/api/staking_test.go b/pkg/api/staking_test.go index ec154cf34bb..14f749b06be 100644 --- a/pkg/api/staking_test.go +++ b/pkg/api/staking_test.go @@ -12,9 +12,9 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/ethersphere/bee/v2/pkg/bigint" "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/bigint" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" "github.com/ethersphere/bee/v2/pkg/sctx" @@ -54,7 +54,21 @@ func TestDepositStake(t *testing.T) { ) ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract}) jsonhttptest.Request(t, ts, http.MethodPost, depositStake(invalidMinStake), http.StatusBadRequest, - jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount"})) + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount, minimum is 100000000000000000"})) + }) + + t.Run("with insufficient amount reports minimum", func(t *testing.T) { + t.Parallel() + + minDeposit := big.NewInt(123) + contract := stakingContractMock.New( + stakingContractMock.WithDepositStake(func(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) { + return common.Hash{}, &staking.MinDepositError{Minimum: minDeposit} + }), + ) + ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract}) + jsonhttptest.Request(t, ts, http.MethodPost, depositStake("1"), http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount, minimum is 123"})) }) t.Run("out of funds", func(t *testing.T) { @@ -134,7 +148,10 @@ func TestGetStakeCommitted(t *testing.T) { ) ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract}) jsonhttptest.Request(t, ts, http.MethodGet, "/stake", http.StatusOK, - jsonhttptest.WithExpectedJSONResponse(&api.GetStakeResponse{StakedAmount: bigint.Wrap(big.NewInt(1))})) + jsonhttptest.WithExpectedJSONResponse(&api.GetStakeResponse{ + StakedAmount: bigint.Wrap(big.NewInt(1)), + MinimumDeposit: bigint.Wrap(big.NewInt(1)), + })) }) t.Run("with error", func(t *testing.T) { @@ -149,6 +166,22 @@ func TestGetStakeCommitted(t *testing.T) { jsonhttptest.Request(t, ts, http.MethodGet, "/stake", http.StatusInternalServerError, jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusInternalServerError, Message: "get staked amount failed"})) }) + + t.Run("minimum deposit error", func(t *testing.T) { + t.Parallel() + + contractWithError := stakingContractMock.New( + stakingContractMock.WithGetStake(func(ctx context.Context) (*big.Int, error) { + return big.NewInt(1), nil + }), + stakingContractMock.WithGetMinDeposit(func(ctx context.Context) (*big.Int, error) { + return nil, fmt.Errorf("get minimum deposit failed") + }), + ) + ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contractWithError}) + jsonhttptest.Request(t, ts, http.MethodGet, "/stake", http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusInternalServerError, Message: "get minimum deposit failed"})) + }) } func TestGetStakeWithdrawable(t *testing.T) { diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index b9f08801d5d..ab1212db7da 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -13,16 +13,18 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethersphere/go-sw3-abi/sw3abi" + "github.com/ethersphere/bee/v2/pkg/sctx" "github.com/ethersphere/bee/v2/pkg/transaction" "github.com/ethersphere/bee/v2/pkg/util/abiutil" - "github.com/ethersphere/go-sw3-abi/sw3abi" ) var ( MinimumStakeAmount = big.NewInt(100000000000000000) - erc20ABI = abiutil.MustParseABI(sw3abi.ERC20ABIv0_6_9) + erc20ABI = abiutil.MustParseABI(sw3abi.ERC20ABIv0_6_9) + priceOracleABI = abiutil.MustParseABI(`[{"inputs":[],"name":"currentPrice","outputs":[{"internalType":"uint32","name":"","type":"uint32"}],"stateMutability":"view","type":"function"}]`) ErrInsufficientStakeAmount = errors.New("insufficient stake amount") ErrInsufficientFunds = errors.New("insufficient token balance") @@ -37,10 +39,25 @@ var ( migrateStakeDescription = "Migrate stake" ) +// MinDepositError is returned when a deposit is below the amount required by the +// staking contract, including the non-decreasing commitment rule. +type MinDepositError struct { + Minimum *big.Int +} + +func (e *MinDepositError) Error() string { + return fmt.Sprintf("insufficient stake amount: minimum %s", e.Minimum) +} + +func (e *MinDepositError) Unwrap() error { + return ErrInsufficientStakeAmount +} + type Contract interface { DepositStake(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) ChangeStakeOverlay(ctx context.Context, nonce common.Hash) (common.Hash, error) GetPotentialStake(ctx context.Context) (*big.Int, error) + GetMinDeposit(ctx context.Context) (*big.Int, error) GetWithdrawableStake(ctx context.Context) (*big.Int, error) WithdrawStake(ctx context.Context) (common.Hash, error) MigrateStake(ctx context.Context) (common.Hash, error) @@ -86,19 +103,13 @@ func New( } func (c *contract) DepositStake(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) { - prevStakedAmount, err := c.GetPotentialStake(ctx) + minDeposit, err := c.GetMinDeposit(ctx) if err != nil { return common.Hash{}, err } - if len(prevStakedAmount.Bits()) == 0 { - if stakedAmount.Cmp(MinimumStakeAmount) == -1 { - return common.Hash{}, ErrInsufficientStakeAmount - } - } - - if big.NewInt(0).Add(prevStakedAmount, stakedAmount).Cmp(big.NewInt(0).Mul(big.NewInt(1< 0 { + price, err = c.getCurrentPrice(ctx) + if err != nil { + return nil, fmt.Errorf("staking contract: failed to get oracle price: %w", err) + } + } + + return calculateMinDeposit(potential, committed, price, c.height), nil +} + +// calculateMinDeposit returns the minimum additional deposit in PLUR that +// manageStake will accept. The first deposit must cover 2^height * MIN_STAKE. +// Later deposits must keep committed stake from decreasing after a price +// increase; if that constraint is already satisfied the minimum is 1 PLUR. +func calculateMinDeposit(potential, committed *big.Int, price uint32, height uint8) *big.Int { + minAdd := new(big.Int) + + minTotal := new(big.Int).Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height)) + if gap := new(big.Int).Sub(minTotal, potential); gap.Sign() > 0 { + minAdd.Set(gap) + } + + if price != 0 && committed.Sign() > 0 { + required := new(big.Int).SetUint64(uint64(price)) + required.Lsh(required, uint(height)) + required.Mul(required, committed) + if gap := new(big.Int).Sub(required, potential); gap.Cmp(minAdd) > 0 { + minAdd.Set(gap) + } + } + + if minAdd.Sign() == 0 { + if potential.Sign() > 0 { + return big.NewInt(1) + } + return new(big.Int).Set(MinimumStakeAmount) + } + + return minAdd } func (c *contract) GetWithdrawableStake(ctx context.Context) (*big.Int, error) { @@ -327,17 +386,17 @@ func (c *contract) sendManageStakeTransaction(ctx context.Context, stakedAmount return receipt, nil } -func (c *contract) getPotentialStake(ctx context.Context) (*big.Int, error) { +func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, err error) { callData, err := c.stakingContractABI.Pack("stakes", c.owner) if err != nil { - return nil, err + return nil, nil, err } result, err := c.transactionService.Call(ctx, &transaction.TxRequest{ To: &c.stakingContractAddress, Data: callData, }) if err != nil { - return nil, fmt.Errorf("get potential stake: %w", err) + return nil, nil, fmt.Errorf("get potential stake: %w", err) } // overlay bytes32, @@ -346,14 +405,71 @@ func (c *contract) getPotentialStake(ctx context.Context) (*big.Int, error) { // lastUpdatedBlockNumber uint256, results, err := c.stakingContractABI.Unpack("stakes", result) if err != nil { - return nil, err + return nil, nil, err } if len(results) < 4 { - return nil, ErrUnexpectedLength + return nil, nil, ErrUnexpectedLength + } + + committed = abi.ConvertType(results[1], new(big.Int)).(*big.Int) + potential = abi.ConvertType(results[2], new(big.Int)).(*big.Int) + return committed, potential, nil +} + +func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) { + callData, err := c.stakingContractABI.Pack("OracleContract") + if err != nil { + return 0, err + } + + result, err := c.transactionService.Call(ctx, &transaction.TxRequest{ + To: &c.stakingContractAddress, + Data: callData, + }) + if err != nil { + return 0, fmt.Errorf("get oracle address: %w", err) + } + + results, err := c.stakingContractABI.Unpack("OracleContract", result) + if err != nil { + return 0, err + } + + if len(results) == 0 { + return 0, errors.New("unexpected empty results") + } + + oracleAddr := *abi.ConvertType(results[0], new(common.Address)).(*common.Address) + + callData, err = priceOracleABI.Pack("currentPrice") + if err != nil { + return 0, err + } + + result, err = c.transactionService.Call(ctx, &transaction.TxRequest{ + To: &oracleAddr, + Data: callData, + }) + if err != nil { + return 0, fmt.Errorf("get current price: %w", err) + } + + results, err = priceOracleABI.Unpack("currentPrice", result) + if err != nil { + return 0, err + } + + if len(results) == 0 { + return 0, errors.New("unexpected empty results") + } + + price, ok := results[0].(uint32) + if !ok { + return 0, fmt.Errorf("unexpected oracle price type %T", results[0]) } - return abi.ConvertType(results[2], new(big.Int)).(*big.Int), nil + return price, nil } func (c *contract) getWithdrawableStake(ctx context.Context) (*big.Int, error) { diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index 71762a569a1..8023668508f 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + chaincfg "github.com/ethersphere/bee/v2/pkg/config" "github.com/ethersphere/bee/v2/pkg/storageincentives/staking" "github.com/ethersphere/bee/v2/pkg/swarm" @@ -27,6 +28,82 @@ var stakingContractABI = abiutil.MustParseABI(chaincfg.Testnet.StakingABI) const stakingHeight = uint8(0) +func TestCalculateMinDeposit(t *testing.T) { + t.Parallel() + + minStake := staking.MinimumStakeAmount + committedAtMin := new(big.Int).Div(minStake, big.NewInt(1000)) // potential/price at price 1000 + + tests := []struct { + name string + potential *big.Int + committed *big.Int + price uint32 + height uint8 + want *big.Int + }{ + { + name: "first deposit height 0", + potential: big.NewInt(0), + committed: big.NewInt(0), + price: 1000, + height: 0, + want: minStake, + }, + { + name: "first deposit height 1", + potential: big.NewInt(0), + committed: big.NewInt(0), + price: 1000, + height: 1, + want: new(big.Int).Mul(minStake, big.NewInt(2)), + }, + { + name: "subsequent with surplus is one plur", + potential: new(big.Int).Mul(minStake, big.NewInt(2)), + committed: committedAtMin, + price: 1000, + height: 0, + want: big.NewInt(1), + }, + { + name: "exact cover is one plur", + potential: new(big.Int).Set(minStake), + committed: committedAtMin, + price: 1000, + height: 0, + want: big.NewInt(1), + }, + { + name: "price increase requires gap", + potential: new(big.Int).Set(minStake), + committed: committedAtMin, + price: 1001, + height: 0, + want: committedAtMin, + }, + { + name: "height doubles required potential", + potential: new(big.Int).Mul(minStake, big.NewInt(2)), + committed: committedAtMin, + price: 1001, + height: 1, + want: new(big.Int).Mul(committedAtMin, big.NewInt(2)), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := staking.CalculateMinDeposit(tc.potential, tc.committed, tc.price, tc.height) + if got.Cmp(tc.want) != 0 { + t.Fatalf("got %s, want %s", got, tc.want) + } + }) + } +} + func TestIsOverlayFrozen(t *testing.T) { t.Parallel() @@ -385,6 +462,97 @@ func TestDepositStake(t *testing.T) { } }) + t.Run("below commitment minimum does not send transaction", func(t *testing.T) { + t.Parallel() + + oracleAddr := common.HexToAddress("1111") + potential := new(big.Int).Set(staking.MinimumStakeAmount) + committed := new(big.Int).Div(potential, big.NewInt(1000)) + price := uint32(1001) + + contract := staking.New( + owner, + stakingContractAddress, + stakingContractABI, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithSendFunc(func(ctx context.Context, request *transaction.TxRequest, boost int) (txHash common.Hash, err error) { + t.Fatal("transaction should not be sent") + return common.Hash{}, nil + }), + transactionMock.WithCallFunc(newStakeCallFunc(t, stakingContractAddress, oracleAddr, bzzTokenAddress, committed, potential, potential, price)), + ), + nonce, + 0, + stakingHeight, + ) + + _, err := contract.DepositStake(ctx, big.NewInt(1)) + if !errors.Is(err, staking.ErrInsufficientStakeAmount) { + t.Fatal(fmt.Errorf("wanted %w, got %w", staking.ErrInsufficientStakeAmount, err)) + } + + var minErr *staking.MinDepositError + if !errors.As(err, &minErr) { + t.Fatal("expected MinDepositError") + } + if minErr.Minimum.Cmp(committed) != 0 { + t.Fatalf("minimum got %s, want %s", minErr.Minimum, committed) + } + }) + + t.Run("meets commitment minimum", func(t *testing.T) { + t.Parallel() + + oracleAddr := common.HexToAddress("1111") + potential := new(big.Int).Set(staking.MinimumStakeAmount) + committed := new(big.Int).Div(potential, big.NewInt(1000)) + price := uint32(1001) + addAmount := committed + balance := new(big.Int).Add(potential, addAmount) + + expectedCallData, err := stakingContractABI.Pack("manageStake", nonce, addAmount, stakingHeight) + if err != nil { + t.Fatal(err) + } + + contract := staking.New( + owner, + stakingContractAddress, + stakingContractABI, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithSendFunc(func(ctx context.Context, request *transaction.TxRequest, boost int) (txHash common.Hash, err error) { + if *request.To == bzzTokenAddress { + return txHashApprove, nil + } + if *request.To == stakingContractAddress { + if !bytes.Equal(expectedCallData[:80], request.Data[:80]) { + return common.Hash{}, fmt.Errorf("got wrong call data. wanted %x, got %x", expectedCallData, request.Data) + } + return txHashDeposited, nil + } + return common.Hash{}, errors.New("sent to wrong contract") + }), + transactionMock.WithWaitForReceiptFunc(func(ctx context.Context, txHash common.Hash) (receipt *types.Receipt, err error) { + if txHash == txHashDeposited || txHash == txHashApprove { + return &types.Receipt{Status: 1}, nil + } + return nil, errors.New("unknown tx hash") + }), + transactionMock.WithCallFunc(newStakeCallFunc(t, stakingContractAddress, oracleAddr, bzzTokenAddress, committed, potential, balance, price)), + ), + nonce, + 0, + stakingHeight, + ) + + _, err = contract.DepositStake(ctx, addAmount) + if err != nil { + t.Fatal(err) + } + }) + t.Run("send tx failed", func(t *testing.T) { t.Parallel() @@ -1235,6 +1403,76 @@ func TestGetCommittedStake(t *testing.T) { }) } +func TestGetMinDeposit(t *testing.T) { + t.Parallel() + + ctx := context.Background() + owner := common.HexToAddress("abcd") + stakingAddress := common.HexToAddress("ffff") + oracleAddr := common.HexToAddress("1111") + bzzTokenAddress := common.HexToAddress("eeee") + nonce := common.BytesToHash(make([]byte, 32)) + + t.Run("first deposit", func(t *testing.T) { + t.Parallel() + + contract := staking.New( + owner, + stakingAddress, + stakingContractABI, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithCallFunc(func(ctx context.Context, request *transaction.TxRequest) (result []byte, err error) { + if *request.To == stakingAddress { + return getPotentialStakeResponse(t, big.NewInt(0)), nil + } + return nil, errors.New("unexpected call") + }), + ), + nonce, + 0, + stakingHeight, + ) + + got, err := contract.GetMinDeposit(ctx) + if err != nil { + t.Fatal(err) + } + if got.Cmp(staking.MinimumStakeAmount) != 0 { + t.Fatalf("got %s, want %s", got, staking.MinimumStakeAmount) + } + }) + + t.Run("price increase", func(t *testing.T) { + t.Parallel() + + potential := new(big.Int).Set(staking.MinimumStakeAmount) + committed := new(big.Int).Div(potential, big.NewInt(1000)) + price := uint32(1001) + + contract := staking.New( + owner, + stakingAddress, + stakingContractABI, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithCallFunc(newStakeCallFunc(t, stakingAddress, oracleAddr, bzzTokenAddress, committed, potential, potential, price)), + ), + nonce, + 0, + stakingHeight, + ) + + got, err := contract.GetMinDeposit(ctx) + if err != nil { + t.Fatal(err) + } + if got.Cmp(committed) != 0 { + t.Fatalf("got %s, want %s", got, committed) + } + }) +} + func TestGetWithdrawableStake(t *testing.T) { t.Parallel() @@ -1926,12 +2164,48 @@ func TestMigrateStake(t *testing.T) { }) } -func getPotentialStakeResponse(t *testing.T, amount *big.Int) []byte { +func newStakeCallFunc( + t *testing.T, + stakingAddr, oracleAddr, bzzAddr common.Address, + committed, potential, balance *big.Int, + price uint32, +) func(ctx context.Context, request *transaction.TxRequest) ([]byte, error) { + t.Helper() + + oracleCallData, err := stakingContractABI.Pack("OracleContract") + if err != nil { + t.Fatal(err) + } + + return func(_ context.Context, request *transaction.TxRequest) ([]byte, error) { + switch *request.To { + case bzzAddr: + return balance.FillBytes(make([]byte, 32)), nil + case oracleAddr: + return big.NewInt(int64(price)).FillBytes(make([]byte, 32)), nil + case stakingAddr: + if bytes.Equal(request.Data, oracleCallData) { + return common.LeftPadBytes(oracleAddr.Bytes(), 32), nil + } + return getStakeResponse(t, committed, potential), nil + default: + return nil, errors.New("unexpected call") + } + } +} + +func getStakeResponse(t *testing.T, committed, potential *big.Int) []byte { t.Helper() - ret := make([]byte, 32+32+32+32+32+32) + ret := make([]byte, 32*5) copy(ret, swarm.RandAddress(t).Bytes()) - copy(ret[64:], amount.FillBytes(make([]byte, 32))) + copy(ret[32:], committed.FillBytes(make([]byte, 32))) + copy(ret[64:], potential.FillBytes(make([]byte, 32))) return ret } + +func getPotentialStakeResponse(t *testing.T, amount *big.Int) []byte { + t.Helper() + return getStakeResponse(t, big.NewInt(0), amount) +} diff --git a/pkg/storageincentives/staking/export_test.go b/pkg/storageincentives/staking/export_test.go index 55fd4332a71..8a88c01648b 100644 --- a/pkg/storageincentives/staking/export_test.go +++ b/pkg/storageincentives/staking/export_test.go @@ -4,4 +4,7 @@ package staking -var Erc20ABI = erc20ABI +var ( + Erc20ABI = erc20ABI + CalculateMinDeposit = calculateMinDeposit +) diff --git a/pkg/storageincentives/staking/mock/contract.go b/pkg/storageincentives/staking/mock/contract.go index e7446a8cd5a..008dfd599ef 100644 --- a/pkg/storageincentives/staking/mock/contract.go +++ b/pkg/storageincentives/staking/mock/contract.go @@ -9,12 +9,14 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/storageincentives/staking" ) type stakingContractMock struct { depositStake func(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) getStake func(ctx context.Context) (*big.Int, error) + getMinDeposit func(ctx context.Context) (*big.Int, error) withdrawAllStake func(ctx context.Context) (common.Hash, error) migrateStake func(ctx context.Context) (common.Hash, error) isFrozen func(ctx context.Context, block uint64) (bool, error) @@ -40,6 +42,13 @@ func (s *stakingContractMock) GetPotentialStake(ctx context.Context) (*big.Int, return s.getStake(ctx) } +func (s *stakingContractMock) GetMinDeposit(ctx context.Context) (*big.Int, error) { + if s.getMinDeposit != nil { + return s.getMinDeposit(ctx) + } + return big.NewInt(1), nil +} + func (s *stakingContractMock) GetWithdrawableStake(ctx context.Context) (*big.Int, error) { return s.getStake(ctx) } @@ -82,6 +91,12 @@ func WithGetStake(f func(ctx context.Context) (*big.Int, error)) Option { } } +func WithGetMinDeposit(f func(ctx context.Context) (*big.Int, error)) Option { + return func(mock *stakingContractMock) { + mock.getMinDeposit = f + } +} + func WithWithdrawStake(f func(ctx context.Context) (common.Hash, error)) Option { return func(mock *stakingContractMock) { mock.withdrawAllStake = f From 606ffc85c05734327507dd6c5a1e455249412bea Mon Sep 17 00:00:00 2001 From: sbackend Date: Sun, 23 Aug 2026 15:44:43 +0200 Subject: [PATCH 2/8] fix: clean up --- pkg/config/chain.go | 39 +++++++------ pkg/node/node.go | 2 +- pkg/storageincentives/staking/contract.go | 56 ++++++------------ .../staking/contract_test.go | 57 ++++++++++++++++--- 4 files changed, 87 insertions(+), 67 deletions(-) diff --git a/pkg/config/chain.go b/pkg/config/chain.go index 0cf07026bf1..5980298e22e 100644 --- a/pkg/config/chain.go +++ b/pkg/config/chain.go @@ -21,12 +21,13 @@ type ChainConfig struct { SwarmTokenSymbol string // Addresses. - StakingAddress common.Address - PostageStampAddress common.Address - RedistributionAddress common.Address - SwapPriceOracleAddress common.Address // Swap swear and swindle (S3) Contracts - CurrentFactoryAddress common.Address - TokenContractAddress common.Address + StakingAddress common.Address + PostageStampAddress common.Address + RedistributionAddress common.Address + IncentivesPriceOracleAddress common.Address // storage-incentives PriceOracle (postage / stake commitment) + SwapPriceOracleAddress common.Address // Swap swear and swindle (S3) Contracts + CurrentFactoryAddress common.Address + TokenContractAddress common.Address // ABIs. StakingABI string @@ -59,12 +60,13 @@ var ( NativeTokenSymbol: "ETH", SwarmTokenSymbol: "sBZZ", - StakingAddress: common.HexToAddress(abi.TestnetStakingAddress), - PostageStampAddress: common.HexToAddress(abi.TestnetPostageStampAddress), - RedistributionAddress: common.HexToAddress(abi.TestnetRedistributionAddress), - SwapPriceOracleAddress: common.HexToAddress("0x1814e9b3951Df0CB8e12b2bB99c5594514588936"), - CurrentFactoryAddress: common.HexToAddress("0x0fF044F6bB4F684a5A149B46D7eC03ea659F98A1"), - TokenContractAddress: common.HexToAddress(abi.TestnetBzzTokenAddress), + StakingAddress: common.HexToAddress(abi.TestnetStakingAddress), + PostageStampAddress: common.HexToAddress(abi.TestnetPostageStampAddress), + RedistributionAddress: common.HexToAddress(abi.TestnetRedistributionAddress), + IncentivesPriceOracleAddress: common.HexToAddress(abi.TestnetPriceOracleAddress), + SwapPriceOracleAddress: common.HexToAddress("0x1814e9b3951Df0CB8e12b2bB99c5594514588936"), + CurrentFactoryAddress: common.HexToAddress("0x0fF044F6bB4F684a5A149B46D7eC03ea659F98A1"), + TokenContractAddress: common.HexToAddress(abi.TestnetBzzTokenAddress), StakingABI: abi.TestnetStakingABI, PostageStampABI: abi.TestnetPostageStampABI, @@ -82,12 +84,13 @@ var ( NativeTokenSymbol: "xDAI", SwarmTokenSymbol: "xBZZ", - StakingAddress: common.HexToAddress(abi.MainnetStakingAddress), - PostageStampAddress: common.HexToAddress(abi.MainnetPostageStampAddress), - RedistributionAddress: common.HexToAddress(abi.MainnetRedistributionAddress), - SwapPriceOracleAddress: common.HexToAddress("0xA57A50a831B31c904A770edBCb706E03afCdbd94"), - CurrentFactoryAddress: common.HexToAddress("0xc2d5a532cf69aa9a1378737d8ccdef884b6e7420"), - TokenContractAddress: common.HexToAddress(abi.MainnetBzzTokenAddress), + StakingAddress: common.HexToAddress(abi.MainnetStakingAddress), + PostageStampAddress: common.HexToAddress(abi.MainnetPostageStampAddress), + RedistributionAddress: common.HexToAddress(abi.MainnetRedistributionAddress), + IncentivesPriceOracleAddress: common.HexToAddress(abi.MainnetPriceOracleAddress), + SwapPriceOracleAddress: common.HexToAddress("0xA57A50a831B31c904A770edBCb706E03afCdbd94"), + CurrentFactoryAddress: common.HexToAddress("0xc2d5a532cf69aa9a1378737d8ccdef884b6e7420"), + TokenContractAddress: common.HexToAddress(abi.MainnetBzzTokenAddress), StakingABI: abi.MainnetStakingABI, PostageStampABI: abi.MainnetPostageStampABI, diff --git a/pkg/node/node.go b/pkg/node/node.go index b04a05a5a30..da536bc3c71 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -1224,7 +1224,7 @@ func NewBee( stakingContractAddress = common.HexToAddress(o.StakingContractAddress) } - stakingContract := staking.New(overlayEthAddress, stakingContractAddress, abiutil.MustParseABI(chainCfg.StakingABI), bzzTokenAddress, transactionService, common.BytesToHash(nonce), contractGasLimit, uint8(o.ReserveCapacityDoubling)) + stakingContract := staking.New(overlayEthAddress, stakingContractAddress, abiutil.MustParseABI(chainCfg.StakingABI), bzzTokenAddress, transactionService, common.BytesToHash(nonce), contractGasLimit, uint8(o.ReserveCapacityDoubling), chainCfg.IncentivesPriceOracleAddress) if chainEnabled { diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index ab1212db7da..6c87c855307 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -74,6 +74,7 @@ type contract struct { stakingContractAddress common.Address stakingContractABI abi.ABI bzzTokenAddress common.Address + priceOracleAddress common.Address transactionService transaction.Service overlayNonce common.Hash gasLimit uint64 @@ -89,12 +90,14 @@ func New( nonce common.Hash, gasLimit uint64, height uint8, + priceOracleAddress common.Address, ) Contract { return &contract{ owner: owner, stakingContractAddress: stakingContractAddress, stakingContractABI: stakingContractABI, bzzTokenAddress: bzzTokenAddress, + priceOracleAddress: priceOracleAddress, transactionService: transactionService, overlayNonce: nonce, gasLimit: gasLimit, @@ -189,34 +192,33 @@ func (c *contract) GetMinDeposit(ctx context.Context) (*big.Int, error) { return calculateMinDeposit(potential, committed, price, c.height), nil } -// calculateMinDeposit returns the minimum additional deposit in PLUR that -// manageStake will accept. The first deposit must cover 2^height * MIN_STAKE. -// Later deposits must keep committed stake from decreasing after a price -// increase; if that constraint is already satisfied the minimum is 1 PLUR. +// calculateMinDeposit returns the minimum additional deposit in PLUR that manageStake will accept according to contract. func calculateMinDeposit(potential, committed *big.Int, price uint32, height uint8) *big.Int { minAdd := new(big.Int) + // potential stake should be at least MIN_STAKE * 2^height. + // minAdd = max(0, minTotal - potential). minTotal := new(big.Int).Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height)) if gap := new(big.Int).Sub(minTotal, potential); gap.Sign() > 0 { minAdd.Set(gap) } if price != 0 && committed.Sign() > 0 { + // User already has committed stake. + // Commitment protection: required = committed * price * 2^height required := new(big.Int).SetUint64(uint64(price)) - required.Lsh(required, uint(height)) - required.Mul(required, committed) + required.Lsh(required, uint(height)) // * 2^height + required.Mul(required, committed) // * committed if gap := new(big.Int).Sub(required, potential); gap.Cmp(minAdd) > 0 { minAdd.Set(gap) } } + // floors and commitment already satisfied. + // DepositStake still needs a positive addAmount; 1 PLUR is enough. if minAdd.Sign() == 0 { - if potential.Sign() > 0 { - return big.NewInt(1) - } - return new(big.Int).Set(MinimumStakeAmount) + return big.NewInt(1) } - return minAdd } @@ -396,7 +398,7 @@ func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, Data: callData, }) if err != nil { - return nil, nil, fmt.Errorf("get potential stake: %w", err) + return nil, nil, fmt.Errorf("get stakes: %w", err) } // overlay bytes32, @@ -418,44 +420,20 @@ func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, } func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) { - callData, err := c.stakingContractABI.Pack("OracleContract") + callData, err := priceOracleABI.Pack("currentPrice") if err != nil { return 0, err } result, err := c.transactionService.Call(ctx, &transaction.TxRequest{ - To: &c.stakingContractAddress, - Data: callData, - }) - if err != nil { - return 0, fmt.Errorf("get oracle address: %w", err) - } - - results, err := c.stakingContractABI.Unpack("OracleContract", result) - if err != nil { - return 0, err - } - - if len(results) == 0 { - return 0, errors.New("unexpected empty results") - } - - oracleAddr := *abi.ConvertType(results[0], new(common.Address)).(*common.Address) - - callData, err = priceOracleABI.Pack("currentPrice") - if err != nil { - return 0, err - } - - result, err = c.transactionService.Call(ctx, &transaction.TxRequest{ - To: &oracleAddr, + To: &c.priceOracleAddress, Data: callData, }) if err != nil { return 0, fmt.Errorf("get current price: %w", err) } - results, err = priceOracleABI.Unpack("currentPrice", result) + results, err := priceOracleABI.Unpack("currentPrice", result) if err != nil { return 0, err } diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index 8023668508f..63db89e31d2 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -24,7 +24,10 @@ import ( "github.com/ethersphere/bee/v2/pkg/util/abiutil" ) -var stakingContractABI = abiutil.MustParseABI(chaincfg.Testnet.StakingABI) +var ( + stakingContractABI = abiutil.MustParseABI(chaincfg.Testnet.StakingABI) + priceOracleAddress = common.HexToAddress("1111") +) const stakingHeight = uint8(0) @@ -135,6 +138,7 @@ func TestIsOverlayFrozen(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) frozen, err := contract.IsOverlayFrozen(ctx, uint64(height-1)) @@ -223,6 +227,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -285,6 +290,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -325,6 +331,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, big.NewInt(0)) @@ -358,6 +365,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, big.NewInt(100000000000000000)) @@ -421,6 +429,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, 1, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -454,6 +463,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, 1, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -485,6 +495,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, big.NewInt(1)) @@ -545,6 +556,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, addAmount) @@ -584,6 +596,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -634,6 +647,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -696,6 +710,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -756,6 +771,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -783,6 +799,7 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -852,6 +869,7 @@ func TestChangeHeight(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, updated, err := contract.UpdateHeight(ctx) @@ -916,6 +934,7 @@ func TestChangeHeight(t *testing.T) { nonce, 0, newHeight, + priceOracleAddress, ) _, updated, err := contract.UpdateHeight(ctx) @@ -980,6 +999,7 @@ func TestChangeHeight(t *testing.T) { nonce, 0, newHeight, + priceOracleAddress, ) _, updated, err := contract.UpdateHeight(ctx) @@ -1015,6 +1035,7 @@ func TestChangeHeight(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -1042,6 +1063,7 @@ func TestChangeHeight(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, _, err := contract.UpdateHeight(ctx) @@ -1098,6 +1120,7 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.ChangeStakeOverlay(ctx, nonce) @@ -1125,6 +1148,7 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.ChangeStakeOverlay(ctx, nonce) @@ -1160,6 +1184,7 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) newNonce := make([]byte, 32) @@ -1206,6 +1231,7 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.ChangeStakeOverlay(ctx, nonce) @@ -1247,6 +1273,7 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.ChangeStakeOverlay(ctx, nonce) @@ -1294,6 +1321,7 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) stakedAmount, err := contract.GetPotentialStake(ctx) @@ -1332,6 +1360,7 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.GetPotentialStake(ctx) @@ -1370,6 +1399,7 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.GetPotentialStake(ctx) @@ -1394,6 +1424,7 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.GetPotentialStake(ctx) @@ -1432,6 +1463,7 @@ func TestGetMinDeposit(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) got, err := contract.GetMinDeposit(ctx) @@ -1461,6 +1493,7 @@ func TestGetMinDeposit(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) got, err := contract.GetMinDeposit(ctx) @@ -1511,6 +1544,7 @@ func TestGetWithdrawableStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) withdrawableStake, err := contract.GetWithdrawableStake(ctx) @@ -1549,6 +1583,7 @@ func TestGetWithdrawableStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.GetPotentialStake(ctx) @@ -1573,6 +1608,7 @@ func TestGetWithdrawableStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err := contract.GetPotentialStake(ctx) @@ -1640,6 +1676,7 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1676,6 +1713,7 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1734,6 +1772,7 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1790,6 +1829,7 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1824,6 +1864,7 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1899,6 +1940,7 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -1934,6 +1976,7 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2015,6 +2058,7 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2079,6 +2123,7 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2113,6 +2158,7 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -2155,6 +2201,7 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, + priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2172,11 +2219,6 @@ func newStakeCallFunc( ) func(ctx context.Context, request *transaction.TxRequest) ([]byte, error) { t.Helper() - oracleCallData, err := stakingContractABI.Pack("OracleContract") - if err != nil { - t.Fatal(err) - } - return func(_ context.Context, request *transaction.TxRequest) ([]byte, error) { switch *request.To { case bzzAddr: @@ -2184,9 +2226,6 @@ func newStakeCallFunc( case oracleAddr: return big.NewInt(int64(price)).FillBytes(make([]byte, 32)), nil case stakingAddr: - if bytes.Equal(request.Data, oracleCallData) { - return common.LeftPadBytes(oracleAddr.Bytes(), 32), nil - } return getStakeResponse(t, committed, potential), nil default: return nil, errors.New("unexpected call") From b974e2021224652140bcae19be80e4814a33a4eb Mon Sep 17 00:00:00 2001 From: sbackend Date: Sun, 23 Aug 2026 16:03:01 +0200 Subject: [PATCH 3/8] fix: clean up 2 --- openapi/Swarm.yaml | 6 +++++- openapi/SwarmCommon.yaml | 10 ++++++++++ pkg/api/export_test.go | 1 + pkg/api/staking.go | 24 ++++++++++++++---------- pkg/api/staking_test.go | 20 +++++--------------- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 89a3966791a..751efdd5711 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -2558,7 +2558,11 @@ paths: schema: $ref: "SwarmCommon.yaml#/components/schemas/StakeTransactionResponse" "400": - $ref: "SwarmCommon.yaml#/components/responses/400" + description: Deposit amount is below the required minimum. + content: + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/StakeDepositErrorResponse" "500": $ref: "SwarmCommon.yaml#/components/responses/500" default: diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index fd26b047230..a1b9718eb3b 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -684,6 +684,16 @@ components: minimumDeposit: $ref: "#/components/schemas/BigInt" + StakeDepositErrorResponse: + type: object + properties: + code: + type: integer + message: + type: string + minimumDeposit: + $ref: "#/components/schemas/BigInt" + GetWithdrawableResponse: type: object properties: diff --git a/pkg/api/export_test.go b/pkg/api/export_test.go index 5bda912a3e9..648f95809a7 100644 --- a/pkg/api/export_test.go +++ b/pkg/api/export_test.go @@ -94,6 +94,7 @@ type ( WalletResponse = walletResponse WalletTxResponse = walletTxResponse GetStakeResponse = getStakeResponse + StakeDepositErrorResponse = stakeDepositErrorResponse GetWithdrawableResponse = getWithdrawableResponse StakeTransactionReponse = stakeTransactionReponse StatusSnapshotResponse = statusSnapshotResponse diff --git a/pkg/api/staking.go b/pkg/api/staking.go index 102a6f6abc4..99357ed4281 100644 --- a/pkg/api/staking.go +++ b/pkg/api/staking.go @@ -6,7 +6,6 @@ package api import ( "errors" - "fmt" "math/big" "net/http" @@ -43,6 +42,12 @@ type stakeTransactionReponse struct { TxHash string `json:"txHash"` } +type stakeDepositErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + MinimumDeposit *bigint.BigInt `json:"minimumDeposit"` +} + func (s *Service) stakingDepositHandler(w http.ResponseWriter, r *http.Request) { logger := s.logger.WithName("post_stake_deposit").Build() @@ -56,16 +61,15 @@ func (s *Service) stakingDepositHandler(w http.ResponseWriter, r *http.Request) txHash, err := s.stakingContract.DepositStake(r.Context(), paths.Amount) if err != nil { - if errors.Is(err, staking.ErrInsufficientStakeAmount) { - minDeposit := staking.MinimumStakeAmount - var minErr *staking.MinDepositError - if errors.As(err, &minErr) && minErr.Minimum != nil { - minDeposit = minErr.Minimum - } - msg := fmt.Sprintf("insufficient stake amount, minimum is %s", minDeposit) - logger.Debug("insufficient stake amount", "minimum_stake", minDeposit, "error", err) + var minErr *staking.MinDepositError + if errors.As(err, &minErr) { + logger.Debug("insufficient stake amount", "minimum_deposit", minErr.Minimum, "error", err) logger.Error(nil, "insufficient stake amount") - jsonhttp.BadRequest(w, msg) + jsonhttp.BadRequest(w, stakeDepositErrorResponse{ + Code: http.StatusBadRequest, + Message: "insufficient stake amount", + MinimumDeposit: bigint.Wrap(minErr.Minimum), + }) return } if errors.Is(err, staking.ErrNotImplemented) { diff --git a/pkg/api/staking_test.go b/pkg/api/staking_test.go index 14f749b06be..febd9823bba 100644 --- a/pkg/api/staking_test.go +++ b/pkg/api/staking_test.go @@ -43,20 +43,6 @@ func TestDepositStake(t *testing.T) { jsonhttptest.Request(t, ts, http.MethodPost, depositStake(minStake), http.StatusOK) }) - t.Run("with invalid stake amount", func(t *testing.T) { - t.Parallel() - - invalidMinStake := big.NewInt(0).String() - contract := stakingContractMock.New( - stakingContractMock.WithDepositStake(func(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) { - return common.Hash{}, staking.ErrInsufficientStakeAmount - }), - ) - ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract}) - jsonhttptest.Request(t, ts, http.MethodPost, depositStake(invalidMinStake), http.StatusBadRequest, - jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount, minimum is 100000000000000000"})) - }) - t.Run("with insufficient amount reports minimum", func(t *testing.T) { t.Parallel() @@ -68,7 +54,11 @@ func TestDepositStake(t *testing.T) { ) ts, _, _, _ := newTestServer(t, testServerOptions{StakingContract: contract}) jsonhttptest.Request(t, ts, http.MethodPost, depositStake("1"), http.StatusBadRequest, - jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "insufficient stake amount, minimum is 123"})) + jsonhttptest.WithExpectedJSONResponse(&api.StakeDepositErrorResponse{ + Code: http.StatusBadRequest, + Message: "insufficient stake amount", + MinimumDeposit: bigint.Wrap(minDeposit), + })) }) t.Run("out of funds", func(t *testing.T) { From 36cacf7444bf64274db1634c8bd5e2336099f3e9 Mon Sep 17 00:00:00 2001 From: sbackend Date: Sun, 23 Aug 2026 16:56:26 +0200 Subject: [PATCH 4/8] fix: read price oracle address from contract --- pkg/config/chain.go | 39 ++++++++-------- pkg/node/node.go | 11 ++++- pkg/storageincentives/staking/contract.go | 45 ++++++++++++++----- .../staking/contract_test.go | 8 ++++ 4 files changed, 70 insertions(+), 33 deletions(-) diff --git a/pkg/config/chain.go b/pkg/config/chain.go index 5980298e22e..0cf07026bf1 100644 --- a/pkg/config/chain.go +++ b/pkg/config/chain.go @@ -21,13 +21,12 @@ type ChainConfig struct { SwarmTokenSymbol string // Addresses. - StakingAddress common.Address - PostageStampAddress common.Address - RedistributionAddress common.Address - IncentivesPriceOracleAddress common.Address // storage-incentives PriceOracle (postage / stake commitment) - SwapPriceOracleAddress common.Address // Swap swear and swindle (S3) Contracts - CurrentFactoryAddress common.Address - TokenContractAddress common.Address + StakingAddress common.Address + PostageStampAddress common.Address + RedistributionAddress common.Address + SwapPriceOracleAddress common.Address // Swap swear and swindle (S3) Contracts + CurrentFactoryAddress common.Address + TokenContractAddress common.Address // ABIs. StakingABI string @@ -60,13 +59,12 @@ var ( NativeTokenSymbol: "ETH", SwarmTokenSymbol: "sBZZ", - StakingAddress: common.HexToAddress(abi.TestnetStakingAddress), - PostageStampAddress: common.HexToAddress(abi.TestnetPostageStampAddress), - RedistributionAddress: common.HexToAddress(abi.TestnetRedistributionAddress), - IncentivesPriceOracleAddress: common.HexToAddress(abi.TestnetPriceOracleAddress), - SwapPriceOracleAddress: common.HexToAddress("0x1814e9b3951Df0CB8e12b2bB99c5594514588936"), - CurrentFactoryAddress: common.HexToAddress("0x0fF044F6bB4F684a5A149B46D7eC03ea659F98A1"), - TokenContractAddress: common.HexToAddress(abi.TestnetBzzTokenAddress), + StakingAddress: common.HexToAddress(abi.TestnetStakingAddress), + PostageStampAddress: common.HexToAddress(abi.TestnetPostageStampAddress), + RedistributionAddress: common.HexToAddress(abi.TestnetRedistributionAddress), + SwapPriceOracleAddress: common.HexToAddress("0x1814e9b3951Df0CB8e12b2bB99c5594514588936"), + CurrentFactoryAddress: common.HexToAddress("0x0fF044F6bB4F684a5A149B46D7eC03ea659F98A1"), + TokenContractAddress: common.HexToAddress(abi.TestnetBzzTokenAddress), StakingABI: abi.TestnetStakingABI, PostageStampABI: abi.TestnetPostageStampABI, @@ -84,13 +82,12 @@ var ( NativeTokenSymbol: "xDAI", SwarmTokenSymbol: "xBZZ", - StakingAddress: common.HexToAddress(abi.MainnetStakingAddress), - PostageStampAddress: common.HexToAddress(abi.MainnetPostageStampAddress), - RedistributionAddress: common.HexToAddress(abi.MainnetRedistributionAddress), - IncentivesPriceOracleAddress: common.HexToAddress(abi.MainnetPriceOracleAddress), - SwapPriceOracleAddress: common.HexToAddress("0xA57A50a831B31c904A770edBCb706E03afCdbd94"), - CurrentFactoryAddress: common.HexToAddress("0xc2d5a532cf69aa9a1378737d8ccdef884b6e7420"), - TokenContractAddress: common.HexToAddress(abi.MainnetBzzTokenAddress), + StakingAddress: common.HexToAddress(abi.MainnetStakingAddress), + PostageStampAddress: common.HexToAddress(abi.MainnetPostageStampAddress), + RedistributionAddress: common.HexToAddress(abi.MainnetRedistributionAddress), + SwapPriceOracleAddress: common.HexToAddress("0xA57A50a831B31c904A770edBCb706E03afCdbd94"), + CurrentFactoryAddress: common.HexToAddress("0xc2d5a532cf69aa9a1378737d8ccdef884b6e7420"), + TokenContractAddress: common.HexToAddress(abi.MainnetBzzTokenAddress), StakingABI: abi.MainnetStakingABI, PostageStampABI: abi.MainnetPostageStampABI, diff --git a/pkg/node/node.go b/pkg/node/node.go index da536bc3c71..8df90ef9f1c 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -1224,7 +1224,16 @@ func NewBee( stakingContractAddress = common.HexToAddress(o.StakingContractAddress) } - stakingContract := staking.New(overlayEthAddress, stakingContractAddress, abiutil.MustParseABI(chainCfg.StakingABI), bzzTokenAddress, transactionService, common.BytesToHash(nonce), contractGasLimit, uint8(o.ReserveCapacityDoubling), chainCfg.IncentivesPriceOracleAddress) + stakingContractABI := abiutil.MustParseABI(chainCfg.StakingABI) + var stakingPriceOracleAddress common.Address + if chainEnabled { + stakingPriceOracleAddress, err = staking.GetPriceOracleAddress(ctx, stakingContractAddress, stakingContractABI, transactionService) + if err != nil { + return nil, fmt.Errorf("get staking price oracle address: %w", err) + } + } + + stakingContract := staking.New(overlayEthAddress, stakingContractAddress, stakingContractABI, bzzTokenAddress, transactionService, common.BytesToHash(nonce), contractGasLimit, uint8(o.ReserveCapacityDoubling), stakingPriceOracleAddress) if chainEnabled { diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index 6c87c855307..6e0bd9e03b7 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -105,6 +105,36 @@ func New( } } +// GetPriceOracleAddress returns the price oracle configured by the staking contract. +func GetPriceOracleAddress(ctx context.Context, stakingAddress common.Address, stakingABI abi.ABI, transactionService transaction.Service) (common.Address, error) { + callData, err := stakingABI.Pack("OracleContract") + if err != nil { + return common.Address{}, err + } + + result, err := transactionService.Call(ctx, &transaction.TxRequest{ + To: &stakingAddress, + Data: callData, + }) + if err != nil { + return common.Address{}, err + } + + results, err := stakingABI.Unpack("OracleContract", result) + if err != nil { + return common.Address{}, err + } + if len(results) == 0 { + return common.Address{}, ErrUnexpectedLength + } + + oracleAddress, ok := results[0].(common.Address) + if !ok { + return common.Address{}, fmt.Errorf("unexpected oracle address type %T", results[0]) + } + return oracleAddress, nil +} + func (c *contract) DepositStake(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) { minDeposit, err := c.GetMinDeposit(ctx) if err != nil { @@ -194,13 +224,11 @@ func (c *contract) GetMinDeposit(ctx context.Context) (*big.Int, error) { // calculateMinDeposit returns the minimum additional deposit in PLUR that manageStake will accept according to contract. func calculateMinDeposit(potential, committed *big.Int, price uint32, height uint8) *big.Int { - minAdd := new(big.Int) + minAdd := big.NewInt(1) - // potential stake should be at least MIN_STAKE * 2^height. - // minAdd = max(0, minTotal - potential). - minTotal := new(big.Int).Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height)) - if gap := new(big.Int).Sub(minTotal, potential); gap.Sign() > 0 { - minAdd.Set(gap) + // The contract applies the minimum stake floor only when creating a stake. + if potential.Sign() == 0 { + minAdd.Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height)) } if price != 0 && committed.Sign() > 0 { @@ -214,11 +242,6 @@ func calculateMinDeposit(potential, committed *big.Int, price uint32, height uin } } - // floors and commitment already satisfied. - // DepositStake still needs a positive addAmount; 1 PLUR is enough. - if minAdd.Sign() == 0 { - return big.NewInt(1) - } return minAdd } diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index 63db89e31d2..6d5d893a9f3 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -93,6 +93,14 @@ func TestCalculateMinDeposit(t *testing.T) { height: 1, want: new(big.Int).Mul(committedAtMin, big.NewInt(2)), }, + { + name: "existing slashed stake does not restore initial floor", + potential: new(big.Int).Div(minStake, big.NewInt(10)), + committed: committedAtMin, + price: 100, + height: 0, + want: big.NewInt(1), + }, } for _, tc := range tests { From ca63de196bf9107368100476d35118d30418490e Mon Sep 17 00:00:00 2001 From: sbackend Date: Wed, 26 Aug 2026 18:08:57 +0200 Subject: [PATCH 5/8] fix: lazy resolve price oracle address --- pkg/node/node.go | 11 +-- pkg/storageincentives/staking/contract.go | 78 +++++++++++-------- .../staking/contract_test.go | 52 ++----------- 3 files changed, 54 insertions(+), 87 deletions(-) diff --git a/pkg/node/node.go b/pkg/node/node.go index 8df90ef9f1c..b04a05a5a30 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -1224,16 +1224,7 @@ func NewBee( stakingContractAddress = common.HexToAddress(o.StakingContractAddress) } - stakingContractABI := abiutil.MustParseABI(chainCfg.StakingABI) - var stakingPriceOracleAddress common.Address - if chainEnabled { - stakingPriceOracleAddress, err = staking.GetPriceOracleAddress(ctx, stakingContractAddress, stakingContractABI, transactionService) - if err != nil { - return nil, fmt.Errorf("get staking price oracle address: %w", err) - } - } - - stakingContract := staking.New(overlayEthAddress, stakingContractAddress, stakingContractABI, bzzTokenAddress, transactionService, common.BytesToHash(nonce), contractGasLimit, uint8(o.ReserveCapacityDoubling), stakingPriceOracleAddress) + stakingContract := staking.New(overlayEthAddress, stakingContractAddress, abiutil.MustParseABI(chainCfg.StakingABI), bzzTokenAddress, transactionService, common.BytesToHash(nonce), contractGasLimit, uint8(o.ReserveCapacityDoubling)) if chainEnabled { diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index 6e0bd9e03b7..899c0a48bde 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -90,14 +90,12 @@ func New( nonce common.Hash, gasLimit uint64, height uint8, - priceOracleAddress common.Address, ) Contract { return &contract{ owner: owner, stakingContractAddress: stakingContractAddress, stakingContractABI: stakingContractABI, bzzTokenAddress: bzzTokenAddress, - priceOracleAddress: priceOracleAddress, transactionService: transactionService, overlayNonce: nonce, gasLimit: gasLimit, @@ -105,36 +103,6 @@ func New( } } -// GetPriceOracleAddress returns the price oracle configured by the staking contract. -func GetPriceOracleAddress(ctx context.Context, stakingAddress common.Address, stakingABI abi.ABI, transactionService transaction.Service) (common.Address, error) { - callData, err := stakingABI.Pack("OracleContract") - if err != nil { - return common.Address{}, err - } - - result, err := transactionService.Call(ctx, &transaction.TxRequest{ - To: &stakingAddress, - Data: callData, - }) - if err != nil { - return common.Address{}, err - } - - results, err := stakingABI.Unpack("OracleContract", result) - if err != nil { - return common.Address{}, err - } - if len(results) == 0 { - return common.Address{}, ErrUnexpectedLength - } - - oracleAddress, ok := results[0].(common.Address) - if !ok { - return common.Address{}, fmt.Errorf("unexpected oracle address type %T", results[0]) - } - return oracleAddress, nil -} - func (c *contract) DepositStake(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) { minDeposit, err := c.GetMinDeposit(ctx) if err != nil { @@ -443,13 +411,18 @@ func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, } func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) { + oracleAddress, err := c.getPriceOracleAddress(ctx) + if err != nil { + return 0, err + } + callData, err := priceOracleABI.Pack("currentPrice") if err != nil { return 0, err } result, err := c.transactionService.Call(ctx, &transaction.TxRequest{ - To: &c.priceOracleAddress, + To: &oracleAddress, Data: callData, }) if err != nil { @@ -473,6 +446,45 @@ func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) { return price, nil } +// getPriceOracleAddress resolves the price oracle from the staking contract on first use. +func (c *contract) getPriceOracleAddress(ctx context.Context) (common.Address, error) { + if (c.priceOracleAddress != common.Address{}) { + return c.priceOracleAddress, nil + } + + callData, err := c.stakingContractABI.Pack("OracleContract") + if err != nil { + return common.Address{}, err + } + + result, err := c.transactionService.Call(ctx, &transaction.TxRequest{ + To: &c.stakingContractAddress, + Data: callData, + }) + if err != nil { + return common.Address{}, fmt.Errorf("get price oracle address: %w", err) + } + + results, err := c.stakingContractABI.Unpack("OracleContract", result) + if err != nil { + return common.Address{}, err + } + if len(results) == 0 { + return common.Address{}, ErrUnexpectedLength + } + + oracleAddress, ok := results[0].(common.Address) + if !ok { + return common.Address{}, fmt.Errorf("unexpected oracle address type %T", results[0]) + } + if (oracleAddress == common.Address{}) { + return common.Address{}, errors.New("staking contract returned zero price oracle address") + } + + c.priceOracleAddress = oracleAddress + return oracleAddress, nil +} + func (c *contract) getWithdrawableStake(ctx context.Context) (*big.Int, error) { callData, err := c.stakingContractABI.Pack("withdrawableStake") if err != nil { diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index 6d5d893a9f3..3039df9d857 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -146,7 +146,6 @@ func TestIsOverlayFrozen(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) frozen, err := contract.IsOverlayFrozen(ctx, uint64(height-1)) @@ -235,7 +234,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -298,7 +296,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -339,7 +336,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, big.NewInt(0)) @@ -373,7 +369,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, big.NewInt(100000000000000000)) @@ -437,7 +432,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, 1, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -471,7 +465,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, 1, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -503,7 +496,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, big.NewInt(1)) @@ -564,7 +556,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, addAmount) @@ -604,7 +595,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -655,7 +645,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -718,7 +707,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -779,7 +767,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.DepositStake(ctx, stakedAmount) @@ -807,7 +794,6 @@ func TestDepositStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -877,7 +863,6 @@ func TestChangeHeight(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, updated, err := contract.UpdateHeight(ctx) @@ -942,7 +927,6 @@ func TestChangeHeight(t *testing.T) { nonce, 0, newHeight, - priceOracleAddress, ) _, updated, err := contract.UpdateHeight(ctx) @@ -1007,7 +991,6 @@ func TestChangeHeight(t *testing.T) { nonce, 0, newHeight, - priceOracleAddress, ) _, updated, err := contract.UpdateHeight(ctx) @@ -1043,7 +1026,6 @@ func TestChangeHeight(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.DepositStake(ctx, stakedAmount) @@ -1071,7 +1053,6 @@ func TestChangeHeight(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, _, err := contract.UpdateHeight(ctx) @@ -1128,7 +1109,6 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.ChangeStakeOverlay(ctx, nonce) @@ -1156,7 +1136,6 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.ChangeStakeOverlay(ctx, nonce) @@ -1192,7 +1171,6 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) newNonce := make([]byte, 32) @@ -1239,7 +1217,6 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.ChangeStakeOverlay(ctx, nonce) @@ -1281,7 +1258,6 @@ func TestChangeStakeOverlay(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.ChangeStakeOverlay(ctx, nonce) @@ -1329,7 +1305,6 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) stakedAmount, err := contract.GetPotentialStake(ctx) @@ -1368,7 +1343,6 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.GetPotentialStake(ctx) @@ -1407,7 +1381,6 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.GetPotentialStake(ctx) @@ -1432,7 +1405,6 @@ func TestGetCommittedStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.GetPotentialStake(ctx) @@ -1471,7 +1443,6 @@ func TestGetMinDeposit(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) got, err := contract.GetMinDeposit(ctx) @@ -1501,7 +1472,6 @@ func TestGetMinDeposit(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) got, err := contract.GetMinDeposit(ctx) @@ -1552,7 +1522,6 @@ func TestGetWithdrawableStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) withdrawableStake, err := contract.GetWithdrawableStake(ctx) @@ -1591,7 +1560,6 @@ func TestGetWithdrawableStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.GetPotentialStake(ctx) @@ -1616,7 +1584,6 @@ func TestGetWithdrawableStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err := contract.GetPotentialStake(ctx) @@ -1684,7 +1651,6 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1721,7 +1687,6 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1780,7 +1745,6 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1837,7 +1801,6 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1872,7 +1835,6 @@ func TestWithdrawStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -1948,7 +1910,6 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -1984,7 +1945,6 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2066,7 +2026,6 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2131,7 +2090,6 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2166,7 +2124,6 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.WithdrawStake(ctx) @@ -2209,7 +2166,6 @@ func TestMigrateStake(t *testing.T) { nonce, 0, stakingHeight, - priceOracleAddress, ) _, err = contract.MigrateStake(ctx) @@ -2227,6 +2183,11 @@ func newStakeCallFunc( ) func(ctx context.Context, request *transaction.TxRequest) ([]byte, error) { t.Helper() + oracleCallData, err := stakingContractABI.Pack("OracleContract") + if err != nil { + t.Fatal(err) + } + return func(_ context.Context, request *transaction.TxRequest) ([]byte, error) { switch *request.To { case bzzAddr: @@ -2234,6 +2195,9 @@ func newStakeCallFunc( case oracleAddr: return big.NewInt(int64(price)).FillBytes(make([]byte, 32)), nil case stakingAddr: + if len(request.Data) >= 4 && bytes.Equal(request.Data[:4], oracleCallData[:4]) { + return common.LeftPadBytes(oracleAddr.Bytes(), 32), nil + } return getStakeResponse(t, committed, potential), nil default: return nil, errors.New("unexpected call") From 64a12cf4dad310906ea830e50c8beb396844c79b Mon Sep 17 00:00:00 2001 From: sbackend Date: Wed, 26 Aug 2026 18:15:25 +0200 Subject: [PATCH 6/8] fix: coding style --- pkg/storageincentives/staking/contract.go | 4 ++-- pkg/storageincentives/staking/contract_test.go | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index 899c0a48bde..2a5baa28484 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -203,8 +203,8 @@ func calculateMinDeposit(potential, committed *big.Int, price uint32, height uin // User already has committed stake. // Commitment protection: required = committed * price * 2^height required := new(big.Int).SetUint64(uint64(price)) - required.Lsh(required, uint(height)) // * 2^height - required.Mul(required, committed) // * committed + required = new(big.Int).Lsh(required, uint(height)) // * 2^height + required = new(big.Int).Mul(required, committed) // * committed if gap := new(big.Int).Sub(required, potential); gap.Cmp(minAdd) > 0 { minAdd.Set(gap) } diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index 3039df9d857..4b0f03c2813 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -26,7 +26,6 @@ import ( var ( stakingContractABI = abiutil.MustParseABI(chaincfg.Testnet.StakingABI) - priceOracleAddress = common.HexToAddress("1111") ) const stakingHeight = uint8(0) From 4da949f3bca7584fad2ec35ca3d0b52ce4026082 Mon Sep 17 00:00:00 2001 From: sbackend Date: Sun, 30 Aug 2026 21:59:47 +0200 Subject: [PATCH 7/8] fix: address comments review --- pkg/storageincentives/staking/contract.go | 29 ++-- .../staking/contract_test.go | 156 ++++++++++++------ 2 files changed, 122 insertions(+), 63 deletions(-) diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index 2a5baa28484..b9775503948 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -166,7 +166,7 @@ func (c *contract) UpdateHeight(ctx context.Context) (common.Hash, bool, error) } func (c *contract) GetPotentialStake(ctx context.Context) (*big.Int, error) { - _, potential, err := c.getStake(ctx) + _, potential, _, err := c.getStake(ctx) if err != nil { return nil, fmt.Errorf("staking contract: failed to get stake: %w", err) } @@ -174,7 +174,7 @@ func (c *contract) GetPotentialStake(ctx context.Context) (*big.Int, error) { } func (c *contract) GetMinDeposit(ctx context.Context) (*big.Int, error) { - committed, potential, err := c.getStake(ctx) + committed, potential, stakeExists, err := c.getStake(ctx) if err != nil { return nil, fmt.Errorf("staking contract: failed to get stake: %w", err) } @@ -187,20 +187,20 @@ func (c *contract) GetMinDeposit(ctx context.Context) (*big.Int, error) { } } - return calculateMinDeposit(potential, committed, price, c.height), nil + return calculateMinDeposit(potential, committed, price, c.height, stakeExists), nil } // calculateMinDeposit returns the minimum additional deposit in PLUR that manageStake will accept according to contract. -func calculateMinDeposit(potential, committed *big.Int, price uint32, height uint8) *big.Int { +// stakeExists mirrors Solidity's _stakingSet != 0 (lastUpdatedBlockNumber != 0). +func calculateMinDeposit(potential, committed *big.Int, price uint32, height uint8, stakeExists bool) *big.Int { minAdd := big.NewInt(1) - // The contract applies the minimum stake floor only when creating a stake. - if potential.Sign() == 0 { - minAdd.Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height)) + // Contract: BelowMinimumStake when addAmount < MIN_STAKE * 2^height && _stakingSet == 0. + if !stakeExists { + minAdd = new(big.Int).Lsh(new(big.Int).Set(MinimumStakeAmount), uint(height)) } if price != 0 && committed.Sign() > 0 { - // User already has committed stake. // Commitment protection: required = committed * price * 2^height required := new(big.Int).SetUint64(uint64(price)) required = new(big.Int).Lsh(required, uint(height)) // * 2^height @@ -379,17 +379,17 @@ func (c *contract) sendManageStakeTransaction(ctx context.Context, stakedAmount return receipt, nil } -func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, err error) { +func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, stakeExists bool, err error) { callData, err := c.stakingContractABI.Pack("stakes", c.owner) if err != nil { - return nil, nil, err + return nil, nil, false, err } result, err := c.transactionService.Call(ctx, &transaction.TxRequest{ To: &c.stakingContractAddress, Data: callData, }) if err != nil { - return nil, nil, fmt.Errorf("get stakes: %w", err) + return nil, nil, false, fmt.Errorf("get stakes: %w", err) } // overlay bytes32, @@ -398,16 +398,17 @@ func (c *contract) getStake(ctx context.Context) (committed, potential *big.Int, // lastUpdatedBlockNumber uint256, results, err := c.stakingContractABI.Unpack("stakes", result) if err != nil { - return nil, nil, err + return nil, nil, false, err } if len(results) < 4 { - return nil, nil, ErrUnexpectedLength + return nil, nil, false, ErrUnexpectedLength } committed = abi.ConvertType(results[1], new(big.Int)).(*big.Int) potential = abi.ConvertType(results[2], new(big.Int)).(*big.Int) - return committed, potential, nil + lastUpdated := abi.ConvertType(results[3], new(big.Int)).(*big.Int) + return committed, potential, lastUpdated.Sign() != 0, nil } func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) { diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index 4b0f03c2813..b2160bc5019 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -37,68 +37,85 @@ func TestCalculateMinDeposit(t *testing.T) { committedAtMin := new(big.Int).Div(minStake, big.NewInt(1000)) // potential/price at price 1000 tests := []struct { - name string - potential *big.Int - committed *big.Int - price uint32 - height uint8 - want *big.Int + name string + potential *big.Int + committed *big.Int + price uint32 + height uint8 + stakeExists bool + want *big.Int }{ { - name: "first deposit height 0", - potential: big.NewInt(0), - committed: big.NewInt(0), - price: 1000, - height: 0, - want: minStake, + name: "first deposit height 0", + potential: big.NewInt(0), + committed: big.NewInt(0), + price: 1000, + height: 0, + stakeExists: false, + want: minStake, }, { - name: "first deposit height 1", - potential: big.NewInt(0), - committed: big.NewInt(0), - price: 1000, - height: 1, - want: new(big.Int).Mul(minStake, big.NewInt(2)), + name: "first deposit height 1", + potential: big.NewInt(0), + committed: big.NewInt(0), + price: 1000, + height: 1, + stakeExists: false, + want: new(big.Int).Mul(minStake, big.NewInt(2)), }, { - name: "subsequent with surplus is one plur", - potential: new(big.Int).Mul(minStake, big.NewInt(2)), - committed: committedAtMin, - price: 1000, - height: 0, - want: big.NewInt(1), + name: "subsequent with surplus is one plur", + potential: new(big.Int).Mul(minStake, big.NewInt(2)), + committed: committedAtMin, + price: 1000, + height: 0, + stakeExists: true, + want: big.NewInt(1), }, { - name: "exact cover is one plur", - potential: new(big.Int).Set(minStake), - committed: committedAtMin, - price: 1000, - height: 0, - want: big.NewInt(1), + name: "exact cover is one plur", + potential: new(big.Int).Set(minStake), + committed: committedAtMin, + price: 1000, + height: 0, + stakeExists: true, + want: big.NewInt(1), }, { - name: "price increase requires gap", - potential: new(big.Int).Set(minStake), - committed: committedAtMin, - price: 1001, - height: 0, - want: committedAtMin, + name: "price increase requires gap", + potential: new(big.Int).Set(minStake), + committed: committedAtMin, + price: 1001, + height: 0, + stakeExists: true, + want: committedAtMin, }, { - name: "height doubles required potential", - potential: new(big.Int).Mul(minStake, big.NewInt(2)), - committed: committedAtMin, - price: 1001, - height: 1, - want: new(big.Int).Mul(committedAtMin, big.NewInt(2)), + name: "height doubles required potential", + potential: new(big.Int).Mul(minStake, big.NewInt(2)), + committed: committedAtMin, + price: 1001, + height: 1, + stakeExists: true, + want: new(big.Int).Mul(committedAtMin, big.NewInt(2)), }, { - name: "existing slashed stake does not restore initial floor", - potential: new(big.Int).Div(minStake, big.NewInt(10)), - committed: committedAtMin, - price: 100, - height: 0, - want: big.NewInt(1), + name: "existing slashed stake does not restore initial floor", + potential: new(big.Int).Div(minStake, big.NewInt(10)), + committed: committedAtMin, + price: 100, + height: 0, + stakeExists: true, + want: big.NewInt(1), + }, + { + name: "initialized stake with zero potential does not restore initial floor", + potential: big.NewInt(0), + committed: big.NewInt(0), + price: 200, + height: 0, + stakeExists: true, + want: big.NewInt(1), }, } @@ -106,7 +123,7 @@ func TestCalculateMinDeposit(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := staking.CalculateMinDeposit(tc.potential, tc.committed, tc.price, tc.height) + got := staking.CalculateMinDeposit(tc.potential, tc.committed, tc.price, tc.height, tc.stakeExists) if got.Cmp(tc.want) != 0 { t.Fatalf("got %s, want %s", got, tc.want) } @@ -1481,6 +1498,36 @@ func TestGetMinDeposit(t *testing.T) { t.Fatalf("got %s, want %s", got, committed) } }) + + t.Run("initialized stake with zero potential", func(t *testing.T) { + t.Parallel() + + contract := staking.New( + owner, + stakingAddress, + stakingContractABI, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithCallFunc(func(ctx context.Context, request *transaction.TxRequest) (result []byte, err error) { + if *request.To == stakingAddress { + return getStakeResponseWithLastUpdated(t, big.NewInt(0), big.NewInt(0), big.NewInt(1000)), nil + } + return nil, errors.New("unexpected call") + }), + ), + nonce, + 0, + stakingHeight, + ) + + got, err := contract.GetMinDeposit(ctx) + if err != nil { + t.Fatal(err) + } + if got.Cmp(big.NewInt(1)) != 0 { + t.Fatalf("got %s, want 1", got) + } + }) } func TestGetWithdrawableStake(t *testing.T) { @@ -2207,10 +2254,21 @@ func newStakeCallFunc( func getStakeResponse(t *testing.T, committed, potential *big.Int) []byte { t.Helper() + lastUpdated := big.NewInt(0) + if committed.Sign() > 0 || potential.Sign() > 0 { + lastUpdated = big.NewInt(1) + } + return getStakeResponseWithLastUpdated(t, committed, potential, lastUpdated) +} + +func getStakeResponseWithLastUpdated(t *testing.T, committed, potential, lastUpdated *big.Int) []byte { + t.Helper() + ret := make([]byte, 32*5) copy(ret, swarm.RandAddress(t).Bytes()) copy(ret[32:], committed.FillBytes(make([]byte, 32))) copy(ret[64:], potential.FillBytes(make([]byte, 32))) + copy(ret[96:], lastUpdated.FillBytes(make([]byte, 32))) return ret } From b68a0b09f7b36d19a9c9cff75d64fc4e99ecb26d Mon Sep 17 00:00:00 2001 From: sbackend Date: Wed, 2 Sep 2026 14:03:00 +0200 Subject: [PATCH 8/8] fix: address review comments --- openapi/Swarm.yaml | 2 +- pkg/storageincentives/staking/contract.go | 5 ++ .../staking/contract_test.go | 50 +++++++++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 751efdd5711..978e0500209 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -2571,7 +2571,7 @@ paths: "/stake": get: summary: Get the staked amount. - 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. + description: This endpoint fetches the total staked amount and the minimum additional deposit from the blockchain. The first deposit is at least 10 BZZ times 2^height. Subsequent deposits are at least 1 PLUR, or more if the price oracle has increased since the last deposit. tags: - Staking responses: diff --git a/pkg/storageincentives/staking/contract.go b/pkg/storageincentives/staking/contract.go index b9775503948..4abd47cecbc 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math/big" + "sync" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" @@ -74,6 +75,7 @@ type contract struct { stakingContractAddress common.Address stakingContractABI abi.ABI bzzTokenAddress common.Address + mtx sync.Mutex priceOracleAddress common.Address transactionService transaction.Service overlayNonce common.Hash @@ -449,6 +451,9 @@ func (c *contract) getCurrentPrice(ctx context.Context) (uint32, error) { // getPriceOracleAddress resolves the price oracle from the staking contract on first use. func (c *contract) getPriceOracleAddress(ctx context.Context) (common.Address, error) { + c.mtx.Lock() + defer c.mtx.Unlock() + if (c.priceOracleAddress != common.Address{}) { return c.priceOracleAddress, nil } diff --git a/pkg/storageincentives/staking/contract_test.go b/pkg/storageincentives/staking/contract_test.go index b2160bc5019..05c59b56fdc 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -11,11 +11,11 @@ import ( "fmt" "math/big" "strings" + "sync" "testing" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - chaincfg "github.com/ethersphere/bee/v2/pkg/config" "github.com/ethersphere/bee/v2/pkg/storageincentives/staking" "github.com/ethersphere/bee/v2/pkg/swarm" @@ -24,9 +24,7 @@ import ( "github.com/ethersphere/bee/v2/pkg/util/abiutil" ) -var ( - stakingContractABI = abiutil.MustParseABI(chaincfg.Testnet.StakingABI) -) +var stakingContractABI = abiutil.MustParseABI(chaincfg.Testnet.StakingABI) const stakingHeight = uint8(0) @@ -1528,6 +1526,50 @@ func TestGetMinDeposit(t *testing.T) { t.Fatalf("got %s, want 1", got) } }) + + t.Run("concurrent oracle resolution", func(t *testing.T) { + t.Parallel() + + potential := new(big.Int).Set(staking.MinimumStakeAmount) + committed := new(big.Int).Div(potential, big.NewInt(1000)) + price := uint32(1001) + + contract := staking.New( + owner, + stakingAddress, + stakingContractABI, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithCallFunc(newStakeCallFunc(t, stakingAddress, oracleAddr, bzzTokenAddress, committed, potential, potential, price)), + ), + nonce, + 0, + stakingHeight, + ) + + const n = 32 + errCh := make(chan error, n) + var wg sync.WaitGroup + wg.Add(n) + for range n { + go func() { + defer wg.Done() + got, err := contract.GetMinDeposit(ctx) + if err != nil { + errCh <- err + return + } + if got.Cmp(committed) != 0 { + errCh <- fmt.Errorf("got %s, want %s", got, committed) + } + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatal(err) + } + }) } func TestGetWithdrawableStake(t *testing.T) {