diff --git a/.github/workflows/beekeeper.yml b/.github/workflows/beekeeper.yml index b785ebf2f23..da44ec4d14a 100644 --- a/.github/workflows/beekeeper.yml +++ b/.github/workflows/beekeeper.yml @@ -162,6 +162,9 @@ jobs: - name: Test staking id: stake run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks ci-stake + - name: Test withdraw + id: withdraw + run: timeout ${TIMEOUT} bash -c 'until beekeeper check --cluster-name local-dns --checks ci-withdraw; do echo "waiting for withdraw..."; sleep .3; done' - name: Test redundancy id: redundancy run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks ci-redundancy diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go index 8b5e6330f18..9ca1bb2d90d 100644 --- a/cmd/bee/cmd/cmd.go +++ b/cmd/bee/cmd/cmd.go @@ -22,70 +22,71 @@ import ( ) const ( - optionNameDataDir = "data-dir" - optionNameCacheCapacity = "cache-capacity" - optionNameDBOpenFilesLimit = "db-open-files-limit" - optionNameDBBlockCacheCapacity = "db-block-cache-capacity" - optionNameDBWriteBufferSize = "db-write-buffer-size" - optionNameDBDisableSeeksCompaction = "db-disable-seeks-compaction" - optionNamePassword = "password" - optionNamePasswordFile = "password-file" - optionNameAPIAddr = "api-addr" - optionNameP2PAddr = "p2p-addr" - optionNameNATAddr = "nat-addr" - optionNameP2PWSEnable = "p2p-ws-enable" - optionNameDebugAPIEnable = "debug-api-enable" - optionNameDebugAPIAddr = "debug-api-addr" - optionNameBootnodes = "bootnode" - optionNameNetworkID = "network-id" - optionWelcomeMessage = "welcome-message" - optionCORSAllowedOrigins = "cors-allowed-origins" - optionNameTracingEnabled = "tracing-enable" - optionNameTracingEndpoint = "tracing-endpoint" - optionNameTracingHost = "tracing-host" - optionNameTracingPort = "tracing-port" - optionNameTracingServiceName = "tracing-service-name" - optionNameVerbosity = "verbosity" - optionNamePaymentThreshold = "payment-threshold" - optionNamePaymentTolerance = "payment-tolerance-percent" - optionNamePaymentEarly = "payment-early-percent" - optionNameResolverEndpoints = "resolver-options" - optionNameBootnodeMode = "bootnode-mode" - optionNameClefSignerEnable = "clef-signer-enable" - optionNameClefSignerEndpoint = "clef-signer-endpoint" - optionNameClefSignerEthereumAddress = "clef-signer-ethereum-address" - optionNameSwapEndpoint = "swap-endpoint" // deprecated: use rpc endpoint instead - optionNameBlockchainRpcEndpoint = "blockchain-rpc-endpoint" - optionNameSwapFactoryAddress = "swap-factory-address" - optionNameSwapInitialDeposit = "swap-initial-deposit" - optionNameSwapEnable = "swap-enable" - optionNameChequebookEnable = "chequebook-enable" - optionNameSwapDeploymentGasPrice = "swap-deployment-gas-price" - optionNameFullNode = "full-node" - optionNamePostageContractAddress = "postage-stamp-address" - optionNamePostageContractStartBlock = "postage-stamp-start-block" - optionNamePriceOracleAddress = "price-oracle-address" - optionNameRedistributionAddress = "redistribution-address" - optionNameStakingAddress = "staking-address" - optionNameBlockTime = "block-time" - optionWarmUpTime = "warmup-time" - optionNameMainNet = "mainnet" - optionNameRetrievalCaching = "cache-retrieval" - optionNameDevReserveCapacity = "dev-reserve-capacity" - optionNameResync = "resync" - optionNamePProfBlock = "pprof-profile" - optionNamePProfMutex = "pprof-mutex" - optionNameStaticNodes = "static-nodes" - optionNameAllowPrivateCIDRs = "allow-private-cidrs" - optionNameSleepAfter = "sleep-after" - optionNameRestrictedAPI = "restricted" - optionNameTokenEncryptionKey = "token-encryption-key" - optionNameAdminPasswordHash = "admin-password" - optionNameUsePostageSnapshot = "use-postage-snapshot" - optionNameStorageIncentivesEnable = "storage-incentives-enable" - optionNameStateStoreCacheCapacity = "statestore-cache-capacity" - optionNameTargetNeighborhood = "target-neighborhood" - optionNameNeighborhoodSuggester = "neighborhood-suggester" + optionNameDataDir = "data-dir" + optionNameCacheCapacity = "cache-capacity" + optionNameDBOpenFilesLimit = "db-open-files-limit" + optionNameDBBlockCacheCapacity = "db-block-cache-capacity" + optionNameDBWriteBufferSize = "db-write-buffer-size" + optionNameDBDisableSeeksCompaction = "db-disable-seeks-compaction" + optionNamePassword = "password" + optionNamePasswordFile = "password-file" + optionNameAPIAddr = "api-addr" + optionNameP2PAddr = "p2p-addr" + optionNameNATAddr = "nat-addr" + optionNameP2PWSEnable = "p2p-ws-enable" + optionNameDebugAPIEnable = "debug-api-enable" + optionNameDebugAPIAddr = "debug-api-addr" + optionNameBootnodes = "bootnode" + optionNameNetworkID = "network-id" + optionWelcomeMessage = "welcome-message" + optionCORSAllowedOrigins = "cors-allowed-origins" + optionNameTracingEnabled = "tracing-enable" + optionNameTracingEndpoint = "tracing-endpoint" + optionNameTracingHost = "tracing-host" + optionNameTracingPort = "tracing-port" + optionNameTracingServiceName = "tracing-service-name" + optionNameVerbosity = "verbosity" + optionNamePaymentThreshold = "payment-threshold" + optionNamePaymentTolerance = "payment-tolerance-percent" + optionNamePaymentEarly = "payment-early-percent" + optionNameResolverEndpoints = "resolver-options" + optionNameBootnodeMode = "bootnode-mode" + optionNameClefSignerEnable = "clef-signer-enable" + optionNameClefSignerEndpoint = "clef-signer-endpoint" + optionNameClefSignerEthereumAddress = "clef-signer-ethereum-address" + optionNameSwapEndpoint = "swap-endpoint" // deprecated: use rpc endpoint instead + optionNameBlockchainRpcEndpoint = "blockchain-rpc-endpoint" + optionNameSwapFactoryAddress = "swap-factory-address" + optionNameSwapInitialDeposit = "swap-initial-deposit" + optionNameSwapEnable = "swap-enable" + optionNameChequebookEnable = "chequebook-enable" + optionNameSwapDeploymentGasPrice = "swap-deployment-gas-price" + optionNameFullNode = "full-node" + optionNamePostageContractAddress = "postage-stamp-address" + optionNamePostageContractStartBlock = "postage-stamp-start-block" + optionNamePriceOracleAddress = "price-oracle-address" + optionNameRedistributionAddress = "redistribution-address" + optionNameStakingAddress = "staking-address" + optionNameBlockTime = "block-time" + optionWarmUpTime = "warmup-time" + optionNameMainNet = "mainnet" + optionNameRetrievalCaching = "cache-retrieval" + optionNameDevReserveCapacity = "dev-reserve-capacity" + optionNameResync = "resync" + optionNamePProfBlock = "pprof-profile" + optionNamePProfMutex = "pprof-mutex" + optionNameStaticNodes = "static-nodes" + optionNameAllowPrivateCIDRs = "allow-private-cidrs" + optionNameSleepAfter = "sleep-after" + optionNameRestrictedAPI = "restricted" + optionNameTokenEncryptionKey = "token-encryption-key" + optionNameAdminPasswordHash = "admin-password" + optionNameUsePostageSnapshot = "use-postage-snapshot" + optionNameStorageIncentivesEnable = "storage-incentives-enable" + optionNameStateStoreCacheCapacity = "statestore-cache-capacity" + optionNameTargetNeighborhood = "target-neighborhood" + optionNameNeighborhoodSuggester = "neighborhood-suggester" + optionNameWhitelistedWithdrawalAddress = "withdrawal-addresses-whitelist" ) // nolint:gochecknoinits @@ -304,6 +305,7 @@ func (c *command) setAllFlags(cmd *cobra.Command) { cmd.Flags().Uint64(optionNameStateStoreCacheCapacity, 100_000, "lru memory caching capacity in number of statestore entries") cmd.Flags().String(optionNameTargetNeighborhood, "", "neighborhood to target in binary format (ex: 111111001) for mining the initial overlay") cmd.Flags().String(optionNameNeighborhoodSuggester, "https://api.swarmscan.io/v1/network/neighborhoods/suggestion", "suggester for target neighborhood") + cmd.Flags().StringSlice(optionNameWhitelistedWithdrawalAddress, []string{}, "withdrawal target addresses") } func newLogger(cmd *cobra.Command, verbosity string) (log.Logger, error) { diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go index 9b5b38f9aab..347597eaf0b 100644 --- a/cmd/bee/cmd/start.go +++ b/cmd/bee/cmd/start.go @@ -345,6 +345,7 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo StatestoreCacheCapacity: c.config.GetUint64(optionNameStateStoreCacheCapacity), TargetNeighborhood: c.config.GetString(optionNameTargetNeighborhood), NeighborhoodSuggester: neighborhoodSuggester, + WhitelistedWithdrawalAddress: c.config.GetStringSlice(optionNameWhitelistedWithdrawalAddress), }) return b, err diff --git a/openapi/SwarmDebug.yaml b/openapi/SwarmDebug.yaml index 11d7b1a5fdb..355d938c3ec 100644 --- a/openapi/SwarmDebug.yaml +++ b/openapi/SwarmDebug.yaml @@ -689,6 +689,41 @@ paths: $ref: "SwarmCommon.yaml#/components/responses/500" default: description: Default response + "/wallet/withdraw/{coin}": + post: + summary: Allows withdrawals of BZZ or xDAI to provided (whitelisted) address + tags: + - Wallet + parameters: + - in: query + name: amount + required: true + schema: + $ref: "#/components/schemas/BigInt" + - in: query + name: address + required: true + schema: + $ref: "#/components/schemas/EthereumAddress" + - in: path + name: coin + required: true + schema: + $ref: "#/components/schemas/SwarmAddress" + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/WalletTxResponse' + description: OK + "400": + $ref: "SwarmCommon.yaml#/components/responses/400" + description: Amount greater than ballance or coin is other than BZZ/xDAI + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + default: + description: Default response "/transactions": get: @@ -1133,3 +1168,11 @@ paths: $ref: "SwarmCommon.yaml#/components/responses/400" default: description: Default response. + +components: + schemas: + WalletTxResponse: + type: object + properties: + transactionHash: + $ref: "#/components/schemas/TransactionHash" diff --git a/pkg/api/api.go b/pkg/api/api.go index 0d9cdaab415..25ef0802692 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -197,6 +197,8 @@ type Service struct { erc20Service erc20.Service chainID int64 + whitelistedWithdrawalAddress []common.Address + preMapHooks map[string]func(v string) (string, error) validate *validator.Validate @@ -254,6 +256,7 @@ type ExtraOptions struct { func New( publicKey, pssPublicKey ecdsa.PublicKey, ethereumAddress common.Address, + whitelistedWithdrawalAddress []string, logger log.Logger, transaction transaction.Service, batchStore postage.Storer, @@ -303,6 +306,10 @@ func New( }) s.stamperStore = stamperStore + for _, v := range whitelistedWithdrawalAddress { + s.whitelistedWithdrawalAddress = append(s.whitelistedWithdrawalAddress, common.HexToAddress(v)) + } + return s } diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index cfa6794257d..9673cb0a25e 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -132,6 +132,7 @@ type testServerOptions struct { RedistributionAgent *storageincentives.Agent NodeStatus *status.Service PinIntegrity api.PinIntegrity + WhitelistedAddr string } func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket.Conn, string, *chanStorer) { @@ -210,7 +211,7 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket. o.BeeMode = api.FullMode } - s := api.New(o.PublicKey, o.PSSPublicKey, o.EthereumAddress, o.Logger, transaction, o.BatchStore, o.BeeMode, true, true, backend, o.CORSAllowedOrigins, inmemstore.New()) + s := api.New(o.PublicKey, o.PSSPublicKey, o.EthereumAddress, []string{o.WhitelistedAddr}, o.Logger, transaction, o.BatchStore, o.BeeMode, true, true, backend, o.CORSAllowedOrigins, inmemstore.New()) testutil.CleanupCloser(t, s) s.SetP2P(o.P2P) @@ -395,7 +396,7 @@ func TestParseName(t *testing.T) { pk, _ := crypto.GenerateSecp256k1Key() signer := crypto.NewDefaultSigner(pk) - s := api.New(pk.PublicKey, pk.PublicKey, common.Address{}, log, nil, nil, 1, false, false, nil, []string{"*"}, inmemstore.New()) + s := api.New(pk.PublicKey, pk.PublicKey, common.Address{}, nil, log, nil, nil, 1, false, false, nil, []string{"*"}, inmemstore.New()) s.Configure(signer, nil, nil, api.Options{}, api.ExtraOptions{Resolver: tC.res}, 1, nil) s.MountAPI() diff --git a/pkg/api/export_test.go b/pkg/api/export_test.go index 030a27d9001..869b00fe5a7 100644 --- a/pkg/api/export_test.go +++ b/pkg/api/export_test.go @@ -95,6 +95,7 @@ type ( PostageStampBucketsResponse = postageStampBucketsResponse BucketData = bucketData WalletResponse = walletResponse + WalletTxResponse = walletTxResponse GetStakeResponse = getStakeResponse WithdrawAllStakeResponse = withdrawAllStakeResponse StatusSnapshotResponse = statusSnapshotResponse diff --git a/pkg/api/router.go b/pkg/api/router.go index 3e4574ed896..bfc6d9a9f11 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -491,6 +491,12 @@ func (s *Service) mountBusinessDebug(restricted bool) { handle("/wallet", jsonhttp.MethodHandler{ "GET": http.HandlerFunc(s.walletHandler), }) + handle("/wallet/withdraw/{coin}", jsonhttp.MethodHandler{ + "POST": web.ChainHandlers( + s.gasConfigMiddleware("wallet withdraw"), + web.FinalHandlerFunc(s.walletWithdrawHandler), + ), + }) } } diff --git a/pkg/api/wallet.go b/pkg/api/wallet.go index c98e83b5987..ff1f6411f21 100644 --- a/pkg/api/wallet.go +++ b/pkg/api/wallet.go @@ -5,11 +5,18 @@ package api import ( + "math/big" "net/http" + "strings" + + "slices" "github.com/ethereum/go-ethereum/common" "github.com/ethersphere/bee/pkg/bigint" "github.com/ethersphere/bee/pkg/jsonhttp" + "github.com/ethersphere/bee/pkg/sctx" + "github.com/ethersphere/bee/pkg/transaction" + "github.com/gorilla/mux" ) type walletResponse struct { @@ -47,3 +54,97 @@ func (s *Service) walletHandler(w http.ResponseWriter, r *http.Request) { WalletAddress: s.ethereumAddress, }) } + +type walletTxResponse struct { + TransactionHash common.Hash `json:"transactionHash"` +} + +func (s *Service) walletWithdrawHandler(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("post_wallet_withdraw").Build() + + queries := struct { + Amount *big.Int `map:"amount" validate:"required"` + Address *common.Address `map:"address" validate:"required"` + }{} + + if response := s.mapStructure(r.URL.Query(), &queries); response != nil { + response("invalid query params", logger, w) + return + } + + path := struct { + Coin *string `map:"coin" validate:"required"` + }{} + + if response := s.mapStructure(mux.Vars(r), &path); response != nil { + response("invalid query params", logger, w) + return + } + + var bzz bool + + if strings.EqualFold("BZZ", *path.Coin) { + bzz = true + } else if !strings.EqualFold("NativeToken", *path.Coin) { + jsonhttp.BadRequest(w, "only BZZ or NativeToken options are accepted") + return + } + + if !slices.Contains(s.whitelistedWithdrawalAddress, *queries.Address) { + jsonhttp.BadRequest(w, "provided address not whitelisted") + return + } + + if bzz { + currentBalance, err := s.erc20Service.BalanceOf(r.Context(), s.ethereumAddress) + if err != nil { + logger.Error(err, "unable to get balance") + jsonhttp.InternalServerError(w, "unable to get balance") + return + } + + if queries.Amount.Cmp(currentBalance) > 0 { + logger.Error(err, "not enough balance") + jsonhttp.BadRequest(w, "not enough balance") + return + } + + txHash, err := s.erc20Service.Transfer(r.Context(), *queries.Address, queries.Amount) + if err != nil { + logger.Error(err, "unable to transfer") + jsonhttp.InternalServerError(w, "unable to transfer amount") + return + } + jsonhttp.OK(w, walletTxResponse{TransactionHash: txHash}) + return + } + + nativeToken, err := s.chainBackend.BalanceAt(r.Context(), s.ethereumAddress, nil) + if err != nil { + logger.Error(err, "unable to acquire balance from the chain backend") + jsonhttp.InternalServerError(w, "unable to acquire balance from the chain backend") + return + } + + if queries.Amount.Cmp(nativeToken) > 0 { + jsonhttp.BadRequest(w, "not enough balance") + return + } + + req := &transaction.TxRequest{ + To: queries.Address, + GasPrice: sctx.GetGasPrice(r.Context()), + GasLimit: sctx.GetGasLimitWithDefault(r.Context(), 300_000), + Value: queries.Amount, + Description: "native token withdraw", + } + + txHash, err := s.transaction.Send(r.Context(), req, transaction.DefaultTipBoostPercent) + if err != nil { + logger.Error(err, "unable to transfer") + jsonhttp.InternalServerError(w, "unable to transfer") + return + } + + jsonhttp.OK(w, walletTxResponse{TransactionHash: txHash}) +} diff --git a/pkg/api/wallet_test.go b/pkg/api/wallet_test.go index a5a881f7b24..80ea932fa19 100644 --- a/pkg/api/wallet_test.go +++ b/pkg/api/wallet_test.go @@ -16,7 +16,9 @@ import ( "github.com/ethersphere/bee/pkg/jsonhttp" "github.com/ethersphere/bee/pkg/jsonhttp/jsonhttptest" erc20mock "github.com/ethersphere/bee/pkg/settlement/swap/erc20/mock" + "github.com/ethersphere/bee/pkg/transaction" "github.com/ethersphere/bee/pkg/transaction/backendmock" + transactionmock "github.com/ethersphere/bee/pkg/transaction/mock" ) func TestWallet(t *testing.T) { @@ -86,3 +88,203 @@ func TestWallet(t *testing.T) { })) }) } + +func TestWalletWithdraw(t *testing.T) { + t.Parallel() + + t.Run("address not whitelisted", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{DebugAPI: true}) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/BZZ?address=0xaf&amount=99999999", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "provided address not whitelisted", + Code: 400, + })) + }) + + t.Run("invalid coin type", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{DebugAPI: true}) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/BTC?address=0xaf&amount=99999999", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "only BZZ or NativeToken options are accepted", + Code: 400, + })) + }) + + t.Run("BZZ erc20 balance error", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/BZZ?address=0xaf&amount=99999999", http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "unable to get balance", + Code: 500, + })) + }) + + t.Run("BZZ erc20 balance insufficient", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + Erc20Opts: []erc20mock.Option{ + erc20mock.WithBalanceOfFunc(func(ctx context.Context, address common.Address) (*big.Int, error) { + return big.NewInt(88888888), nil + }), + }, + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/BZZ?address=0xaf&amount=99999999", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "not enough balance", + Code: 400, + })) + }) + + t.Run("BZZ erc20 transfer error", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + Erc20Opts: []erc20mock.Option{ + erc20mock.WithBalanceOfFunc(func(ctx context.Context, address common.Address) (*big.Int, error) { + return big.NewInt(100000000), nil + }), + }, + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/BZZ?address=0xaf&amount=99999999", http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "unable to transfer amount", + Code: 500, + })) + }) + + t.Run("BZZ erc20 transfer ok", func(t *testing.T) { + t.Parallel() + + txHash := common.HexToHash("0x00f") + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + Erc20Opts: []erc20mock.Option{ + erc20mock.WithBalanceOfFunc(func(ctx context.Context, address common.Address) (*big.Int, error) { + return big.NewInt(100000000), nil + }), + erc20mock.WithTransferFunc(func(ctx context.Context, address common.Address, value *big.Int) (common.Hash, error) { + if address != common.HexToAddress("0xaf") { + t.Fatalf("want addr 0xaf, got %s", address) + } + if value.Cmp(big.NewInt(99999999)) != 0 { + t.Fatalf("want value 99999999, got %s", value) + } + return txHash, nil + }), + }, + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/BZZ?address=0xaf&amount=99999999", http.StatusOK, + jsonhttptest.WithExpectedJSONResponse(api.WalletTxResponse{ + TransactionHash: txHash, + })) + }) + + t.Run("native balance error", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/NativeToken?address=0xaf&amount=99999999", http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "unable to acquire balance from the chain backend", + Code: 500, + })) + }) + + t.Run("native insufficient balance", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + BackendOpts: []backendmock.Option{ + backendmock.WithBalanceAt(func(ctx context.Context, address common.Address, block *big.Int) (*big.Int, error) { + return big.NewInt(99999990), nil + }), + }, + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/NativeToken?address=0xaf&amount=99999999", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "not enough balance", + Code: 400, + })) + }) + + t.Run("native backend send error", func(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + BackendOpts: []backendmock.Option{ + backendmock.WithBalanceAt(func(ctx context.Context, address common.Address, block *big.Int) (*big.Int, error) { + return big.NewInt(100000000), nil + }), + }, + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/NativeToken?address=0xaf&amount=99999999", http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "unable to transfer", + Code: 500, + })) + }) + + t.Run("native ok", func(t *testing.T) { + t.Parallel() + + txHash := common.HexToHash("0x00f") + + srv, _, _, _ := newTestServer(t, testServerOptions{ + DebugAPI: true, + WhitelistedAddr: "0xaf", + BackendOpts: []backendmock.Option{ + backendmock.WithBalanceAt(func(ctx context.Context, address common.Address, block *big.Int) (*big.Int, error) { + return big.NewInt(100000000), nil + }), + }, + TransactionOpts: []transactionmock.Option{ + transactionmock.WithSendFunc(func(ctx context.Context, tx *transaction.TxRequest, i int) (common.Hash, error) { + if tx.Value.Cmp(big.NewInt(99999999)) != 0 { + t.Fatalf("bad value, want 99999999, got %s", tx.Value) + } + if tx.To.Cmp(common.HexToAddress("0xaf")) != 0 { + t.Fatalf("bad address, want 0xaf, got %s", tx.To) + } + return txHash, nil + }), + }, + }) + + jsonhttptest.Request(t, srv, http.MethodPost, "/wallet/withdraw/NativeToken?address=0xaf&amount=99999999", http.StatusOK, + jsonhttptest.WithExpectedJSONResponse(api.WalletTxResponse{ + TransactionHash: txHash, + })) + }) +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 8c9b01c8719..a12b883bb83 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -291,6 +291,7 @@ func applyPolicies(e *casbin.Enforcer) error { {"maintainer", "/chequebook/address", "GET"}, {"maintainer", "/chequebook/balance", "GET"}, {"maintainer", "/wallet", "GET"}, + {"maintainer", "/wallet/withdraw/*", "POST"}, {"maintainer", "/chunks/*", "(GET)|(DELETE)"}, {"maintainer", "/reservestate", "GET"}, {"maintainer", "/chainstate", "GET"}, diff --git a/pkg/node/devnode.go b/pkg/node/devnode.go index 76b055b531f..cff06b2d711 100644 --- a/pkg/node/devnode.go +++ b/pkg/node/devnode.go @@ -202,7 +202,7 @@ func NewDevBee(logger log.Logger, o *DevOptions) (b *DevBee, err error) { return nil, fmt.Errorf("debug api listener: %w", err) } - debugApiService = api.New(mockKey.PublicKey, mockKey.PublicKey, overlayEthAddress, logger, mockTransaction, batchStore, api.DevMode, true, true, chainBackend, o.CORSAllowedOrigins, inmemstore.New()) + debugApiService = api.New(mockKey.PublicKey, mockKey.PublicKey, overlayEthAddress, nil, logger, mockTransaction, batchStore, api.DevMode, true, true, chainBackend, o.CORSAllowedOrigins, inmemstore.New()) debugAPIServer := &http.Server{ IdleTimeout: 30 * time.Second, ReadHeaderTimeout: 3 * time.Second, @@ -398,7 +398,7 @@ func NewDevBee(logger log.Logger, o *DevOptions) (b *DevBee, err error) { }), ) - apiService := api.New(mockKey.PublicKey, mockKey.PublicKey, overlayEthAddress, logger, mockTransaction, batchStore, api.DevMode, true, true, chainBackend, o.CORSAllowedOrigins, inmemstore.New()) + apiService := api.New(mockKey.PublicKey, mockKey.PublicKey, overlayEthAddress, nil, logger, mockTransaction, batchStore, api.DevMode, true, true, chainBackend, o.CORSAllowedOrigins, inmemstore.New()) apiService.Configure(signer, authenticator, tracer, api.Options{ CORSAllowedOrigins: o.CORSAllowedOrigins, diff --git a/pkg/node/node.go b/pkg/node/node.go index f4149ba8097..ba8b98c5d20 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -172,6 +172,7 @@ type Options struct { StatestoreCacheCapacity uint64 TargetNeighborhood string NeighborhoodSuggester string + WhitelistedWithdrawalAddress []string } const ( @@ -421,6 +422,7 @@ func NewBee( *publicKey, pssPrivateKey.PublicKey, overlayEthAddress, + o.WhitelistedWithdrawalAddress, logger, transactionService, batchStore, @@ -460,6 +462,7 @@ func NewBee( *publicKey, pssPrivateKey.PublicKey, overlayEthAddress, + o.WhitelistedWithdrawalAddress, logger, transactionService, batchStore, @@ -1094,7 +1097,7 @@ func NewBee( if o.APIAddr != "" { if apiService == nil { - apiService = api.New(*publicKey, pssPrivateKey.PublicKey, overlayEthAddress, logger, transactionService, batchStore, beeNodeMode, o.ChequebookEnable, o.SwapEnable, chainBackend, o.CORSAllowedOrigins, stamperStore) + apiService = api.New(*publicKey, pssPrivateKey.PublicKey, overlayEthAddress, o.WhitelistedWithdrawalAddress, logger, transactionService, batchStore, beeNodeMode, o.ChequebookEnable, o.SwapEnable, chainBackend, o.CORSAllowedOrigins, stamperStore) apiService.SetProbe(probe) apiService.SetRedistributionAgent(agent) }