Commit 2cb6e13f authored by Anatol's avatar Anatol Committed by GitHub

feat: start in dev mode (#2347)

parent 2862b772
......@@ -71,6 +71,7 @@ const (
optionWarmUpTime = "warmup-time"
optionNameMainNet = "mainnet"
optionNameRetrievalCaching = "cache-retrieval"
optionNameDevReserveCapacity = "dev-reserve-capacity"
)
func init() {
......@@ -118,6 +119,10 @@ func newCommand(opts ...option) (c *command, err error) {
return nil, err
}
if err := c.initStartDevCmd(); err != nil {
return nil, err
}
if err := c.initInitCmd(); err != nil {
return nil, err
}
......
// Copyright 2021 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/ethersphere/bee/pkg/node"
"github.com/kardianos/service"
"github.com/spf13/cobra"
)
func (c *command) initStartDevCmd() (err error) {
cmd := &cobra.Command{
Use: "dev",
Short: "Start a Swarm node in development mode",
RunE: func(cmd *cobra.Command, args []string) (err error) {
if len(args) > 0 {
return cmd.Help()
}
v := strings.ToLower(c.config.GetString(optionNameVerbosity))
logger, err := newLogger(cmd, v)
if err != nil {
return fmt.Errorf("new logger: %v", err)
}
isWindowsService, err := isWindowsService()
if err != nil {
return fmt.Errorf("failed to determine if we are running in service: %w", err)
}
if isWindowsService {
var err error
logger, err = createWindowsEventLogger(serviceName, logger)
if err != nil {
return fmt.Errorf("failed to create windows logger %w", err)
}
}
beeASCII := `
( * ) (
)\ ) ( * ( /( )\ )
(()/( ( ( ( )\))( )\())(()/( (
/(_)) )\ )\ )\ ((_)()\ ((_)\ /(_)) )\
(_))_ ((_) ((_)((_) (_()((_) ((_)(_))_ ((_)
| \ | __|\ \ / / | \/ | / _ \ | \ | __|
| |) || _| \ V / | |\/| || (_) || |) || _|
|___/ |___| \_/ |_| |_| \___/ |___/ |___|
`
fmt.Println(beeASCII)
fmt.Println()
fmt.Println("Starting in development mode")
fmt.Println()
debugAPIAddr := c.config.GetString(optionNameDebugAPIAddr)
if !c.config.GetBool(optionNameDebugAPIEnable) {
debugAPIAddr = ""
}
// generate signer in here
b, err := node.NewDevBee(logger, &node.DevOptions{
APIAddr: c.config.GetString(optionNameAPIAddr),
DebugAPIAddr: debugAPIAddr,
Logger: logger,
DBOpenFilesLimit: c.config.GetUint64(optionNameDBOpenFilesLimit),
DBBlockCacheCapacity: c.config.GetUint64(optionNameDBBlockCacheCapacity),
DBWriteBufferSize: c.config.GetUint64(optionNameDBWriteBufferSize),
DBDisableSeeksCompaction: c.config.GetBool(optionNameDBDisableSeeksCompaction),
CORSAllowedOrigins: c.config.GetStringSlice(optionCORSAllowedOrigins),
ReserveCapacity: c.config.GetUint64(optionNameDevReserveCapacity),
})
if err != nil {
return err
}
// Wait for termination or interrupt signals.
// We want to clean up things at the end.
interruptChannel := make(chan os.Signal, 1)
signal.Notify(interruptChannel, syscall.SIGINT, syscall.SIGTERM)
p := &program{
start: func() {
// Block main goroutine until it is interrupted
sig := <-interruptChannel
logger.Debugf("received signal: %v", sig)
logger.Info("shutting down")
},
stop: func() {
// Shutdown
done := make(chan struct{})
go func() {
defer close(done)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := b.Shutdown(ctx); err != nil {
logger.Errorf("shutdown: %v", err)
}
}()
// If shutdown function is blocking too long,
// allow process termination by receiving another signal.
select {
case sig := <-interruptChannel:
logger.Debugf("received signal: %v", sig)
case <-done:
}
},
}
if isWindowsService {
s, err := service.New(p, &service.Config{
Name: serviceName,
DisplayName: "Bee",
Description: "Bee, Swarm client.",
})
if err != nil {
return err
}
if err = s.Run(); err != nil {
return err
}
} else {
// start blocks until some interrupt is received
p.start()
p.stop()
}
return nil
},
PreRunE: func(cmd *cobra.Command, args []string) error {
return c.config.BindPFlags(cmd.Flags())
},
}
cmd.Flags().Bool(optionNameDebugAPIEnable, true, "enable debug HTTP API")
cmd.Flags().String(optionNameAPIAddr, ":1633", "HTTP API listen address")
cmd.Flags().String(optionNameDebugAPIAddr, ":1635", "debug HTTP API listen address")
cmd.Flags().String(optionNameVerbosity, "info", "log verbosity level 0=silent, 1=error, 2=warn, 3=info, 4=debug, 5=trace")
cmd.Flags().Uint64(optionNameDevReserveCapacity, 4194304, "cache reserve capacity")
cmd.Flags().StringSlice(optionCORSAllowedOrigins, []string{}, "origins with CORS headers enabled")
cmd.Flags().Uint64(optionNameDBOpenFilesLimit, 200, "number of open files allowed by database")
cmd.Flags().Uint64(optionNameDBBlockCacheCapacity, 32*1024*1024, "size of block cache of the database in bytes")
cmd.Flags().Uint64(optionNameDBWriteBufferSize, 32*1024*1024, "size of the database write buffer in bytes")
cmd.Flags().Bool(optionNameDBDisableSeeksCompaction, false, "disables db compactions triggered by seeks")
c.root.AddCommand(cmd)
return nil
}
......@@ -14,6 +14,7 @@ import (
"sync"
"github.com/ethereum/go-ethereum/common"
"github.com/ethersphere/bee/pkg/accounting"
"github.com/ethersphere/bee/pkg/logging"
"github.com/ethersphere/bee/pkg/p2p"
......
// Copyright 2021 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package node
import (
"context"
"fmt"
"io"
"log"
"math/big"
"net"
"net/http"
"time"
"github.com/ethereum/go-ethereum/common"
mockAccounting "github.com/ethersphere/bee/pkg/accounting/mock"
"github.com/ethersphere/bee/pkg/api"
"github.com/ethersphere/bee/pkg/bzz"
"github.com/ethersphere/bee/pkg/crypto"
"github.com/ethersphere/bee/pkg/debugapi"
"github.com/ethersphere/bee/pkg/feeds/factory"
"github.com/ethersphere/bee/pkg/localstore"
"github.com/ethersphere/bee/pkg/logging"
mockP2P "github.com/ethersphere/bee/pkg/p2p/mock"
mockPingPong "github.com/ethersphere/bee/pkg/pingpong/mock"
"github.com/ethersphere/bee/pkg/pinning"
"github.com/ethersphere/bee/pkg/postage"
"github.com/ethersphere/bee/pkg/postage/batchstore"
mockPost "github.com/ethersphere/bee/pkg/postage/mock"
mockPostContract "github.com/ethersphere/bee/pkg/postage/postagecontract/mock"
postagetesting "github.com/ethersphere/bee/pkg/postage/testing"
"github.com/ethersphere/bee/pkg/pss"
"github.com/ethersphere/bee/pkg/pushsync"
mockPushsync "github.com/ethersphere/bee/pkg/pushsync/mock"
"github.com/ethersphere/bee/pkg/settlement/pseudosettle"
"github.com/ethersphere/bee/pkg/settlement/swap/chequebook"
mockchequebook "github.com/ethersphere/bee/pkg/settlement/swap/chequebook/mock"
swapmock "github.com/ethersphere/bee/pkg/settlement/swap/mock"
"github.com/ethersphere/bee/pkg/statestore/leveldb"
mockStateStore "github.com/ethersphere/bee/pkg/statestore/mock"
"github.com/ethersphere/bee/pkg/swarm"
"github.com/ethersphere/bee/pkg/tags"
"github.com/ethersphere/bee/pkg/topology/lightnode"
mockTopology "github.com/ethersphere/bee/pkg/topology/mock"
"github.com/ethersphere/bee/pkg/tracing"
"github.com/ethersphere/bee/pkg/transaction"
transactionmock "github.com/ethersphere/bee/pkg/transaction/mock"
"github.com/ethersphere/bee/pkg/traversal"
"github.com/hashicorp/go-multierror"
"github.com/multiformats/go-multiaddr"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
)
type DevBee struct {
tracerCloser io.Closer
stateStoreCloser io.Closer
localstoreCloser io.Closer
apiCloser io.Closer
pssCloser io.Closer
tagsCloser io.Closer
errorLogWriter *io.PipeWriter
apiServer *http.Server
debugAPIServer *http.Server
}
type DevOptions struct {
Logger logging.Logger
APIAddr string
DebugAPIAddr string
CORSAllowedOrigins []string
DBOpenFilesLimit uint64
ReserveCapacity uint64
DBWriteBufferSize uint64
DBBlockCacheCapacity uint64
DBDisableSeeksCompaction bool
}
// NewDevBee starts the bee instance in 'development' mode
// this implies starting an API and a Debug endpoints while mocking all their services.
func NewDevBee(logger logging.Logger, o *DevOptions) (b *DevBee, err error) {
tracer, tracerCloser, err := tracing.NewTracer(&tracing.Options{
Enabled: false,
})
if err != nil {
return nil, fmt.Errorf("tracer: %w", err)
}
b = &DevBee{
errorLogWriter: logger.WriterLevel(logrus.ErrorLevel),
tracerCloser: tracerCloser,
}
stateStore, err := leveldb.NewInMemoryStateStore(logger)
if err != nil {
return nil, err
}
b.stateStoreCloser = stateStore
mockKey, err := crypto.GenerateSecp256k1Key()
if err != nil {
return nil, err
}
signer := crypto.NewDefaultSigner(mockKey)
overlayEthAddress, err := signer.EthereumAddress()
if err != nil {
return nil, fmt.Errorf("eth address: %w", err)
}
var debugAPIService *debugapi.Service
if o.DebugAPIAddr != "" {
debugAPIListener, err := net.Listen("tcp", o.DebugAPIAddr)
if err != nil {
return nil, fmt.Errorf("debug api listener: %w", err)
}
var mockTransaction = transactionmock.New(transactionmock.WithPendingTransactionsFunc(func() ([]common.Hash, error) {
return []common.Hash{common.HexToHash("abcd")}, nil
}), transactionmock.WithResendTransactionFunc(func(ctx context.Context, txHash common.Hash) error {
return nil
}), transactionmock.WithStoredTransactionFunc(func(txHash common.Hash) (*transaction.StoredTransaction, error) {
recipient := common.HexToAddress("dfff")
return &transaction.StoredTransaction{
To: &recipient,
Created: 1,
Data: []byte{1, 2, 3, 4},
GasPrice: big.NewInt(12),
GasLimit: 5345,
Value: big.NewInt(4),
Nonce: 3,
Description: "test",
}, nil
}), transactionmock.WithCancelTransactionFunc(func(ctx context.Context, originalTxHash common.Hash) (common.Hash, error) {
return common.Hash{}, nil
}),
)
debugAPIService = debugapi.New(mockKey.PublicKey, mockKey.PublicKey, overlayEthAddress, logger, tracer, nil, big.NewInt(0), mockTransaction)
debugAPIServer := &http.Server{
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 3 * time.Second,
Handler: debugAPIService,
ErrorLog: log.New(b.errorLogWriter, "", 0),
}
go func() {
logger.Infof("debug api address: %s", debugAPIListener.Addr())
if err := debugAPIServer.Serve(debugAPIListener); err != nil && err != http.ErrServerClosed {
logger.Debugf("debug api server: %v", err)
logger.Error("unable to serve debug api")
}
}()
b.debugAPIServer = debugAPIServer
}
lo := &localstore.Options{
Capacity: 1000,
ReserveCapacity: o.ReserveCapacity,
OpenFilesLimit: o.DBOpenFilesLimit,
BlockCacheCapacity: o.DBBlockCacheCapacity,
WriteBufferSize: o.DBWriteBufferSize,
DisableSeeksCompaction: o.DBDisableSeeksCompaction,
UnreserveFunc: func(postage.UnreserveIteratorFn) error {
return nil
},
}
var swarmAddress swarm.Address
storer, err := localstore.New("", swarmAddress.Bytes(), stateStore, lo, logger)
if err != nil {
return nil, fmt.Errorf("localstore: %w", err)
}
b.localstoreCloser = storer
tagService := tags.NewTags(stateStore, logger)
b.tagsCloser = tagService
pssService := pss.New(mockKey, logger)
b.pssCloser = pssService
pssService.SetPushSyncer(mockPushsync.New(func(ctx context.Context, chunk swarm.Chunk) (*pushsync.Receipt, error) {
pssService.TryUnwrap(chunk)
return &pushsync.Receipt{}, nil
}))
traversalService := traversal.New(storer)
pinningService := pinning.NewService(storer, stateStore, traversalService)
batchStore, err := batchstore.New(stateStore, func(b []byte) error { return nil }, logger)
if err != nil {
return nil, fmt.Errorf("batchstore: %w", err)
}
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
},
))
feedFactory := factory.New(storer)
apiService := api.New(tagService, storer, nil, pssService, traversalService, pinningService, feedFactory, post, postageContract, nil, signer, logger, tracer, api.Options{
CORSAllowedOrigins: o.CORSAllowedOrigins,
GatewayMode: false,
WsPingPeriod: 60 * time.Second,
})
apiListener, err := net.Listen("tcp", o.APIAddr)
if err != nil {
return nil, fmt.Errorf("api listener: %w", err)
}
apiServer := &http.Server{
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 3 * time.Second,
Handler: apiService,
ErrorLog: log.New(b.errorLogWriter, "", 0),
}
go func() {
logger.Infof("api address: %s", apiListener.Addr())
if err := apiServer.Serve(apiListener); err != nil && err != http.ErrServerClosed {
logger.Debugf("api server: %v", err)
logger.Error("unable to serve api")
}
}()
b.apiServer = apiServer
b.apiCloser = apiService
if debugAPIService != nil {
var (
lightNodes = lightnode.NewContainer(swarm.NewAddress(nil))
pingPong = mockPingPong.New(pong)
p2ps = mockP2P.New(
mockP2P.WithConnectFunc(func(ctx context.Context, addr multiaddr.Multiaddr) (address *bzz.Address, err error) {
return &bzz.Address{}, nil
}), mockP2P.WithDisconnectFunc(
func(overlay swarm.Address) error {
return nil
},
), mockP2P.WithAddressesFunc(
func() ([]multiaddr.Multiaddr, error) {
ma, _ := multiaddr.NewMultiaddr("mock")
return []multiaddr.Multiaddr{ma}, nil
},
))
acc = mockAccounting.NewAccounting()
kad = mockTopology.NewTopologyDriver()
storeRecipient = mockStateStore.NewStateStore()
pseudoset = pseudosettle.New(nil, logger, storeRecipient, nil, big.NewInt(10000), p2ps)
mockSwap = swapmock.New(swapmock.WithCashoutStatusFunc(
func(ctx context.Context, peer swarm.Address) (*chequebook.CashoutStatus, error) {
return &chequebook.CashoutStatus{
Last: &chequebook.LastCashout{},
UncashedAmount: big.NewInt(0),
}, nil
},
), swapmock.WithLastSentChequeFunc(
func(a swarm.Address) (*chequebook.SignedCheque, error) {
return &chequebook.SignedCheque{
Cheque: chequebook.Cheque{
Beneficiary: common.Address{},
Chequebook: common.Address{},
},
}, nil
},
), swapmock.WithLastReceivedChequeFunc(
func(a swarm.Address) (*chequebook.SignedCheque, error) {
return &chequebook.SignedCheque{
Cheque: chequebook.Cheque{
Beneficiary: common.Address{},
Chequebook: common.Address{},
},
}, nil
},
))
mockChequebook = mockchequebook.NewChequebook(mockchequebook.WithChequebookBalanceFunc(
func(context.Context) (ret *big.Int, err error) {
return big.NewInt(0), nil
},
), mockchequebook.WithChequebookAvailableBalanceFunc(
func(context.Context) (ret *big.Int, err error) {
return big.NewInt(0), nil
},
), mockchequebook.WithChequebookWithdrawFunc(
func(ctx context.Context, amount *big.Int) (hash common.Hash, err error) {
return common.Hash{}, nil
},
), mockchequebook.WithChequebookDepositFunc(
func(ctx context.Context, amount *big.Int) (hash common.Hash, err error) {
return common.Hash{}, nil
},
))
)
// inject dependencies and configure full debug api http path routes
debugAPIService.Configure(swarmAddress, p2ps, pingPong, kad, lightNodes, storer, tagService, acc, pseudoset, true, mockSwap, mockChequebook, batchStore, post, postageContract)
}
return b, nil
}
func (b *DevBee) Shutdown(ctx context.Context) error {
var mErr error
tryClose := func(c io.Closer, errMsg string) {
if c == nil {
return
}
if err := c.Close(); err != nil {
mErr = multierror.Append(mErr, fmt.Errorf("%s: %w", errMsg, err))
}
}
tryClose(b.apiCloser, "api")
var eg errgroup.Group
if b.apiServer != nil {
eg.Go(func() error {
if err := b.apiServer.Shutdown(ctx); err != nil {
return fmt.Errorf("api server: %w", err)
}
return nil
})
}
if b.debugAPIServer != nil {
eg.Go(func() error {
if err := b.debugAPIServer.Shutdown(ctx); err != nil {
return fmt.Errorf("debug api server: %w", err)
}
return nil
})
}
if err := eg.Wait(); err != nil {
mErr = multierror.Append(mErr, err)
}
tryClose(b.pssCloser, "pss")
tryClose(b.tracerCloser, "tracer")
tryClose(b.tagsCloser, "tag persistence")
tryClose(b.stateStoreCloser, "statestore")
tryClose(b.localstoreCloser, "localstore")
tryClose(b.errorLogWriter, "error log writer")
return mErr
}
func pong(ctx context.Context, address swarm.Address, msgs ...string) (rtt time.Duration, err error) {
return time.Millisecond, nil
}
......@@ -7,6 +7,7 @@ package mock
import (
"errors"
"math/big"
"sync"
"github.com/ethersphere/bee/pkg/postage"
)
......@@ -22,7 +23,9 @@ func (f optionFunc) apply(r *mockPostage) { f(r) }
// New creates a new mock postage service.
func New(o ...Option) postage.Service {
m := &mockPostage{}
m := &mockPostage{
issuersMap: make(map[string]*postage.StampIssuer),
}
for _, v := range o {
v.apply(m)
}
......@@ -37,20 +40,33 @@ func WithAcceptAll() Option {
}
func WithIssuer(s *postage.StampIssuer) Option {
return optionFunc(func(m *mockPostage) { m.i = s })
return optionFunc(func(m *mockPostage) {
m.issuersMap = map[string]*postage.StampIssuer{string(s.ID()): s}
})
}
type mockPostage struct {
i *postage.StampIssuer
acceptAll bool
issuersMap map[string]*postage.StampIssuer
issuerLock sync.Mutex
acceptAll bool
}
func (m *mockPostage) Add(s *postage.StampIssuer) {
m.i = s
m.issuerLock.Lock()
defer m.issuerLock.Unlock()
m.issuersMap[string(s.ID())] = s
}
func (m *mockPostage) StampIssuers() []*postage.StampIssuer {
return []*postage.StampIssuer{m.i}
m.issuerLock.Lock()
defer m.issuerLock.Unlock()
issuers := []*postage.StampIssuer{}
for _, v := range m.issuersMap {
issuers = append(issuers, v)
}
return issuers
}
func (m *mockPostage) GetStampIssuer(id []byte) (*postage.StampIssuer, error) {
......@@ -58,11 +74,14 @@ func (m *mockPostage) GetStampIssuer(id []byte) (*postage.StampIssuer, error) {
return postage.NewStampIssuer("test fallback", "test identity", id, big.NewInt(3), 24, 6, 1000, true), nil
}
if m.i != nil {
return m.i, nil
}
m.issuerLock.Lock()
defer m.issuerLock.Unlock()
return nil, errors.New("stampissuer not found")
i, exists := m.issuersMap[string(id)]
if !exists {
return nil, errors.New("stampissuer not found")
}
return i, nil
}
func (m *mockPostage) IssuerUsable(_ *postage.StampIssuer) bool {
......
......@@ -14,6 +14,10 @@ import (
"github.com/ethersphere/bee/pkg/storage"
"github.com/syndtr/goleveldb/leveldb"
ldberr "github.com/syndtr/goleveldb/leveldb/errors"
ldb "github.com/syndtr/goleveldb/leveldb"
ldbs "github.com/syndtr/goleveldb/leveldb/storage"
"github.com/syndtr/goleveldb/leveldb/util"
)
......@@ -25,6 +29,24 @@ type store struct {
logger logging.Logger
}
func NewInMemoryStateStore(l logging.Logger) (storage.StateStorer, error) {
ldb, err := ldb.Open(ldbs.NewMemStorage(), nil)
if err != nil {
return nil, err
}
s := &store{
db: ldb,
logger: l,
}
if err := migrate(s); err != nil {
return nil, err
}
return s, nil
}
// NewStateStore creates a new persistent state storage.
func NewStateStore(path string, l logging.Logger) (storage.StateStorer, error) {
db, err := leveldb.OpenFile(path, nil)
......@@ -46,26 +68,34 @@ func NewStateStore(path string, l logging.Logger) (storage.StateStorer, error) {
logger: l,
}
if err := migrate(s); err != nil {
return nil, err
}
return s, nil
}
func migrate(s *store) error {
sn, err := s.getSchemaName()
if err != nil {
if !errors.Is(err, storage.ErrNotFound) {
_ = s.Close()
return nil, fmt.Errorf("get schema name: %w", err)
return fmt.Errorf("get schema name: %w", err)
}
// new statestore - put schema key with current name
if err := s.putSchemaName(dbSchemaCurrent); err != nil {
_ = s.Close()
return nil, fmt.Errorf("put schema name: %w", err)
return fmt.Errorf("put schema name: %w", err)
}
sn = dbSchemaCurrent
}
if err = s.migrate(sn); err != nil {
_ = s.Close()
return nil, fmt.Errorf("migrate: %w", err)
return fmt.Errorf("migrate: %w", err)
}
return s, nil
return nil
}
// Get retrieves a value of the requested key. If no results are found,
......
......@@ -182,6 +182,10 @@ func (k *Kad) generateCommonBinPrefixes() {
for j := range binPrefixes[i] {
pseudoAddrBytes := binPrefixes[i][j].Bytes()
if len(pseudoAddrBytes) < 1 {
continue
}
// flip first bit for bin
indexByte, posBit := i/8, i%8
if hasBit(bits.Reverse8(pseudoAddrBytes[indexByte]), uint8(posBit)) {
......
......@@ -78,7 +78,7 @@ func (m *transactionServiceMock) StoredTransaction(txHash common.Hash) (*transac
}
func (m *transactionServiceMock) CancelTransaction(ctx context.Context, originalTxHash common.Hash) (common.Hash, error) {
if m.send != nil {
if m.cancelTransaction != nil {
return m.cancelTransaction(ctx, originalTxHash)
}
return common.Hash{}, errors.New("not implemented")
......@@ -133,6 +133,12 @@ func WithResendTransactionFunc(f func(ctx context.Context, txHash common.Hash) e
})
}
func WithCancelTransactionFunc(f func(ctx context.Context, originalTxHash common.Hash) (common.Hash, error)) Option {
return optionFunc(func(s *transactionServiceMock) {
s.cancelTransaction = f
})
}
func New(opts ...Option) transaction.Service {
mock := new(transactionServiceMock)
for _, o := range opts {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment