diff --git a/openapi/SwarmDebug.yaml b/openapi/SwarmDebug.yaml index ecec480b67e..afd19bc3436 100644 --- a/openapi/SwarmDebug.yaml +++ b/openapi/SwarmDebug.yaml @@ -871,3 +871,40 @@ paths: $ref: "SwarmCommon.yaml#/components/responses/500" default: description: Default response + + "/stamps/topup/{id}/{amount}": + patch: + summary: Top up an existing postage batch. + description: Be aware, this endpoint creates on-chain transactions and transfers BZZ from the node's Ethereum account and hence directly manipulates the wallet balance! + tags: + - Postage Stamps + parameters: + - in: path + name: id + schema: + $ref: "SwarmCommon.yaml#/components/schemas/BatchID" + required: true + description: Batch ID to top up + - in: path + name: amount + schema: + type: integer + required: true + description: Amount of BZZ per chunk to top up to an existing postage batch. + responses: + "202": + description: Returns the postage batch ID that was topped up + content: + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/BatchIDResponse" + "400": + $ref: "SwarmCommon.yaml#/components/responses/400" + "429": + $ref: "SwarmCommon.yaml#/components/responses/429" + "402": + $ref: "SwarmCommon.yaml#/components/responses/402" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + default: + description: Default response diff --git a/pkg/debugapi/postage.go b/pkg/debugapi/postage.go index 958bbc51713..e9ea902485f 100644 --- a/pkg/debugapi/postage.go +++ b/pkg/debugapi/postage.go @@ -321,3 +321,63 @@ func (s *Service) estimateBatchTTL(id []byte) (int64, error) { return ttl.Int64(), nil } + +func (s *Service) postageTopUpHandler(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["id"] + if idStr == "" || len(idStr) != 64 { + s.logger.Error("topup batch: invalid batchID") + jsonhttp.BadRequest(w, "invalid batchID") + return + } + id, err := hex.DecodeString(idStr) + if err != nil { + s.logger.Debugf("topup batch: invalid batchID: %v", err) + s.logger.Error("topup batch: invalid batchID") + jsonhttp.BadRequest(w, "invalid batchID") + return + } + + amount, ok := big.NewInt(0).SetString(mux.Vars(r)["amount"], 10) + if !ok { + s.logger.Error("topup batch: invalid amount") + jsonhttp.BadRequest(w, "invalid postage amount") + return + } + + ctx := r.Context() + if price, ok := r.Header[gasPriceHeader]; ok { + p, ok := big.NewInt(0).SetString(price[0], 10) + if !ok { + s.logger.Error("topup batch: bad gas price") + jsonhttp.BadRequest(w, errBadGasPrice) + return + } + ctx = sctx.SetGasPrice(ctx, p) + } + + if !s.postageCreateSem.TryAcquire(1) { + s.logger.Debug("topup batch: simultaneous on-chain operations not supported") + s.logger.Error("topup batch: simultaneous on-chain operations not supported") + jsonhttp.TooManyRequests(w, "simultaneous on-chain operations not supported") + return + } + defer s.postageCreateSem.Release(1) + + err = s.postageContract.TopUpBatch(ctx, id, amount) + if err != nil { + if errors.Is(err, postagecontract.ErrInsufficientFunds) { + s.logger.Debugf("topup batch: out of funds: %v", err) + s.logger.Error("topup batch: out of funds") + jsonhttp.PaymentRequired(w, "out of funds") + return + } + s.logger.Debugf("topup batch: failed to create: %v", err) + s.logger.Error("topup batch: failed to create") + jsonhttp.InternalServerError(w, "cannot topup batch") + return + } + + jsonhttp.Accepted(w, &postageCreateResponse{ + BatchID: id, + }) +} diff --git a/pkg/debugapi/postage_test.go b/pkg/debugapi/postage_test.go index 65e75d297db..a20947c1eae 100644 --- a/pkg/debugapi/postage_test.go +++ b/pkg/debugapi/postage_test.go @@ -5,6 +5,7 @@ package debugapi_test import ( + "bytes" "context" "encoding/hex" "errors" @@ -368,3 +369,120 @@ func TestChainState(t *testing.T) { ) }) } + +func TestPostageTopUpStamp(t *testing.T) { + topupAmount := int64(1000) + topupBatch := func(id string, amount int64) string { + return fmt.Sprintf("/stamps/topup/%s/%d", id, amount) + } + + t.Run("ok", func(t *testing.T) { + contract := contractMock.New( + contractMock.WithTopUpBatchFunc(func(ctx context.Context, id []byte, ib *big.Int) error { + if !bytes.Equal(id, batchOk) { + return errors.New("incorrect batch ID in call") + } + if ib.Cmp(big.NewInt(topupAmount)) != 0 { + return fmt.Errorf("called with wrong topup amount. wanted %d, got %d", topupAmount, ib) + } + return nil + }), + ) + ts := newTestServer(t, testServerOptions{ + PostageContract: contract, + }) + + jsonhttptest.Request(t, ts.Client, http.MethodPatch, topupBatch(batchOkStr, topupAmount), http.StatusAccepted, + jsonhttptest.WithExpectedJSONResponse(&debugapi.PostageCreateResponse{ + BatchID: batchOk, + }), + ) + }) + + t.Run("with-custom-gas", func(t *testing.T) { + contract := contractMock.New( + contractMock.WithTopUpBatchFunc(func(ctx context.Context, id []byte, ib *big.Int) error { + if !bytes.Equal(id, batchOk) { + return errors.New("incorrect batch ID in call") + } + if ib.Cmp(big.NewInt(topupAmount)) != 0 { + return fmt.Errorf("called with wrong topup amount. wanted %d, got %d", topupAmount, ib) + } + if sctx.GetGasPrice(ctx).Cmp(big.NewInt(10000)) != 0 { + return fmt.Errorf("called with wrong gas price. wanted %d, got %d", 10000, sctx.GetGasPrice(ctx)) + } + return nil + }), + ) + ts := newTestServer(t, testServerOptions{ + PostageContract: contract, + }) + + jsonhttptest.Request(t, ts.Client, http.MethodPatch, topupBatch(batchOkStr, topupAmount), http.StatusAccepted, + jsonhttptest.WithRequestHeader("Gas-Price", "10000"), + jsonhttptest.WithExpectedJSONResponse(&debugapi.PostageCreateResponse{ + BatchID: batchOk, + }), + ) + }) + + t.Run("with-error", func(t *testing.T) { + contract := contractMock.New( + contractMock.WithTopUpBatchFunc(func(ctx context.Context, id []byte, ib *big.Int) error { + return errors.New("err") + }), + ) + ts := newTestServer(t, testServerOptions{ + PostageContract: contract, + }) + + jsonhttptest.Request(t, ts.Client, http.MethodPatch, topupBatch(batchOkStr, topupAmount), http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{ + Code: http.StatusInternalServerError, + Message: "cannot topup batch", + }), + ) + }) + + t.Run("out-of-funds", func(t *testing.T) { + contract := contractMock.New( + contractMock.WithTopUpBatchFunc(func(ctx context.Context, id []byte, ib *big.Int) error { + return postagecontract.ErrInsufficientFunds + }), + ) + ts := newTestServer(t, testServerOptions{ + PostageContract: contract, + }) + + jsonhttptest.Request(t, ts.Client, http.MethodPatch, topupBatch(batchOkStr, topupAmount), http.StatusPaymentRequired, + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{ + Code: http.StatusPaymentRequired, + Message: "out of funds", + }), + ) + }) + + t.Run("invalid batch id", func(t *testing.T) { + ts := newTestServer(t, testServerOptions{}) + + jsonhttptest.Request(t, ts.Client, http.MethodPatch, "/stamps/topup/abcd/2", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid batchID", + }), + ) + }) + + t.Run("invalid amount", func(t *testing.T) { + ts := newTestServer(t, testServerOptions{}) + + wrongURL := fmt.Sprintf("/stamps/topup/%s/amount", batchOkStr) + + jsonhttptest.Request(t, ts.Client, http.MethodPatch, wrongURL, http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(&jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid postage amount", + }), + ) + }) +} diff --git a/pkg/debugapi/router.go b/pkg/debugapi/router.go index b9b66e9fd12..f8ba97db886 100644 --- a/pkg/debugapi/router.go +++ b/pkg/debugapi/router.go @@ -210,6 +210,12 @@ func (s *Service) newRouter() *mux.Router { })), ) + router.Handle("/stamps/topup/{id}/{amount}", web.ChainHandlers( + web.FinalHandler(jsonhttp.MethodHandler{ + "PATCH": http.HandlerFunc(s.postageTopUpHandler), + })), + ) + return router } diff --git a/pkg/node/devnode.go b/pkg/node/devnode.go index a82043432bf..b871c40a1b1 100644 --- a/pkg/node/devnode.go +++ b/pkg/node/devnode.go @@ -200,28 +200,52 @@ func NewDevBee(logger logging.Logger, o *DevOptions) (b *DevBee, err error) { } post := mockPost.New() - postageContract := mockPostContract.New(mockPostContract.WithCreateBatchFunc( - func(ctx context.Context, initialBalance *big.Int, depth uint8, immutable bool, label string) ([]byte, error) { - id := postagetesting.MustNewID() - b := &postage.Batch{ - ID: id, - Owner: overlayEthAddress.Bytes(), - Value: big.NewInt(0), - Depth: depth, - Immutable: immutable, - } - - err := batchStore.Put(b, initialBalance, depth) - if err != nil { - return nil, err - } - - stampIssuer := postage.NewStampIssuer(label, string(overlayEthAddress.Bytes()), id, initialBalance, depth, 0, 0, immutable) - post.Add(stampIssuer) - - return id, nil - }, - )) + postageContract := mockPostContract.New( + mockPostContract.WithCreateBatchFunc( + func(ctx context.Context, initialBalance *big.Int, depth uint8, immutable bool, label string) ([]byte, error) { + id := postagetesting.MustNewID() + b := &postage.Batch{ + ID: id, + Owner: overlayEthAddress.Bytes(), + Value: big.NewInt(0), + Depth: depth, + Immutable: immutable, + } + + totalAmount := big.NewInt(0).Mul(initialBalance, big.NewInt(int64(1< 0 && ev.Topics[0] == batchTopUpTopic { + return nil + } + } + + return ErrBatchTopUp +} + type batchCreatedEvent struct { BatchId [32]byte TotalAmount *big.Int diff --git a/pkg/postage/postagecontract/contract_test.go b/pkg/postage/postagecontract/contract_test.go index d40173461f8..a2c12683e93 100644 --- a/pkg/postage/postagecontract/contract_test.go +++ b/pkg/postage/postagecontract/contract_test.go @@ -14,8 +14,11 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethersphere/bee/pkg/postage" + postagestoreMock "github.com/ethersphere/bee/pkg/postage/batchstore/mock" postageMock "github.com/ethersphere/bee/pkg/postage/mock" "github.com/ethersphere/bee/pkg/postage/postagecontract" + postagetesting "github.com/ethersphere/bee/pkg/postage/testing" "github.com/ethersphere/bee/pkg/transaction" transactionMock "github.com/ethersphere/bee/pkg/transaction/mock" ) @@ -85,6 +88,7 @@ func TestCreateBatch(t *testing.T) { }), ), postageMock, + postagestoreMock.New(), ) returnedID, err := contract.CreateBatch(ctx, initialBalance, depth, false, label) @@ -115,6 +119,7 @@ func TestCreateBatch(t *testing.T) { bzzTokenAddress, transactionMock.New(), postageMock.New(), + postagestoreMock.New(), ) _, err := contract.CreateBatch(ctx, initialBalance, depth, false, label) @@ -140,6 +145,7 @@ func TestCreateBatch(t *testing.T) { }), ), postageMock.New(), + postagestoreMock.New(), ) _, err := contract.CreateBatch(ctx, initialBalance, depth, false, label) @@ -192,3 +198,157 @@ func TestLookupERC20Address(t *testing.T) { t.Fatalf("got wrong erc20 address. wanted %v, got %v", erc20Address, addr) } } + +func TestTopUpBatch(t *testing.T) { + defer func(b uint8) { + postagecontract.BucketDepth = b + }(postagecontract.BucketDepth) + postagecontract.BucketDepth = 9 + owner := common.HexToAddress("abcd") + postageStampAddress := common.HexToAddress("ffff") + bzzTokenAddress := common.HexToAddress("eeee") + ctx := context.Background() + topupBalance := big.NewInt(100) + + t.Run("ok", func(t *testing.T) { + + totalAmount := big.NewInt(102400) + txHashApprove := common.HexToHash("abb0") + txHashTopup := common.HexToHash("c3a7") + batch := postagetesting.MustNewBatch(postagetesting.WithOwner(owner.Bytes())) + batch.Depth = uint8(10) + batch.BucketDepth = uint8(9) + postageMock := postageMock.New(postageMock.WithIssuer(postage.NewStampIssuer( + "label", + "keyID", + batch.ID, + batch.Value, + batch.Depth, + batch.BucketDepth, + batch.Start, + batch.Immutable, + ))) + batchStoreMock := postagestoreMock.New(postagestoreMock.WithBatch(batch)) + + expectedCallData, err := postagecontract.PostageStampABI.Pack("topUp", common.BytesToHash(batch.ID), topupBalance) + if err != nil { + t.Fatal(err) + } + + contract := postagecontract.New( + owner, + postageStampAddress, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithSendFunc(func(ctx context.Context, request *transaction.TxRequest) (txHash common.Hash, err error) { + if *request.To == bzzTokenAddress { + return txHashApprove, nil + } else if *request.To == postageStampAddress { + if !bytes.Equal(expectedCallData[:64], request.Data[:64]) { + return common.Hash{}, fmt.Errorf("got wrong call data. wanted %x, got %x", expectedCallData, request.Data) + } + return txHashTopup, 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 == txHashApprove { + return &types.Receipt{ + Status: 1, + }, nil + } else if txHash == txHashTopup { + return &types.Receipt{ + Logs: []*types.Log{ + newTopUpEvent(postageStampAddress, batch), + }, + Status: 1, + }, nil + } + return nil, errors.New("unknown tx hash") + }), + transactionMock.WithCallFunc(func(ctx context.Context, request *transaction.TxRequest) (result []byte, err error) { + if *request.To == bzzTokenAddress { + return totalAmount.FillBytes(make([]byte, 32)), nil + } + return nil, errors.New("unexpected call") + }), + ), + postageMock, + batchStoreMock, + ) + + err = contract.TopUpBatch(ctx, batch.ID, topupBalance) + if err != nil { + t.Fatal(err) + } + + si, err := postageMock.GetStampIssuer(batch.ID) + if err != nil { + t.Fatal(err) + } + + if si == nil { + t.Fatal("stamp issuer not set") + } + }) + + t.Run("batch doesnt exist", func(t *testing.T) { + errNotFound := errors.New("not found") + contract := postagecontract.New( + owner, + postageStampAddress, + bzzTokenAddress, + transactionMock.New(), + postageMock.New(), + postagestoreMock.New(postagestoreMock.WithGetErr(errNotFound, 0)), + ) + + err := contract.TopUpBatch(ctx, postagetesting.MustNewID(), topupBalance) + if !errors.Is(err, errNotFound) { + t.Fatal("expected error on topup of non existent batch") + } + }) + + t.Run("insufficient funds", func(t *testing.T) { + totalAmount := big.NewInt(102399) + batch := postagetesting.MustNewBatch(postagetesting.WithOwner(owner.Bytes())) + batchStoreMock := postagestoreMock.New(postagestoreMock.WithBatch(batch)) + + contract := postagecontract.New( + owner, + postageStampAddress, + bzzTokenAddress, + transactionMock.New( + transactionMock.WithCallFunc(func(ctx context.Context, request *transaction.TxRequest) (result []byte, err error) { + if *request.To == bzzTokenAddress { + return big.NewInt(0).Sub(totalAmount, big.NewInt(1)).FillBytes(make([]byte, 32)), nil + } + return nil, errors.New("unexpected call") + }), + ), + postageMock.New(), + batchStoreMock, + ) + + err := contract.TopUpBatch(ctx, batch.ID, topupBalance) + if !errors.Is(err, postagecontract.ErrInsufficientFunds) { + t.Fatalf("expected error %v. got %v", postagecontract.ErrInsufficientFunds, err) + } + }) +} + +func newTopUpEvent(postageContractAddress common.Address, batch *postage.Batch) *types.Log { + b, err := postagecontract.PostageStampABI.Events["BatchTopUp"].Inputs.NonIndexed().Pack( + big.NewInt(0), + big.NewInt(0), + ) + if err != nil { + panic(err) + } + return &types.Log{ + Address: postageContractAddress, + Data: b, + Topics: []common.Hash{postagecontract.BatchTopUpTopic, common.BytesToHash(batch.ID)}, + BlockNumber: batch.Start + 1, + } +} diff --git a/pkg/postage/postagecontract/export_test.go b/pkg/postage/postagecontract/export_test.go index 76c7d3b8dc7..c14e06314ec 100644 --- a/pkg/postage/postagecontract/export_test.go +++ b/pkg/postage/postagecontract/export_test.go @@ -7,4 +7,5 @@ package postagecontract var ( PostageStampABI = postageStampABI BatchCreatedTopic = batchCreatedTopic + BatchTopUpTopic = batchTopUpTopic ) diff --git a/pkg/postage/postagecontract/mock/contract.go b/pkg/postage/postagecontract/mock/contract.go index 45380e5e382..b0b8b8dffa9 100644 --- a/pkg/postage/postagecontract/mock/contract.go +++ b/pkg/postage/postagecontract/mock/contract.go @@ -13,12 +13,17 @@ import ( type contractMock struct { createBatch func(ctx context.Context, initialBalance *big.Int, depth uint8, immutable bool, label string) ([]byte, error) + topupBatch func(ctx context.Context, id []byte, amount *big.Int) error } func (c *contractMock) CreateBatch(ctx context.Context, initialBalance *big.Int, depth uint8, immutable bool, label string) ([]byte, error) { return c.createBatch(ctx, initialBalance, depth, immutable, label) } +func (c *contractMock) TopUpBatch(ctx context.Context, batchID []byte, amount *big.Int) error { + return c.topupBatch(ctx, batchID, amount) +} + // Option is a an option passed to New type Option func(*contractMock) @@ -38,3 +43,9 @@ func WithCreateBatchFunc(f func(ctx context.Context, initialBalance *big.Int, de m.createBatch = f } } + +func WithTopUpBatchFunc(f func(ctx context.Context, batchID []byte, amount *big.Int) error) Option { + return func(m *contractMock) { + m.topupBatch = f + } +} diff --git a/pkg/postage/service.go b/pkg/postage/service.go index 1ddee8f3ff2..aa0cbcc8e30 100644 --- a/pkg/postage/service.go +++ b/pkg/postage/service.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math/big" "sync" "github.com/ethersphere/bee/pkg/storage" @@ -34,7 +35,7 @@ type Service interface { StampIssuers() []*StampIssuer GetStampIssuer([]byte) (*StampIssuer, error) IssuerUsable(*StampIssuer) bool - BatchCreationListener + BatchEventListener io.Closer } @@ -87,10 +88,10 @@ func (ps *service) Add(st *StampIssuer) { ps.issuers = append(ps.issuers, st) } -// Handle implements the BatchCreationListener interface. This is fired on receiving +// HandleCreate implements the BatchEventListener interface. This is fired on receiving // a batch creation event from the blockchain listener to ensure that if a stamp // issuer was not created initially, we will create it here. -func (ps *service) Handle(b *Batch) { +func (ps *service) HandleCreate(b *Batch) { ps.Add(NewStampIssuer( "recovered", string(b.Owner), @@ -103,6 +104,22 @@ func (ps *service) Handle(b *Batch) { )) } +// HandleTopUp implements the BatchEventListener interface. This is fired on receiving +// a batch topup event from the blockchain to update stampissuer details +func (ps *service) HandleTopUp(batchID []byte, newValue *big.Int) { + ps.lock.Lock() + defer ps.lock.Unlock() + + for _, v := range ps.issuers { + if bytes.Equal(batchID, v.data.BatchID) { + if newValue.Cmp(v.data.BatchAmount) > 0 { + v.data.BatchAmount = newValue + } + return + } + } +} + // StampIssuers returns the currently active stamp issuers. func (ps *service) StampIssuers() []*StampIssuer { ps.lock.Lock() diff --git a/pkg/postage/service_test.go b/pkg/postage/service_test.go index 2b2c167870e..63e2f75aa6c 100644 --- a/pkg/postage/service_test.go +++ b/pkg/postage/service_test.go @@ -83,9 +83,6 @@ func TestGetStampIssuer(t *testing.T) { } ps.Add(postage.NewStampIssuer(string(id), "", id, big.NewInt(3), 16, 8, validBlockNumber+shift, true)) } - b := postagetesting.MustNewBatch() - b.Start = validBlockNumber - ps.Handle(b) t.Run("found", func(t *testing.T) { for _, id := range ids[1:4] { st, err := ps.GetStampIssuer(id) @@ -112,6 +109,9 @@ func TestGetStampIssuer(t *testing.T) { } }) t.Run("recovered", func(t *testing.T) { + b := postagetesting.MustNewBatch() + b.Start = validBlockNumber + ps.HandleCreate(b) st, err := ps.GetStampIssuer(b.ID) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -120,4 +120,14 @@ func TestGetStampIssuer(t *testing.T) { t.Fatal("wrong issuer returned") } }) + t.Run("topup", func(t *testing.T) { + ps.HandleTopUp(ids[1], big.NewInt(10)) + _, err := ps.GetStampIssuer(ids[1]) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ps.StampIssuers()[0].Amount().Cmp(big.NewInt(10)) != 0 { + t.Fatalf("expected amount %d got %d", 10, ps.StampIssuers()[0].Amount().Int64()) + } + }) }