diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index c320410b4ee..978e0500209 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: - version: 8.1.1 + version: 8.2.0 title: Bee API description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management" @@ -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: @@ -2567,7 +2571,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 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/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index ffcbbac3b8e..a1b9718eb3b 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -681,6 +681,18 @@ components: properties: stakedAmount: $ref: "#/components/schemas/BigInt" + minimumDeposit: + $ref: "#/components/schemas/BigInt" + + StakeDepositErrorResponse: + type: object + properties: + code: + type: integer + message: + type: string + minimumDeposit: + $ref: "#/components/schemas/BigInt" GetWithdrawableResponse: type: object 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 c9eb32219e4..99357ed4281 100644 --- a/pkg/api/staking.go +++ b/pkg/api/staking.go @@ -9,11 +9,11 @@ import ( "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 +31,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 { @@ -41,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() @@ -54,10 +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) { - logger.Debug("insufficient stake amount", "minimum_stake", staking.MinimumStakeAmount, "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, "insufficient stake amount") + jsonhttp.BadRequest(w, stakeDepositErrorResponse{ + Code: http.StatusBadRequest, + Message: "insufficient stake amount", + MinimumDeposit: bigint.Wrap(minErr.Minimum), + }) return } if errors.Is(err, staking.ErrNotImplemented) { @@ -105,7 +117,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..febd9823bba 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" @@ -43,18 +43,22 @@ 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.Run("with insufficient amount reports minimum", func(t *testing.T) { t.Parallel() - invalidMinStake := big.NewInt(0).String() + minDeposit := big.NewInt(123) contract := stakingContractMock.New( stakingContractMock.WithDepositStake(func(ctx context.Context, stakedAmount *big.Int) (common.Hash, error) { - return common.Hash{}, staking.ErrInsufficientStakeAmount + return common.Hash{}, &staking.MinDepositError{Minimum: minDeposit} }), ) 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.Request(t, ts, http.MethodPost, depositStake("1"), http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(&api.StakeDepositErrorResponse{ + Code: http.StatusBadRequest, + Message: "insufficient stake amount", + MinimumDeposit: bigint.Wrap(minDeposit), + })) }) t.Run("out of funds", func(t *testing.T) { @@ -134,7 +138,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 +156,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..4abd47cecbc 100644 --- a/pkg/storageincentives/staking/contract.go +++ b/pkg/storageincentives/staking/contract.go @@ -9,20 +9,23 @@ import ( "errors" "fmt" "math/big" + "sync" "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 +40,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) @@ -57,6 +75,8 @@ type contract struct { stakingContractAddress common.Address stakingContractABI abi.ABI bzzTokenAddress common.Address + mtx sync.Mutex + priceOracleAddress common.Address transactionService transaction.Service overlayNonce common.Hash gasLimit uint64 @@ -86,19 +106,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, stakeExists), nil +} + +// calculateMinDeposit returns the minimum additional deposit in PLUR that manageStake will accept according to contract. +// 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) + + // 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 { + // Commitment protection: required = committed * price * 2^height + required := new(big.Int).SetUint64(uint64(price)) + 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) + } + } + + return minAdd } func (c *contract) GetWithdrawableStake(ctx context.Context) (*big.Int, error) { @@ -327,17 +381,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, stakeExists bool, err error) { callData, err := c.stakingContractABI.Pack("stakes", c.owner) if err != nil { - return 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, fmt.Errorf("get potential stake: %w", err) + return nil, nil, false, fmt.Errorf("get stakes: %w", err) } // overlay bytes32, @@ -346,14 +400,95 @@ 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, false, err } if len(results) < 4 { - return 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) + 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) { + 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: &oracleAddress, + 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 price, nil +} + +// 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 + } + + 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") } - return abi.ConvertType(results[2], new(big.Int)).(*big.Int), nil + c.priceOracleAddress = oracleAddress + return oracleAddress, 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..05c59b56fdc 100644 --- a/pkg/storageincentives/staking/contract_test.go +++ b/pkg/storageincentives/staking/contract_test.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" "strings" + "sync" "testing" "github.com/ethereum/go-ethereum/common" @@ -27,6 +28,107 @@ 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 + stakeExists bool + want *big.Int + }{ + { + 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, + 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, + stakeExists: true, + 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, + stakeExists: true, + want: committedAtMin, + }, + { + 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, + 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), + }, + } + + 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, tc.stakeExists) + if got.Cmp(tc.want) != 0 { + t.Fatalf("got %s, want %s", got, tc.want) + } + }) + } +} + func TestIsOverlayFrozen(t *testing.T) { t.Parallel() @@ -385,6 +487,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 +1428,150 @@ 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) + } + }) + + 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) + } + }) + + 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) { t.Parallel() @@ -1926,12 +2263,59 @@ 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 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") + } + } +} + +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+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))) + copy(ret[96:], lastUpdated.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