From 5fadf0fbfb5e0e07c88dbc190aef130f52cef853 Mon Sep 17 00:00:00 2001 From: chris-4chain <152964795+chris-4chain@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:04:11 +0200 Subject: [PATCH] feat(SPV-1075) new broadcaster (#737) --- .../fixture_spvwallet_application.go | 3 +- actions/transactions/broadcast_callback.go | 2 +- config.example.yaml | 10 +- config/config.go | 6 +- config/config_to_options.go | 85 ++++--- config/defaults.go | 2 +- config/validate_app_config.go | 4 + config/validate_arc.go | 4 - config/validate_arc_test.go | 28 -- config/validate_customfee.go | 18 ++ config/validate_customfee_test.go | 98 +++++++ engine/action_transaction.go | 4 +- engine/action_transaction_test.go | 6 +- engine/chain/chain_service.go | 19 +- engine/chain/errors/arc_errors.go | 33 +++ engine/chain/errors/junglebus_errors.go | 3 + engine/chain/errors/tx_getter_errors.go | 6 + engine/chain/fee_unit.go | 21 ++ engine/chain/interface.go | 5 +- engine/chain/internal/arc/arc_mock_test.go | 106 -------- engine/chain/internal/arc/broadcast.go | 94 +++++++ engine/chain/internal/arc/broadcast_test.go | 164 ++++++++++++ .../arc/{policy_test.go => fee_unit_test.go} | 53 ++-- engine/chain/internal/arc/mock_test.go | 240 ++++++++++++++++++ engine/chain/internal/arc/policy.go | 16 +- .../arc/policy_model.go} | 2 +- .../chain/internal/arc/query_transaction.go | 10 +- .../internal/arc/query_transaction_test.go | 24 +- engine/chain/internal/arc/request.go | 5 +- engine/chain/internal/arc/service.go | 37 ++- engine/chain/internal/bhs/service.go | 2 +- engine/chain/internal/combined_txs_getter.go | 76 ++++++ .../internal/combined_txs_getter_test.go | 143 +++++++++++ engine/chain/internal/ef/converter.go | 11 +- .../internal/junglebus/fetch_transaction.go | 24 +- .../junglebus/fetch_transaction_test.go | 76 ------ .../internal/junglebus/junglebus_mock_test.go | 67 ----- engine/chain/internal/junglebus/txs_getter.go | 32 +++ engine/chain/models/arc_config.go | 21 ++ engine/chain/models/arc_error.go | 3 + engine/chain/models/tx_info.go | 16 +- engine/chain/models/txstatus.go | 22 +- engine/chainstate/arc_deafult.go | 9 - engine/chainstate/broadcast.go | 95 ------- engine/chainstate/broadcast_client_init.go | 42 --- engine/chainstate/broadcast_providers.go | 81 ------ engine/chainstate/broadcast_test.go | 80 ------ engine/chainstate/broadcast_utils.go | 56 ---- engine/chainstate/chainstate.go | 85 ------- engine/chainstate/chainstate_test.go | 20 -- engine/chainstate/client.go | 146 ----------- engine/chainstate/client_options.go | 131 ---------- engine/chainstate/client_options_test.go | 238 ----------------- engine/chainstate/client_test.go | 88 ------- engine/chainstate/definitions.go | 144 ----------- engine/chainstate/interface.go | 42 --- engine/chainstate/mock_const.go | 19 -- engine/chainstate/network.go | 30 --- engine/chainstate/network_test.go | 39 --- engine/chainstate/requirements.go | 33 --- engine/chainstate/transaction.go | 68 ----- engine/chainstate/transaction_info.go | 16 -- engine/chainstate/transaction_test.go | 132 ---------- engine/chainstate/types.go | 19 -- engine/client.go | 61 +---- engine/client_internal.go | 25 +- engine/client_options.go | 92 +------ engine/client_options_test.go | 32 +-- engine/ef_tx.go | 75 ------ .../broadcast_miners/broadcast_miners.go | 41 --- .../examples/client/chainstate/chainstate.go | 25 -- .../client/custom_rates/custom_rates.go | 58 ----- engine/interface.go | 7 +- engine/model_draft_transactions.go | 6 +- engine/model_draft_transactions_test.go | 22 +- engine/model_transaction_config.go | 2 +- engine/model_transaction_config_test.go | 3 +- engine/model_transactions.go | 13 +- engine/model_transactions_test.go | 2 - engine/model_utxos.go | 3 +- engine/paymail/paymail_test.go | 11 +- engine/record_tx_strategy_outgoing_tx.go | 7 +- engine/sdk_tx_getter.go | 59 +++++ engine/spv_wallet_engine_suite_test.go | 22 +- engine/spv_wallet_engine_test.go | 58 +---- engine/spverrors/definitions.go | 32 +-- engine/tx_broadcast.go | 34 +-- engine/tx_sync_task.go | 21 +- go.mod | 2 - go.sum | 3 - mappings/fee_unit.go | 4 +- mappings/fee_unit_old.go | 4 +- models/bsv/fee_unit.go | 8 +- tests/tests.go | 3 +- 94 files changed, 1356 insertions(+), 2593 deletions(-) create mode 100644 config/validate_customfee.go create mode 100644 config/validate_customfee_test.go create mode 100644 engine/chain/errors/arc_errors.go create mode 100644 engine/chain/errors/tx_getter_errors.go create mode 100644 engine/chain/fee_unit.go delete mode 100644 engine/chain/internal/arc/arc_mock_test.go create mode 100644 engine/chain/internal/arc/broadcast.go create mode 100644 engine/chain/internal/arc/broadcast_test.go rename engine/chain/internal/arc/{policy_test.go => fee_unit_test.go} (61%) create mode 100644 engine/chain/internal/arc/mock_test.go rename engine/chain/{models/policy.go => internal/arc/policy_model.go} (97%) create mode 100644 engine/chain/internal/combined_txs_getter.go create mode 100644 engine/chain/internal/combined_txs_getter_test.go delete mode 100644 engine/chain/internal/junglebus/fetch_transaction_test.go delete mode 100644 engine/chain/internal/junglebus/junglebus_mock_test.go create mode 100644 engine/chain/internal/junglebus/txs_getter.go delete mode 100644 engine/chainstate/arc_deafult.go delete mode 100644 engine/chainstate/broadcast.go delete mode 100644 engine/chainstate/broadcast_client_init.go delete mode 100644 engine/chainstate/broadcast_providers.go delete mode 100644 engine/chainstate/broadcast_test.go delete mode 100644 engine/chainstate/broadcast_utils.go delete mode 100644 engine/chainstate/chainstate.go delete mode 100644 engine/chainstate/chainstate_test.go delete mode 100644 engine/chainstate/client.go delete mode 100644 engine/chainstate/client_options.go delete mode 100644 engine/chainstate/client_options_test.go delete mode 100644 engine/chainstate/client_test.go delete mode 100644 engine/chainstate/definitions.go delete mode 100644 engine/chainstate/interface.go delete mode 100644 engine/chainstate/mock_const.go delete mode 100644 engine/chainstate/network.go delete mode 100644 engine/chainstate/network_test.go delete mode 100644 engine/chainstate/requirements.go delete mode 100644 engine/chainstate/transaction.go delete mode 100644 engine/chainstate/transaction_info.go delete mode 100644 engine/chainstate/transaction_test.go delete mode 100644 engine/chainstate/types.go delete mode 100644 engine/ef_tx.go delete mode 100644 engine/examples/client/broadcast_miners/broadcast_miners.go delete mode 100644 engine/examples/client/chainstate/chainstate.go delete mode 100644 engine/examples/client/custom_rates/custom_rates.go create mode 100644 engine/sdk_tx_getter.go diff --git a/actions/testabilities/fixture_spvwallet_application.go b/actions/testabilities/fixture_spvwallet_application.go index 39c4f9e3..b53a8d93 100644 --- a/actions/testabilities/fixture_spvwallet_application.go +++ b/actions/testabilities/fixture_spvwallet_application.go @@ -188,8 +188,7 @@ func getConfigForTests() *config.AppConfig { cfg.DebugProfiling = false - cfg.ARC.UseFeeQuotes = false - cfg.ARC.FeeUnit = &config.FeeUnitConfig{ + cfg.CustomFeeUnit = &config.FeeUnitConfig{ Satoshis: 1, Bytes: 1000, } diff --git a/actions/transactions/broadcast_callback.go b/actions/transactions/broadcast_callback.go index f967446c..c44c9e45 100644 --- a/actions/transactions/broadcast_callback.go +++ b/actions/transactions/broadcast_callback.go @@ -3,7 +3,7 @@ package transactions import ( "net/http" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/server/reqctx" "github.com/gin-gonic/gin" diff --git a/config.example.yaml b/config.example.yaml index 93eabb2d..17e30b04 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -72,12 +72,10 @@ arc: host: https://example.com # token to authenticate callback calls - default callback token will be generated from the Admin Key _token: 44a82509 - # use fee quotes for transaction fee calculation - use_fee_quotes: true - # used as the fee value if 'use_fee_quotes' is set to false - fee_unit: - satoshis: 1 - bytes: 1000 +# custom fee unit used for calculating fees (if not set, a unit from ARC policy will be used) +_custom_fee_unit: + satoshis: 1 + bytes: 1000 notifications: enabled: false block_headers_service: diff --git a/config/config.go b/config/config.go index 8cfad244..c6144bbe 100644 --- a/config/config.go +++ b/config/config.go @@ -61,6 +61,8 @@ type AppConfig struct { DisableITC bool `json:"disable_itc" mapstructure:"disable_itc"` // RequestLogging is flag for enabling logging in go-api-router. RequestLogging bool `json:"request_logging" mapstructure:"request_logging"` + // CustomFeeUnit + CustomFeeUnit *FeeUnitConfig `json:"custom_fee_unit" mapstructure:"custom_fee_unit"` } // AuthenticationConfig is the configuration for Authentication @@ -144,11 +146,9 @@ type DatastoreConfig struct { // ARCConfig consists of blockchain nodes (Arc) configuration type ARCConfig struct { Callback *CallbackConfig `json:"callback" mapstructure:"callback"` - FeeUnit *FeeUnitConfig `json:"fee_unit" mapstructure:"fee_unit"` DeploymentID string `json:"deployment_id" mapstructure:"deployment_id"` Token string `json:"token" mapstructure:"token"` URL string `json:"url" mapstructure:"url"` - UseFeeQuotes bool `json:"use_fee_quotes" mapstructure:"use_fee_quotes"` } // FeeUnitConfig reflects the utils.FeeUnit struct with proper annotations for json and mapstructure @@ -241,6 +241,8 @@ type ExperimentalConfig struct { PikeContactsEnabled bool `json:"pike_contacts_enabled" mapstructure:"pike_contacts_enabled"` // PikePaymentEnabled is a flag for enabling Pike payment capability. PikePaymentEnabled bool `json:"pike_payment_enabled" mapstructure:"pike_payment_enabled"` + // Use junglebus external service to fetch missing source transactions for inputs + UseJunglebus bool `json:"use_junglebus" mapstructure:"use_junglebus"` } // GetUserAgent will return the outgoing user agent diff --git a/config/config_to_options.go b/config/config_to_options.go index 3cccf48d..a70c563a 100644 --- a/config/config_to_options.go +++ b/config/config_to_options.go @@ -6,14 +6,15 @@ import ( "net/url" "time" - broadcastclient "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client" "github.com/bitcoin-sv/spv-wallet/engine" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/cluster" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" "github.com/bitcoin-sv/spv-wallet/engine/utils" "github.com/bitcoin-sv/spv-wallet/metrics" + "github.com/bitcoin-sv/spv-wallet/models/bsv" "github.com/go-redis/redis/v8" "github.com/go-resty/resty/v2" "github.com/mrz1836/go-cachestore" @@ -48,15 +49,13 @@ func (c *AppConfig) ToEngineOptions(logger zerolog.Logger) (options []engine.Cli options = c.addNotificationOpts(options) - options = c.addARCOpts(options) + if options, err = c.addARCOpts(options); err != nil { + return nil, err + } options = c.addBHSOpts(options) - options = c.addBroadcastClientOpts(options, logger) - - if options, err = c.addCallbackOpts(options); err != nil { - return nil, err - } + options = c.addCustomFeeUnit(options) return options, nil } @@ -69,6 +68,17 @@ func (c *AppConfig) addHttpClientOpts(options []engine.ClientOps) []engine.Clien return append(options, engine.WithHTTPClient(client)) } +func (c *AppConfig) addCustomFeeUnit(options []engine.ClientOps) []engine.ClientOps { + if c.CustomFeeUnit != nil { + options = append(options, engine.WithCustomFeeUnit(bsv.FeeUnit{ + Satoshis: bsv.Satoshis(c.CustomFeeUnit.Satoshis), + Bytes: c.CustomFeeUnit.Bytes, + })) + } + + return options +} + func (c *AppConfig) addUserAgentOpts(options []engine.ClientOps) []engine.ClientOps { return append(options, engine.WithUserAgent(c.GetUserAgent())) } @@ -250,44 +260,35 @@ func (c *AppConfig) addNotificationOpts(options []engine.ClientOps) []engine.Cli return options } -func (c *AppConfig) addARCOpts(options []engine.ClientOps) []engine.ClientOps { - return append(options, engine.WithARC(c.ARC.URL, c.ARC.Token, c.ARC.DeploymentID)) -} - -func (c *AppConfig) addBHSOpts(options []engine.ClientOps) []engine.ClientOps { - return append(options, engine.WithBHS(c.BHS.URL, c.BHS.AuthToken)) -} - -func (c *AppConfig) addBroadcastClientOpts(options []engine.ClientOps, logger zerolog.Logger) []engine.ClientOps { - bcLogger := logger.With().Str("service", "broadcast-client").Logger() - - broadcastClient := broadcastclient.Builder(). - WithArc(broadcastclient.ArcClientConfig{ - Token: c.ARC.Token, - APIUrl: c.ARC.URL, - DeploymentID: c.ARC.DeploymentID, - }, &bcLogger). - Build() - - return append( - options, - engine.WithBroadcastClient(broadcastClient), - ) -} - -func (c *AppConfig) addCallbackOpts(options []engine.ClientOps) ([]engine.ClientOps, error) { - if !c.ARC.Callback.Enabled { - return options, nil +func (c *AppConfig) addARCOpts(options []engine.ClientOps) ([]engine.ClientOps, error) { + arcCfg := chainmodels.ARCConfig{ + URL: c.ARC.URL, + Token: c.ARC.Token, + DeploymentID: c.ARC.DeploymentID, } - if c.ARC.Callback.Token == "" { - callbackToken, err := utils.HashAdler32(DefaultAdminXpub) - if err != nil { - return nil, spverrors.Wrapf(err, "error while generating callback token") + if c.ARC.Callback.Enabled { + var err error + if c.ARC.Callback.Token == "" { + // This also sets the token to the config reference and, it is used in the callbacktoken_middleware + // TODO: consider moving config modification to a PostLoad method and make this ToEngineOptions pure (no side effects) + if c.ARC.Callback.Token, err = utils.HashAdler32(DefaultAdminXpub); err != nil { + return nil, spverrors.Wrapf(err, "error while generating callback token") + } + } + arcCfg.Callback = &chainmodels.ARCCallbackConfig{ + URL: c.ARC.Callback.Host, + Token: c.ARC.Callback.Token, } - c.ARC.Callback.Token = callbackToken } - options = append(options, engine.WithCallback(c.ARC.Callback.Host+BroadcastCallbackRoute, c.ARC.Callback.Token)) - return options, nil + if c.ExperimentalFeatures != nil && c.ExperimentalFeatures.UseJunglebus { + arcCfg.UseJunglebus = true + } + + return append(options, engine.WithARC(arcCfg)), nil +} + +func (c *AppConfig) addBHSOpts(options []engine.ClientOps) []engine.ClientOps { + return append(options, engine.WithBHS(c.BHS.URL, c.BHS.AuthToken)) } diff --git a/config/defaults.go b/config/defaults.go index f45423b2..a3e9cf63 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -33,6 +33,7 @@ func GetDefaultAppConfig() *AppConfig { TaskManager: getTaskManagerDefault(), Metrics: getMetricsDefaults(), ExperimentalFeatures: getExperimentalFeaturesConfig(), + CustomFeeUnit: nil, } } @@ -107,7 +108,6 @@ func getARCDefaults() *ARCConfig { DeploymentID: "spv-wallet-" + depIDSufix.String(), URL: "https://arc.taal.com", Token: "mainnet_06770f425eb00298839a24a49cbdc02c", - UseFeeQuotes: true, Callback: &CallbackConfig{ Enabled: false, Host: "https://example.com", diff --git a/config/validate_app_config.go b/config/validate_app_config.go index 16486c8c..4589ccc4 100644 --- a/config/validate_app_config.go +++ b/config/validate_app_config.go @@ -32,5 +32,9 @@ func (c *AppConfig) Validate() error { return err } + if err = c.CustomFeeUnit.Validate(); err != nil { + return err + } + return nil } diff --git a/config/validate_arc.go b/config/validate_arc.go index bf7f168a..e6242bcd 100644 --- a/config/validate_arc.go +++ b/config/validate_arc.go @@ -26,10 +26,6 @@ func (n *ARCConfig) Validate() error { return spverrors.Newf("invalid callback host: %s - must be a valid external url - not a localhost", n.Callback.Host) } - if !n.UseFeeQuotes && n.FeeUnit == nil { - return spverrors.Newf("fee unit is not configured, define nodes.fee_unit or set nodes.use_fee_quotes") - } - return nil } diff --git a/config/validate_arc_test.go b/config/validate_arc_test.go index 294aef05..93e1b9ec 100644 --- a/config/validate_arc_test.go +++ b/config/validate_arc_test.go @@ -105,32 +105,4 @@ func TestValidateArcConfig(t *testing.T) { require.Error(t, err) }) } - - t.Run("fee unit must be set if not using fee quotes", func(t *testing.T) { - // given: - cfg := config.GetDefaultAppConfig() - - cfg.ARC.UseFeeQuotes = false - cfg.ARC.FeeUnit = nil - - // when: - err := cfg.Validate() - - // then: - require.Error(t, err) - }) - - t.Run("fee unit can be not set when using fee quotes", func(t *testing.T) { - // given: - cfg := config.GetDefaultAppConfig() - - cfg.ARC.UseFeeQuotes = true - cfg.ARC.FeeUnit = nil - - // when: - err := cfg.Validate() - - // then: - require.NoError(t, err) - }) } diff --git a/config/validate_customfee.go b/config/validate_customfee.go new file mode 100644 index 00000000..023b5d82 --- /dev/null +++ b/config/validate_customfee.go @@ -0,0 +1,18 @@ +package config + +import "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + +// Validate validates the custom fee unit configuration +func (cf *FeeUnitConfig) Validate() error { + if cf == nil { + return nil + } + + if cf.Bytes <= 0 { + return spverrors.Newf("invalid custom fee unit - bytes value is equal or less than zero: %d", cf.Bytes) + } + if cf.Satoshis < 0 { + return spverrors.Newf("invalid custom fee unit - satoshis value is less than zero: %d", cf.Satoshis) + } + return nil +} diff --git a/config/validate_customfee_test.go b/config/validate_customfee_test.go new file mode 100644 index 00000000..a3fbe254 --- /dev/null +++ b/config/validate_customfee_test.go @@ -0,0 +1,98 @@ +package config_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/config" + "github.com/stretchr/testify/require" +) + +func TestValidateFeeUnit(t *testing.T) { + validConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "Not defined is valid": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = nil + }, + }, + "Standard": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = &config.FeeUnitConfig{ + Satoshis: 1, + Bytes: 1000, + } + }, + }, + "Zero Satoshi is valid": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = &config.FeeUnitConfig{ + Satoshis: 0, + Bytes: 1000, + } + }, + }, + } + for name, test := range validConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) + }) + } + + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "Empty is not ok": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = &config.FeeUnitConfig{} + }, + }, + "Negative satoshis": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = &config.FeeUnitConfig{ + Satoshis: -1, + Bytes: 1000, + } + }, + }, + "Zero bytes": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = &config.FeeUnitConfig{ + Satoshis: 1, + Bytes: 0, + } + }, + }, + "Negative bytes": { + scenario: func(cfg *config.AppConfig) { + cfg.CustomFeeUnit = &config.FeeUnitConfig{ + Satoshis: 1, + Bytes: -1, + } + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } +} diff --git a/engine/action_transaction.go b/engine/action_transaction.go index a723d74a..a7f9a940 100644 --- a/engine/action_transaction.go +++ b/engine/action_transaction.go @@ -6,7 +6,7 @@ import ( "time" trx "github.com/bitcoin-sv/go-sdk/transaction" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/utils" @@ -105,7 +105,7 @@ func (c *Client) GetTransactionsByIDs(ctx context.Context, txIDs []string) ([]*T // Create the conditions conditions := generateTxIDFilterConditions(txIDs) - // Get the transactions by it's IDs + // Get the transactions by its IDs transactions, err := getTransactions( ctx, nil, conditions, nil, c.DefaultModelOptions()..., diff --git a/engine/action_transaction_test.go b/engine/action_transaction_test.go index ed8dc942..c4e83861 100644 --- a/engine/action_transaction_test.go +++ b/engine/action_transaction_test.go @@ -5,7 +5,6 @@ import ( "fmt" "testing" - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" compat "github.com/bitcoin-sv/go-sdk/compat/bip32" "github.com/bitcoin-sv/spv-wallet/engine/utils" "github.com/bitcoin-sv/spv-wallet/models/bsv" @@ -14,11 +13,8 @@ import ( ) func Test_RevertTransaction(t *testing.T) { - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockNilQueryTxResp). - Build() t.Run("revert transaction", func(t *testing.T) { - ctx, client, transaction, _, deferMe := initRevertTransactionData(t, WithBroadcastClient(bc)) + ctx, client, transaction, _, deferMe := initRevertTransactionData(t) defer deferMe() // diff --git a/engine/chain/chain_service.go b/engine/chain/chain_service.go index 913502bc..39a6dddc 100644 --- a/engine/chain/chain_service.go +++ b/engine/chain/chain_service.go @@ -1,15 +1,19 @@ package chain import ( + "github.com/bitcoin-sv/spv-wallet/engine/chain/internal" "github.com/bitcoin-sv/spv-wallet/engine/chain/internal/arc" "github.com/bitcoin-sv/spv-wallet/engine/chain/internal/bhs" + "github.com/bitcoin-sv/spv-wallet/engine/chain/internal/junglebus" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/go-resty/resty/v2" "github.com/rs/zerolog" ) +type arcService = arc.Service + type chainService struct { - ARCService + *arcService BHSService } @@ -19,8 +23,21 @@ func NewChainService(logger zerolog.Logger, httpClient *resty.Client, arcCfg cha panic("httpClient is required") } + if arcCfg.UseJunglebus { + addJunglebusTxsGetter(logger, httpClient, &arcCfg) + } + return &chainService{ arc.NewARCService(logger.With().Str("chain", "arc").Logger(), httpClient, arcCfg), bhs.NewBHSService(logger.With().Str("chain", "bhs").Logger(), httpClient, bhsConf), } } + +func addJunglebusTxsGetter(logger zerolog.Logger, httpClient *resty.Client, arcCfg *chainmodels.ARCConfig) { + junglebusTxsGetter := junglebus.NewJunglebusService(logger.With().Str("service", "junglebus").Logger(), httpClient) + + arcCfg.TxsGetter = internal.CombineTxsGetters( + arcCfg.TxsGetter, + junglebusTxsGetter, + ) +} diff --git a/engine/chain/errors/arc_errors.go b/engine/chain/errors/arc_errors.go new file mode 100644 index 00000000..0b138847 --- /dev/null +++ b/engine/chain/errors/arc_errors.go @@ -0,0 +1,33 @@ +package chainerrors + +import "github.com/bitcoin-sv/spv-wallet/models" + +// ErrARCUnreachable is when ARC cannot be requested +var ErrARCUnreachable = models.SPVError{Message: "ARC cannot be requested", StatusCode: 500, Code: "error-arc-unreachable"} + +// ErrARCUnauthorized is when ARC returns unauthorized +var ErrARCUnauthorized = models.SPVError{Message: "ARC returned unauthorized", StatusCode: 500, Code: "error-arc-unauthorized"} + +// ErrARCGenericError is when ARC returns generic error (according to documentation - status code: 409) +var ErrARCGenericError = models.SPVError{Message: "ARC returned generic error", StatusCode: 500, Code: "error-arc-generic-error"} + +// ErrARCUnsupportedStatusCode is when ARC returns unsupported status code +var ErrARCUnsupportedStatusCode = models.SPVError{Message: "ARC returned unsupported status code", StatusCode: 500, Code: "error-arc-unsupported-status-code"} + +// ErrARCUnprocessable is when ARC rejects because provided tx cannot be processed +var ErrARCUnprocessable = models.SPVError{Message: "ARC cannot process provided transaction", StatusCode: 500, Code: "error-arc-unprocessable-tx"} + +// ErrARCNotExtendedFormat is when ARC rejects transaction which is not in extended format +var ErrARCNotExtendedFormat = models.SPVError{Message: "ARC expects transaction in extended format", StatusCode: 500, Code: "error-arc-not-extended-format"} + +// ErrARCWrongFee is when ARC rejects transaction because of wrong fee +var ErrARCWrongFee = models.SPVError{Message: "ARC rejected transaction because of wrong fee", StatusCode: 500, Code: "error-arc-wrong-fee"} + +// ErrARCProblematicStatus is when ARC returns problematic status +var ErrARCProblematicStatus = models.SPVError{Message: "ARC returned problematic status", StatusCode: 500, Code: "error-arc-problematic-status"} + +// ErrGetFeeUnit is when fee unit cannot be retrieved +var ErrGetFeeUnit = models.SPVError{Message: "Fee unit cannot be retrieved", StatusCode: 500, Code: "error-get-fee-unit"} + +// ErrEFConversion is when EF conversion fails +var ErrEFConversion = models.SPVError{Message: "EF conversion failed", StatusCode: 500, Code: "error-ef-conversion"} diff --git a/engine/chain/errors/junglebus_errors.go b/engine/chain/errors/junglebus_errors.go index b95a11ef..d141c71a 100644 --- a/engine/chain/errors/junglebus_errors.go +++ b/engine/chain/errors/junglebus_errors.go @@ -7,3 +7,6 @@ var ErrJunglebusFailure = models.SPVError{Message: "junglebus failed to return t // ErrJunglebusParseTransaction is when we can't parse transaction from junglebus response var ErrJunglebusParseTransaction = models.SPVError{Message: "failed to parse transaction from junglebus response", StatusCode: 500, Code: "error-junglebus-parse-transaction"} + +// ErrJunglebusTxNotFound is when transaction is not found in junglebus +var ErrJunglebusTxNotFound = models.SPVError{Message: "transaction not found in junglebus", StatusCode: 404, Code: "error-junglebus-tx-not-found"} diff --git a/engine/chain/errors/tx_getter_errors.go b/engine/chain/errors/tx_getter_errors.go new file mode 100644 index 00000000..80b46ce6 --- /dev/null +++ b/engine/chain/errors/tx_getter_errors.go @@ -0,0 +1,6 @@ +package chainerrors + +import "github.com/bitcoin-sv/spv-wallet/models" + +// ErrGetTransactionsByTxsGetter is when error occurred during getting transactions +var ErrGetTransactionsByTxsGetter = models.SPVError{Message: "error getting transactions during collecting transactions for Txs getter", StatusCode: 500, Code: "error-get-transactions-txs-getter"} diff --git a/engine/chain/fee_unit.go b/engine/chain/fee_unit.go new file mode 100644 index 00000000..07a75a4b --- /dev/null +++ b/engine/chain/fee_unit.go @@ -0,0 +1,21 @@ +package chain + +import ( + "context" + + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/models/bsv" +) + +// GetFeeUnit returns the current fee unit from the ARC policy. +func (s *chainService) GetFeeUnit(ctx context.Context) (*bsv.FeeUnit, error) { + policy, err := s.arcService.GetPolicy(ctx) + if err != nil { + return nil, chainerrors.ErrGetFeeUnit.Wrap(err) + } + + return &bsv.FeeUnit{ + Satoshis: policy.Content.MiningFee.Satoshis, + Bytes: policy.Content.MiningFee.Bytes, + }, nil +} diff --git a/engine/chain/interface.go b/engine/chain/interface.go index 11649e88..1f8e980b 100644 --- a/engine/chain/interface.go +++ b/engine/chain/interface.go @@ -5,14 +5,17 @@ import ( "net/url" "github.com/bitcoin-sv/go-paymail/spv" + sdk "github.com/bitcoin-sv/go-sdk/transaction" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/bsv" ) // ARCService for querying ARC server. type ARCService interface { QueryTransaction(ctx context.Context, txID string) (*chainmodels.TXInfo, error) - GetPolicy(ctx context.Context) (*chainmodels.Policy, error) + GetFeeUnit(ctx context.Context) (*bsv.FeeUnit, error) + Broadcast(ctx context.Context, tx *sdk.Transaction) (*chainmodels.TXInfo, error) } // BHSService for querying BHS server. diff --git a/engine/chain/internal/arc/arc_mock_test.go b/engine/chain/internal/arc/arc_mock_test.go deleted file mode 100644 index 65d40ab0..00000000 --- a/engine/chain/internal/arc/arc_mock_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package arc_test - -import ( - "fmt" - "net/http" - "time" - - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/go-resty/resty/v2" - "github.com/jarcoal/httpmock" -) - -const minedTxID = "4dff1d32c1a02d7797e33d7c4ab2f96fe6699005b6d79e6391bdf5e358232e06" -const unknownTxID = "aaaa1d32c1a02d7797e33d7c4ab2f96fe6699005b6d79e6391bdf5e358232e06" -const wrongButReachable = "/wrong/url" -const arcURL = "https://arc.taal.com" -const arcToken = "mainnet_06770f425eb00298839a24a49cbdc02c" -const invalidTxID = "invalid" - -func arcMockActivate(applyTimeout bool) *resty.Client { - transport := httpmock.NewMockTransport() - client := resty.New() - client.GetClient().Transport = transport - - responder := func(status int, content string) func(req *http.Request) (*http.Response, error) { - return func(req *http.Request) (*http.Response, error) { - if applyTimeout { - time.Sleep(100 * time.Millisecond) - } - if req.Header.Get("Authorization") != arcToken { - return httpmock.NewStringResponse(http.StatusUnauthorized, ""), nil - } - res := httpmock.NewStringResponse(status, content) - res.Header.Set("Content-Type", "application/json") - return res, nil - } - } - - transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/tx/%s", arcURL, minedTxID), responder(http.StatusOK, `{ - "blockHash": "0000000000000000034df47d8fe84ccf10267b4f6bc43be513d4604229d1c209", - "blockHeight": 862510, - "competingTxs": null, - "extraInfo": "", - "merklePath": "fe2e290d00080231006449ce1869e63013f9b3ad17151fe0fe37091c47fd9a70e03dddeb6a64a5592c3002062e2358e3f5bd91639ed7b6059069e66ff9b24a7c3de397772da0c1321dff4d011900bcab35ce0c50582723db10783f8b48f1f3165203ffb0644b91fd0d6cb4d6190f010d003e1c17e035a1248377cf3371863c853892283ef032abc53d8427b0b196368aec010700cf74d836ab526d6b3ef705f4adc1121724b886f6c6a79ccf080c1a0bbce712570102005a7c6ac761a529dc616656cf187b354516752372823f574706c61747741ac3d4010000d58c14f52200fd9d9e2ef87c993c99c2f28a636ebbfe88a0097066b5f10bc3a5010100e74bf2106c1b378d72b2e8e4f82646f955e0b6b9955505f7f3cddebce3ab733801010056365352ba7e5578ff8249905d25e272c540472276163b27b0e9c6d4d26b7d0e", - "timestamp": "2024-09-27T06:11:41.417057192Z", - "txStatus": "MINED", - "txid": "4dff1d32c1a02d7797e33d7c4ab2f96fe6699005b6d79e6391bdf5e358232e06" - }`), - ) - - transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/tx/%s", arcURL, unknownTxID), responder(http.StatusNotFound, `{ - "detail": "The requested resource could not be found", - "extraInfo": "transaction not found", - "instance": null, - "status": 404, - "title": "Not found", - "txid": null, - "type": "https://bitcoin-sv.github.io/arc/#/errors?id=_404" - }`), - ) - - transport.RegisterResponder("GET", arcURL+wrongButReachable, responder(http.StatusNotFound, `{ - "message": "no matching operation was found" - }`), - ) - - transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/tx/%s", arcURL, invalidTxID), responder(http.StatusConflict, `{ - "detail": "Transaction could not be processed", - "extraInfo": "rpc error: code = Unknown desc = encoding/hex: invalid byte: U+0073 's'", - "instance": null, - "status": 409, - "title": "Generic error", - "txid": null, - "type": "https://bitcoin-sv.github.io/arc/#/errors?id=_409" - }`), - ) - - transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/policy", arcURL), responder(http.StatusOK, `{ - "policy": { - "maxscriptsizepolicy": 100000000, - "maxtxsigopscountspolicy": 4294967295, - "maxtxsizepolicy": 100000000, - "miningFee": { - "bytes": 1000, - "satoshis": 1 - } - }, - "timestamp": "2024-10-02T07:36:33.589144918Z" - }`), - ) - - transport.RegisterResponder("GET", arcURL+wrongButReachable, responder(http.StatusNotFound, `{ - "message": "no matching operation was found" - }`), - ) - - return client -} - -func arcCfg(url, token string) chainmodels.ARCConfig { - return chainmodels.ARCConfig{ - URL: url, - Token: token, - DeploymentID: "spv-wallet-test-arc-connection", - } -} diff --git a/engine/chain/internal/arc/broadcast.go b/engine/chain/internal/arc/broadcast.go new file mode 100644 index 00000000..a9789173 --- /dev/null +++ b/engine/chain/internal/arc/broadcast.go @@ -0,0 +1,94 @@ +package arc + +import ( + "context" + "errors" + "fmt" + "net/http" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/go-resty/resty/v2" +) + +// Custom ARC defined http status codes +const ( + StatusNotExtendedFormat = 460 + StatusFeeTooLow = 465 + StatusCumulativeFeeValidationFailed = 473 +) + +// Broadcast submits a transaction to the ARC server and returns the transaction info. +func (s *Service) Broadcast(ctx context.Context, tx *sdk.Transaction) (*chainmodels.TXInfo, error) { + result := &chainmodels.TXInfo{} + arcErr := &chainmodels.ArcError{} + req := s.prepareARCRequest(ctx). + SetResult(result). + SetError(arcErr) + + s.setCallbackHeaders(req) + + txHex, err := s.prepareTxHex(ctx, tx) + if err != nil { + return nil, err + } + + req.SetBody(requestBody{ + RawTx: txHex, + }) + + response, err := req.Post(fmt.Sprintf("%s/v1/tx", s.arcCfg.URL)) + + if err != nil { + return nil, s.wrapRequestError(err) + } + + switch response.StatusCode() { + case http.StatusOK: + if result.TXStatus.IsProblematic() { + return nil, chainerrors.ErrARCProblematicStatus.Wrap(spverrors.Newf("ARC Problematic tx status: %s", result.TXStatus)) + } + return result, nil + case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: + return nil, s.wrapARCError(chainerrors.ErrARCUnauthorized, arcErr) + case StatusNotExtendedFormat: + return nil, s.wrapARCError(chainerrors.ErrARCNotExtendedFormat, arcErr) + case StatusFeeTooLow, StatusCumulativeFeeValidationFailed: + return nil, s.wrapARCError(chainerrors.ErrARCWrongFee, arcErr) + default: + return nil, s.wrapARCError(chainerrors.ErrARCUnprocessable, arcErr) + } +} + +type requestBody struct { + // Even though the name suggests that it is a raw transaction, + // it is actually a hex encoded transaction + // and can be in Raw, Extended Format or BEEF format. + RawTx string `json:"rawTx"` +} + +func (s *Service) prepareTxHex(ctx context.Context, tx *sdk.Transaction) (string, error) { + efHex, err := s.efConverter.Convert(ctx, tx) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return "", spverrors.ErrCtxInterrupted.Wrap(err) + } + if err != nil { + // Log level is set to Info because it can happen in standard flow when source transaction is not from our wallet (and Junglebus is disabled) + s.logger.Info().Err(err).Msg("Could not convert transaction to EFHex. Using raw transaction hex as a fallback.") + return tx.Hex(), nil + } + return efHex, nil +} + +func (s *Service) setCallbackHeaders(req *resty.Request) { + cb := s.arcCfg.Callback + if cb != nil && cb.URL != "" { + req.SetHeader("X-CallbackUrl", cb.URL) + + if cb.Token != "" { + req.SetHeader("X-CallbackToken", cb.Token) + } + } +} diff --git a/engine/chain/internal/arc/broadcast_test.go b/engine/chain/internal/arc/broadcast_test.go new file mode 100644 index 00000000..163aedee --- /dev/null +++ b/engine/chain/internal/arc/broadcast_test.go @@ -0,0 +1,164 @@ +package arc_test + +import ( + "context" + "testing" + "time" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/tester" + "github.com/stretchr/testify/require" + "iter" +) + +func TestBroadcastTransaction(t *testing.T) { + tests := map[string]struct { + hex string + arcCfgModifier func(cfg *chainmodels.ARCConfig) + }{ + "Broadcast unsourced tx with txs getter provided": { + hex: validRawHex, + arcCfgModifier: func(cfg *chainmodels.ARCConfig) { + cfg.TxsGetter = &mockTxsGetter{ + transactions: []*sdk.Transaction{fromHex(sourceOfValidRawHex)}, + } + }, + }, + "Broadcast tx in EF with no txs getter": { + hex: efOfValidRawHex, + }, + "Broadcast unsourced tx with no txs getter - raw hex as fallback": { + hex: fallbackRawHex, + }, + "Broadcast two-missing-inputs unsourced tx with txs getter and junglebus": { + hex: txWithMultipleInputs, + arcCfgModifier: func(cfg *chainmodels.ARCConfig) { + cfg.TxsGetter = &mockTxsGetter{ + // first missing input source is provided by this txs getter (mocking getting from database) + transactions: []*sdk.Transaction{fromHex(sourceOneOfTxWithMultipleInputs)}, + } + cfg.UseJunglebus = true //second missing input source is provided by junglebus (mocked) + }, + }, + "Broadcast unsourced tx with junglebus which doesn't know the source tx - raw hex as fallback": { + hex: fallbackRawHex, + arcCfgModifier: func(cfg *chainmodels.ARCConfig) { + cfg.UseJunglebus = true + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + httpClient := mockActivate(false) + + tx, err := sdk.NewTransactionFromHex(test.hex) + require.NoError(t, err) + + cfg := arcCfg(arcURL, arcToken) + if test.arcCfgModifier != nil { + test.arcCfgModifier(&cfg) + } + + service := chain.NewChainService(tester.Logger(t), httpClient, cfg, chainmodels.BHSConfig{}) + + txInfo, err := service.Broadcast(context.Background(), tx) + require.NoError(t, err) + require.Equal(t, tx.TxID().String(), txInfo.TxID) + require.Equal(t, chainmodels.SeenOnNetwork, txInfo.TXStatus) + }) + } +} + +func TestBroadcastTransactionErrorCases(t *testing.T) { + tests := map[string]struct { + hex string + arcCfgModifier func(cfg *chainmodels.ARCConfig) + expectErr error + }{ + "Double spend attempt with 'old' UTXO": { + hex: oldWithDoubleSpentHex, + expectErr: chainerrors.ErrARCProblematicStatus, + }, + "Double spend attempt with relatively 'new' UTXO": { + hex: newWithDoubleSpentHex, + expectErr: chainerrors.ErrARCProblematicStatus, + }, + "Broadcast malformed tx": { + hex: malformedTxHex, + expectErr: chainerrors.ErrARCUnprocessable, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + httpClient := mockActivate(false) + + tx, err := sdk.NewTransactionFromHex(test.hex) + require.NoError(t, err) + + cfg := arcCfg(arcURL, arcToken) + if test.arcCfgModifier != nil { + test.arcCfgModifier(&cfg) + } + + service := chain.NewChainService(tester.Logger(t), httpClient, cfg, chainmodels.BHSConfig{}) + + txInfo, err := service.Broadcast(context.Background(), tx) + require.ErrorIs(t, err, test.expectErr) + require.Nil(t, txInfo) + }) + } +} + +func TestBroadcastTimeouts(t *testing.T) { + t.Run("Broadcast transaction interrupted by ctx timeout", func(t *testing.T) { + httpClient := mockActivate(true) + + tx, err := sdk.NewTransactionFromHex(efOfValidRawHex) + require.NoError(t, err) + + service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) + + ctx, cancel := context.WithTimeout(context.Background(), 1) + defer cancel() + + txInfo, err := service.Broadcast(ctx, tx) + + require.Error(t, err) + require.ErrorIs(t, err, chainerrors.ErrARCUnreachable) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, txInfo) + }) + + t.Run("Broadcast transaction interrupted by resty timeout", func(t *testing.T) { + httpClient := mockActivate(true) + httpClient.SetTimeout(1 * time.Millisecond) + + tx, err := sdk.NewTransactionFromHex(efOfValidRawHex) + require.NoError(t, err) + + service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) + + txInfo, err := service.Broadcast(context.Background(), tx) + + require.Error(t, err) + require.ErrorIs(t, err, chainerrors.ErrARCUnreachable) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, txInfo) + }) +} + +type mockTxsGetter struct { + transactions []*sdk.Transaction +} + +func (mtg *mockTxsGetter) GetTransactions(_ context.Context, _ iter.Seq[string]) ([]*sdk.Transaction, error) { + return mtg.transactions, nil +} + +func fromHex(hex string) *sdk.Transaction { + tx, _ := sdk.NewTransactionFromHex(hex) + return tx +} diff --git a/engine/chain/internal/arc/policy_test.go b/engine/chain/internal/arc/fee_unit_test.go similarity index 61% rename from engine/chain/internal/arc/policy_test.go rename to engine/chain/internal/arc/fee_unit_test.go index 944a8074..8e013b05 100644 --- a/engine/chain/internal/arc/policy_test.go +++ b/engine/chain/internal/arc/fee_unit_test.go @@ -6,8 +6,8 @@ import ( "time" "github.com/bitcoin-sv/spv-wallet/engine/chain" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/tester" "github.com/bitcoin-sv/spv-wallet/models/bsv" "github.com/stretchr/testify/require" @@ -17,88 +17,87 @@ import ( NOTE: switch httpClient to resty.New() tu call actual ARC server */ -func TestPolicyService(t *testing.T) { +func TestFeeUnit(t *testing.T) { t.Run("Request for policy", func(t *testing.T) { - httpClient := arcMockActivate(false) + httpClient := mockActivate(false) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) - policy, err := service.GetPolicy(context.Background()) + feeUnit, err := service.GetFeeUnit(context.Background()) require.NoError(t, err) - require.NotNil(t, policy) - require.Equal(t, 1000, policy.Content.MiningFee.Bytes) - require.Equal(t, bsv.Satoshis(1), policy.Content.MiningFee.Satoshis) - require.NotEmpty(t, policy.Timestamp) + require.NotNil(t, feeUnit) + require.Equal(t, 1000, feeUnit.Bytes) + require.Equal(t, bsv.Satoshis(1), feeUnit.Satoshis) }) } -func TestPolicyServiceErrorCases(t *testing.T) { +func TestFeeUnitErrorCases(t *testing.T) { errTestCases := map[string]struct { arcToken string arcURL string expectErr error }{ - "GetPolicy with wrong token": { + "GetFeeUnit with wrong token": { arcToken: "wrong-token", //if you test it on actual ARC server, this test might fail if the ARC doesn't require token arcURL: arcURL, - expectErr: spverrors.ErrARCUnauthorized, + expectErr: chainerrors.ErrARCUnauthorized, }, - "GetPolicy 404 endpoint but reachable": { + "GetFeeUnit 404 endpoint but reachable": { arcToken: arcToken, arcURL: arcURL + wrongButReachable, - expectErr: spverrors.ErrARCUnreachable, + expectErr: chainerrors.ErrARCUnreachable, }, - "GetPolicy 404 endpoint with wrong arcURL": { + "GetFeeUnit 404 endpoint with wrong arcURL": { arcToken: arcToken, arcURL: "wrong-url", - expectErr: spverrors.ErrARCUnreachable, + expectErr: chainerrors.ErrARCUnreachable, }, } for name, tc := range errTestCases { t.Run(name, func(t *testing.T) { - httpClient := arcMockActivate(false) + httpClient := mockActivate(false) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(tc.arcURL, tc.arcToken), chainmodels.BHSConfig{}) - policy, err := service.GetPolicy(context.Background()) + feeUnit, err := service.GetFeeUnit(context.Background()) require.Error(t, err) require.ErrorIs(t, err, tc.expectErr) - require.Nil(t, policy) + require.Nil(t, feeUnit) }) } } -func TestPolicyServiceTimeouts(t *testing.T) { +func TestFeeUnitTimeouts(t *testing.T) { t.Run("GetPolicy interrupted by ctx timeout", func(t *testing.T) { - httpClient := arcMockActivate(true) + httpClient := mockActivate(true) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) ctx, cancel := context.WithTimeout(context.Background(), 1) defer cancel() - txInfo, err := service.GetPolicy(ctx) + feeUnit, err := service.GetFeeUnit(ctx) require.Error(t, err) - require.ErrorIs(t, err, spverrors.ErrARCUnreachable) + require.ErrorIs(t, err, chainerrors.ErrARCUnreachable) require.ErrorIs(t, err, context.DeadlineExceeded) - require.Nil(t, txInfo) + require.Nil(t, feeUnit) }) t.Run("GetPolicy interrupted by resty timeout", func(t *testing.T) { - httpClient := arcMockActivate(true) + httpClient := mockActivate(true) httpClient.SetTimeout(1 * time.Millisecond) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) - txInfo, err := service.GetPolicy(context.Background()) + feeUnit, err := service.GetFeeUnit(context.Background()) require.Error(t, err) - require.ErrorIs(t, err, spverrors.ErrARCUnreachable) + require.ErrorIs(t, err, chainerrors.ErrARCUnreachable) require.ErrorIs(t, err, context.DeadlineExceeded) - require.Nil(t, txInfo) + require.Nil(t, feeUnit) }) } diff --git a/engine/chain/internal/arc/mock_test.go b/engine/chain/internal/arc/mock_test.go new file mode 100644 index 00000000..406a2e00 --- /dev/null +++ b/engine/chain/internal/arc/mock_test.go @@ -0,0 +1,240 @@ +package arc_test + +import ( + "fmt" + "net/http" + "time" + + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/go-resty/resty/v2" + "github.com/jarcoal/httpmock" +) + +const ( + minedTxID = "4dff1d32c1a02d7797e33d7c4ab2f96fe6699005b6d79e6391bdf5e358232e06" + unknownTxID = "aaaa1d32c1a02d7797e33d7c4ab2f96fe6699005b6d79e6391bdf5e358232e06" + wrongButReachable = "/wrong/url" + arcURL = "https://arc.taal.com" + arcToken = "mainnet_06770f425eb00298839a24a49cbdc02c" + invalidTxID = "invalid" +) + +// broadcast transaction cases +const ( + validRawHex = "0100000001776f9d2c0d80b612ca54ebca1fa3bd38db375756ec7778edddc323569e06dc96010000006b483045022100f83415750880cc9464b752c33215ede7568c45c83cd3ccb841787edd1219d368022065e71b09241c889529e1b979c9cc9f569263386811f0005485b2617134722dd04121020d0ace627fbf80e20ce54d8bcfa5aa41f4a6f2d9c113bac0cf1b1316a96f0c9fffffffff0201000000000000001976a9147d71e2f8ce19409fb93312c9f019a7dee3d14ea188ac0c000000000000001976a914e5a4f06c1a24c2a9ddffcae4405aaf6c203ba3d288ac00000000" + sourceOfValidRawHex = "0100000001f7af56f1d861954b4c2b5a55619e57d3ccf266a71ef9e3ff7e5adfefd8f85d30010000006a47304402200a9f2f7a1222329b97e23bc5c4def0823600495e2afa3bc6ae7be5d5bbd6aff20220525f69245f52548066c5319423578688ef46bab18b8185645dbe2587f0c9dbb341210237af69199d207ff5ce2309697644eac2babea83eadefd8d38384c8cae3e87cdfffffffff0201000000000000001976a9146fdeac955937ceec0493e6960421849636727fc088ac0e000000000000001976a9149c85e1657fb23d41e147124adef6df7933e86cb288ac00000000" + efOfValidRawHex = "010000000000000000ef01776f9d2c0d80b612ca54ebca1fa3bd38db375756ec7778edddc323569e06dc96010000006b483045022100f83415750880cc9464b752c33215ede7568c45c83cd3ccb841787edd1219d368022065e71b09241c889529e1b979c9cc9f569263386811f0005485b2617134722dd04121020d0ace627fbf80e20ce54d8bcfa5aa41f4a6f2d9c113bac0cf1b1316a96f0c9fffffffff0e000000000000001976a9149c85e1657fb23d41e147124adef6df7933e86cb288ac0201000000000000001976a9147d71e2f8ce19409fb93312c9f019a7dee3d14ea188ac0c000000000000001976a914e5a4f06c1a24c2a9ddffcae4405aaf6c203ba3d288ac00000000" + + // https://whatsonchain.com/tx/88a7c0ed1cb4767cfc8e7434561379eaea21ae78e480cacf4e69284387057c70 + txWithMultipleInputs = "01000000021b4ae503913172c5e16bd89dabb71d353c5b9cb2a1c69970fd4e690e49f97410010000006a47304402203127d53ed2ed8843d95ad0da49659e086e298dc8c4abf946656eeae1fd5c8c8602205e10f3bd2c3f01c08903c3969d138d62b1e76c96f856e2743cf3069cb4695a75412102792258b7fba50c8a1d6154f0b4be4a4e57b078efe1b47946c010697e99dde791ffffffffc88e4c870d61d7e14b8931d941c888ffb36ad58c52364e49c2df20f565dadecd010000006b483045022100c42531f0b50acab6fd1f63b30a2b1046ac29965bc0cf41409b180d3d4b91abec022006fbada6d4969de5f16297f8905a8a00763ca19a1a27ac31b1e77d128777a140412103d21e72986de0d354aff1dd737a066b6b786bc204bec22b3941e10e9575a7aa7bffffffff0214000000000000001976a914e8964298fcaa506f39e6d1d1f29657f79c1e72e788ac09000000000000001976a914e0bd3f2d5c1919109831bfad40b8eb293c07621b88ac00000000" + sourceOneOfTxWithMultipleInputs = "010000000124eebc416395164f0361f40aa2f555c26e9715d34e3c053d4e2a320465aebd71010000006a473044022018f346a2f9ef9b97d10b8771b5062a8dc689a7cf5c7dc75f4043bcf9e9c84aad022065dad45fc43b270ea6050c82a1dd28e2d03c5544e983a6b1c814a8bf76c0d79141210264250fb3346aaa01d758219d4c5707cafefe2224f4f78ee91eea50a054e5d704ffffffff0201000000000000001976a914e0842daa9d18a889c57d99aa510e5492c950bf9988ac10000000000000001976a914f8704d915ad7d2b559f61bad6c31b60deac52a3788ac00000000" + txIDOfSourceTwoOfTxWithMultipleInputs = "cddeda65f520dfc2494e36528cd56ab3ff88c841d931894be1d7610d874c8ec8" + efHexOfTxWithMultipleInputs = "010000000000000000ef021b4ae503913172c5e16bd89dabb71d353c5b9cb2a1c69970fd4e690e49f97410010000006a47304402203127d53ed2ed8843d95ad0da49659e086e298dc8c4abf946656eeae1fd5c8c8602205e10f3bd2c3f01c08903c3969d138d62b1e76c96f856e2743cf3069cb4695a75412102792258b7fba50c8a1d6154f0b4be4a4e57b078efe1b47946c010697e99dde791ffffffff10000000000000001976a914f8704d915ad7d2b559f61bad6c31b60deac52a3788acc88e4c870d61d7e14b8931d941c888ffb36ad58c52364e49c2df20f565dadecd010000006b483045022100c42531f0b50acab6fd1f63b30a2b1046ac29965bc0cf41409b180d3d4b91abec022006fbada6d4969de5f16297f8905a8a00763ca19a1a27ac31b1e77d128777a140412103d21e72986de0d354aff1dd737a066b6b786bc204bec22b3941e10e9575a7aa7bffffffff0e000000000000001976a9146b8297b1c3cd9ec13151c90d29e3a96f147535a688ac0214000000000000001976a914e8964298fcaa506f39e6d1d1f29657f79c1e72e788ac09000000000000001976a914e0bd3f2d5c1919109831bfad40b8eb293c07621b88ac00000000" + + fallbackRawHex = "010000000116d60a1563239eac2295b4eecbc6982ff6d007f480e52505c78f803bc8e03a05010000006a473044022024f84674219f2ec2fb78d38bcd19d4ae5b44dd45474d7680d56662a56b127326022025590d4aec95942b0eb6d52e679e4c98939d7a72b5901fae46354552af42cdeb412103ec9a56e27b5b773459c7cef92683a0498da7073346728a724d1878a9d7ce9615ffffffff0201000000000000001976a9149eb8198a2f08551afc193663a0dd80a9ed2f3c1288ac10000000000000001976a914098d21f508a39588d31dd746757c83b7d790cccc88ac00000000" + + oldWithDoubleSpentHex = "0100000001293f17ea61f50d5ea815780c3d571f0f475533b8e812189724ab8e14b77e1616000000006a4730440220607cf28232c23fc7a2283e89466e740a02dcdc6bc5fe094a281ac89d81c1987f02206baadd4c704c0b8099e1df85ca90cce74023fef6f6381ca93f422f9ac5af4d58412103513000984c44b7316671c1875c32eaeeacfd886f561623479794913c1cb91f73ffffffff01000000000000000038006a35323032342d31302d31342030373a34323a30362e31343333383534202b303230302043455354206d3d2b302e30313833333730303100000000" + newWithDoubleSpentHex = "0100000001ef9af77a38ee871bcca33df1260ab0b5c647743b4da33e417c4986150af6131b000000006a47304402207e93d325c42536b8255db76be8e34dfb486563f24e251a38f4afd3172bb78295022003ff04493bda06d020237f560f1ea0625da111a2c1bed72d0d1ae602dbaa12984121024a1ffa7ae3125b10c870d883d9dcd256f7b5ac51902f6901138e0861f95a9f59ffffffff01000000000000000038006a35323032342d31302d31342030383a30383a35382e37323636363431202b303230302043455354206d3d2b302e30313839303039303100000000" + malformedTxHex = "0100000001ef9af77a38ee871bcca33df1260ab0b5c647743b4da33e417c4986150af6131b0000000000ffffffff01000000000000000038006a35323032342d31302d31342030383a31313a33342e37313032333437202b303230302043455354206d3d2b302e30313832363439303100000000" +) + +func mockActivate(applyTimeout bool) *resty.Client { + transport := httpmock.NewMockTransport() + client := resty.New() + client.GetClient().Transport = transport + + arcMockResponses(transport, applyTimeout) + junglebusMockResponses(transport, applyTimeout) + + return client +} + +func arcMockResponses(transport *httpmock.MockTransport, applyTimeout bool) { + responder := func(status int, content string) func(req *http.Request) (*http.Response, error) { + return func(req *http.Request) (*http.Response, error) { + if applyTimeout { + time.Sleep(100 * time.Millisecond) + } + if req.Header.Get("Authorization") != arcToken { + return httpmock.NewStringResponse(http.StatusUnauthorized, ""), nil + } + res := httpmock.NewStringResponse(status, content) + res.Header.Set("Content-Type", "application/json") + return res, nil + } + } + + transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/tx/%s", arcURL, minedTxID), responder(http.StatusOK, `{ + "blockHash": "0000000000000000034df47d8fe84ccf10267b4f6bc43be513d4604229d1c209", + "blockHeight": 862510, + "competingTxs": null, + "extraInfo": "", + "merklePath": "fe2e290d00080231006449ce1869e63013f9b3ad17151fe0fe37091c47fd9a70e03dddeb6a64a5592c3002062e2358e3f5bd91639ed7b6059069e66ff9b24a7c3de397772da0c1321dff4d011900bcab35ce0c50582723db10783f8b48f1f3165203ffb0644b91fd0d6cb4d6190f010d003e1c17e035a1248377cf3371863c853892283ef032abc53d8427b0b196368aec010700cf74d836ab526d6b3ef705f4adc1121724b886f6c6a79ccf080c1a0bbce712570102005a7c6ac761a529dc616656cf187b354516752372823f574706c61747741ac3d4010000d58c14f52200fd9d9e2ef87c993c99c2f28a636ebbfe88a0097066b5f10bc3a5010100e74bf2106c1b378d72b2e8e4f82646f955e0b6b9955505f7f3cddebce3ab733801010056365352ba7e5578ff8249905d25e272c540472276163b27b0e9c6d4d26b7d0e", + "timestamp": "2024-09-27T06:11:41.417057192Z", + "txStatus": "MINED", + "txid": "4dff1d32c1a02d7797e33d7c4ab2f96fe6699005b6d79e6391bdf5e358232e06" + }`), + ) + + transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/tx/%s", arcURL, unknownTxID), responder(http.StatusNotFound, `{ + "detail": "The requested resource could not be found", + "extraInfo": "transaction not found", + "instance": null, + "status": 404, + "title": "Not found", + "txid": null, + "type": "https://bitcoin-sv.github.io/arc/#/errors?id=_404" + }`), + ) + + transport.RegisterResponder("GET", arcURL+wrongButReachable, responder(http.StatusNotFound, `{ + "message": "no matching operation was found" + }`), + ) + + transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/tx/%s", arcURL, invalidTxID), responder(http.StatusConflict, `{ + "detail": "Transaction could not be processed", + "extraInfo": "rpc error: code = Unknown desc = encoding/hex: invalid byte: U+0073 's'", + "instance": null, + "status": 409, + "title": "Generic error", + "txid": null, + "type": "https://bitcoin-sv.github.io/arc/#/errors?id=_409" + }`), + ) + + transport.RegisterResponder("GET", fmt.Sprintf("%s/v1/policy", arcURL), responder(http.StatusOK, `{ + "policy": { + "maxscriptsizepolicy": 100000000, + "maxtxsigopscountspolicy": 4294967295, + "maxtxsizepolicy": 100000000, + "miningFee": { + "bytes": 1000, + "satoshis": 1 + } + }, + "timestamp": "2024-10-02T07:36:33.589144918Z" + }`), + ) + + transport.RegisterResponder("GET", arcURL+wrongButReachable, responder(http.StatusNotFound, `{ + "message": "no matching operation was found" + }`), + ) + + transport.RegisterMatcherResponder("POST", fmt.Sprintf("%s/v1/tx", arcURL), + httpmock.BodyContainsString(efOfValidRawHex), + responder(http.StatusOK, `{ + "blockHash": "", + "blockHeight": 0, + "competingTxs": null, + "extraInfo": "", + "merklePath": "", + "timestamp": "2024-09-27T06:11:41.417057192Z", + "txStatus": "SEEN_ON_NETWORK", + "txid": "2978f03c8a21bf90b5980113f988c39ef4ae691b9bedd5178c50ebb9c034dabf" + }`), + ) + + transport.RegisterMatcherResponder("POST", fmt.Sprintf("%s/v1/tx", arcURL), + httpmock.BodyContainsString(efHexOfTxWithMultipleInputs), + responder(http.StatusOK, `{ + "blockHash": "", + "blockHeight": 0, + "competingTxs": null, + "extraInfo": "", + "merklePath": "", + "timestamp": "2024-09-27T06:11:41.417057192Z", + "txStatus": "SEEN_ON_NETWORK", + "txid": "88a7c0ed1cb4767cfc8e7434561379eaea21ae78e480cacf4e69284387057c70" + }`), + ) + + transport.RegisterMatcherResponder("POST", fmt.Sprintf("%s/v1/tx", arcURL), + httpmock.BodyContainsString(fallbackRawHex), + responder(http.StatusOK, `{ + "blockHash": "", + "blockHeight": 0, + "competingTxs": null, + "extraInfo": "", + "merklePath": "", + "timestamp": "2024-09-27T06:11:41.417057192Z", + "txStatus": "SEEN_ON_NETWORK", + "txid": "305df8d8efdf5a7effe3f91ea766f2ccd3579e61555a2b4c4b9561d8f156aff7" + }`), + ) + + transport.RegisterMatcherResponder("POST", fmt.Sprintf("%s/v1/tx", arcURL), + httpmock.BodyContainsString(oldWithDoubleSpentHex), + responder(http.StatusOK, `{ + "blockHash" : "", + "blockHeight" : 0, + "competingTxs" : null, + "extraInfo" : "", + "merklePath" : "", + "status" : 200, + "timestamp" : "2024-10-14T06:03:29.085353Z", + "title" : "OK", + "txStatus" : "SEEN_IN_ORPHAN_MEMPOOL", + "txid" : "65e965d0ff776bf6dbbcc257d62a9cd7b52bd4caee5999e65fc83656550e2756" + }`), + ) + + transport.RegisterMatcherResponder("POST", fmt.Sprintf("%s/v1/tx", arcURL), + httpmock.BodyContainsString(newWithDoubleSpentHex), + responder(http.StatusOK, `{ + "blockHash": "", + "blockHeight": 0, + "competingTxs": [ + "62bf0fad6d45a7fdfbb2aae58a99c2b0812b1fa7141c4f98087ad721e3590731" + ], + "extraInfo": "", + "merklePath": "", + "status": 200, + "timestamp": "2024-10-11T11:06:12.891372826Z", + "title": "OK", + "txStatus": "DOUBLE_SPEND_ATTEMPTED", + "txid": "4997c2b412a9b9ae82074ef41f561371c74a33ff01cefce75b56caf546a77d19" + }`), + ) + + transport.RegisterMatcherResponder("POST", fmt.Sprintf("%s/v1/tx", arcURL), + httpmock.BodyContainsString(malformedTxHex), + responder(461, `{ + "detail": "Transaction is malformed and cannot be processed", + "extraInfo": "arc error 461: script execution failed\nindex 0 is invalid for stack size 0", + "instance": null, + "status": 461, + "title": "Malformed transaction", + "txid": "5b5ead5a42c4320f5b345e387c3648e8e789b171d17002e3efdc828534979f57", + "type": "https://bitcoin-sv.github.io/arc/#/errors?id=_461" + }`), + ) +} + +func junglebusMockResponses(transport *httpmock.MockTransport, applyTimeout bool) { + responder := func(status int, content string) func(req *http.Request) (*http.Response, error) { + return func(req *http.Request) (*http.Response, error) { + if applyTimeout { + time.Sleep(100 * time.Millisecond) + } + res := httpmock.NewStringResponse(status, content) + res.Header.Set("Content-Type", "application/json") + return res, nil + } + } + + transport.RegisterResponder("GET", fmt.Sprintf("https://junglebus.gorillapool.io/v1/transaction/get/%s", txIDOfSourceTwoOfTxWithMultipleInputs), responder(http.StatusOK, `{ + "id": "cddeda65f520dfc2494e36528cd56ab3ff88c841d931894be1d7610d874c8ec8", + "transaction": "AQAAAAHU58f2jMJt3XzGjJEKINLPVzwd2Mr6NDEAq8exla/vIgEAAABrSDBFAiEA3rvUh3L5fGG8nzMdxTW6AoKarzlehm3pHMDDULQ+f0sCIAmo1o/v9WUJD62kTZgsZ3iBYn3AjpkjOG7iWyedxxCxQSEDXI/Xt/qQrisBpMkdoNh/87u8M5DZ3me2n61SqLeP9J3/////AgEAAAAAAAAAGXapFAS8COAvcQwoaykycYzP1nGgyBZEiKwOAAAAAAAAABl2qRRrgpexw82ewTFRyQ0p46lvFHU1poisAAAAAA==" + }`), + ) +} + +func arcCfg(url, token string) chainmodels.ARCConfig { + return chainmodels.ARCConfig{ + URL: url, + Token: token, + DeploymentID: "spv-wallet-test-arc-connection", + } +} diff --git a/engine/chain/internal/arc/policy.go b/engine/chain/internal/arc/policy.go index a0089cc3..c0190805 100644 --- a/engine/chain/internal/arc/policy.go +++ b/engine/chain/internal/arc/policy.go @@ -5,13 +5,13 @@ import ( "fmt" "net/http" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" ) -// GetPolicy requests ARC server for the policy -func (s *Service) GetPolicy(ctx context.Context) (*chainmodels.Policy, error) { - result := &chainmodels.Policy{} +// GetPolicy returns the current policy from the ARC server. +func (s *Service) GetPolicy(ctx context.Context) (*Policy, error) { + result := &Policy{} arcErr := &chainmodels.ArcError{} req := s.prepareARCRequest(ctx). SetResult(result). @@ -27,10 +27,10 @@ func (s *Service) GetPolicy(ctx context.Context) (*chainmodels.Policy, error) { case http.StatusOK: return result, nil case http.StatusUnauthorized, http.StatusForbidden: - return nil, s.wrapARCError(spverrors.ErrARCUnauthorized, arcErr) + return nil, s.wrapARCError(chainerrors.ErrARCUnauthorized, arcErr) case http.StatusNotFound: - return nil, spverrors.ErrARCUnreachable + return nil, chainerrors.ErrARCUnreachable default: - return nil, s.wrapARCError(spverrors.ErrARCUnsupportedStatusCode, arcErr) + return nil, s.wrapARCError(chainerrors.ErrARCUnsupportedStatusCode, arcErr) } } diff --git a/engine/chain/models/policy.go b/engine/chain/internal/arc/policy_model.go similarity index 97% rename from engine/chain/models/policy.go rename to engine/chain/internal/arc/policy_model.go index 1b87160a..9d8a1047 100644 --- a/engine/chain/models/policy.go +++ b/engine/chain/internal/arc/policy_model.go @@ -1,4 +1,4 @@ -package chainmodels +package arc import ( "time" diff --git a/engine/chain/internal/arc/query_transaction.go b/engine/chain/internal/arc/query_transaction.go index 1d861e78..320f6325 100644 --- a/engine/chain/internal/arc/query_transaction.go +++ b/engine/chain/internal/arc/query_transaction.go @@ -5,8 +5,8 @@ import ( "fmt" "net/http" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" ) // QueryTransaction a transaction. @@ -27,16 +27,16 @@ func (s *Service) QueryTransaction(ctx context.Context, txID string) (*chainmode case http.StatusOK: return result, nil case http.StatusUnauthorized, http.StatusForbidden: - return nil, s.wrapARCError(spverrors.ErrARCUnauthorized, arcErr) + return nil, s.wrapARCError(chainerrors.ErrARCUnauthorized, arcErr) case http.StatusNotFound: if !arcErr.IsEmpty() { // ARC returns 404 when transaction is not found return nil, nil // By convention, nil is returned when transaction is not found } - return nil, spverrors.ErrARCUnreachable + return nil, chainerrors.ErrARCUnreachable case http.StatusConflict: - return nil, s.wrapARCError(spverrors.ErrARCGenericError, arcErr) + return nil, s.wrapARCError(chainerrors.ErrARCGenericError, arcErr) default: - return nil, s.wrapARCError(spverrors.ErrARCUnsupportedStatusCode, arcErr) + return nil, s.wrapARCError(chainerrors.ErrARCUnsupportedStatusCode, arcErr) } } diff --git a/engine/chain/internal/arc/query_transaction_test.go b/engine/chain/internal/arc/query_transaction_test.go index d2d5d819..9afb0950 100644 --- a/engine/chain/internal/arc/query_transaction_test.go +++ b/engine/chain/internal/arc/query_transaction_test.go @@ -6,8 +6,8 @@ import ( "time" "github.com/bitcoin-sv/spv-wallet/engine/chain" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/tester" "github.com/stretchr/testify/require" ) @@ -18,7 +18,7 @@ NOTE: switch httpClient to resty.New() tu call actual ARC server func TestQueryService(t *testing.T) { t.Run("QueryTransaction for MINED transaction", func(t *testing.T) { - httpClient := arcMockActivate(false) + httpClient := mockActivate(false) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) @@ -31,7 +31,7 @@ func TestQueryService(t *testing.T) { }) t.Run("QueryTransaction for unknown transaction", func(t *testing.T) { - httpClient := arcMockActivate(false) + httpClient := mockActivate(false) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) @@ -53,31 +53,31 @@ func TestQueryServiceErrorCases(t *testing.T) { txID: invalidTxID, arcToken: arcToken, arcURL: arcURL, - expectErr: spverrors.ErrARCGenericError, + expectErr: chainerrors.ErrARCGenericError, }, "QueryTransaction with wrong token": { txID: minedTxID, arcToken: "wrong-token", //if you test it on actual ARC server, this test might fail if the ARC doesn't require token arcURL: arcURL, - expectErr: spverrors.ErrARCUnauthorized, + expectErr: chainerrors.ErrARCUnauthorized, }, "QueryTransaction 404 endpoint but reachable": { txID: minedTxID, arcToken: arcToken, arcURL: arcURL + wrongButReachable, - expectErr: spverrors.ErrARCUnreachable, + expectErr: chainerrors.ErrARCUnreachable, }, "QueryTransaction 404 endpoint with wrong arcURL": { txID: minedTxID, arcToken: arcToken, arcURL: "wrong-url", - expectErr: spverrors.ErrARCUnreachable, + expectErr: chainerrors.ErrARCUnreachable, }, } for name, tc := range errTestCases { t.Run(name, func(t *testing.T) { - httpClient := arcMockActivate(false) + httpClient := mockActivate(false) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(tc.arcURL, tc.arcToken), chainmodels.BHSConfig{}) @@ -92,7 +92,7 @@ func TestQueryServiceErrorCases(t *testing.T) { func TestQueryServiceTimeouts(t *testing.T) { t.Run("QueryTransaction interrupted by ctx timeout", func(t *testing.T) { - httpClient := arcMockActivate(true) + httpClient := mockActivate(true) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) @@ -102,13 +102,13 @@ func TestQueryServiceTimeouts(t *testing.T) { txInfo, err := service.QueryTransaction(ctx, minedTxID) require.Error(t, err) - require.ErrorIs(t, err, spverrors.ErrARCUnreachable) + require.ErrorIs(t, err, chainerrors.ErrARCUnreachable) require.ErrorIs(t, err, context.DeadlineExceeded) require.Nil(t, txInfo) }) t.Run("QueryTransaction interrupted by resty timeout", func(t *testing.T) { - httpClient := arcMockActivate(true) + httpClient := mockActivate(true) httpClient.SetTimeout(1 * time.Millisecond) service := chain.NewChainService(tester.Logger(t), httpClient, arcCfg(arcURL, arcToken), chainmodels.BHSConfig{}) @@ -116,7 +116,7 @@ func TestQueryServiceTimeouts(t *testing.T) { txInfo, err := service.QueryTransaction(context.Background(), minedTxID) require.Error(t, err) - require.ErrorIs(t, err, spverrors.ErrARCUnreachable) + require.ErrorIs(t, err, chainerrors.ErrARCUnreachable) require.ErrorIs(t, err, context.DeadlineExceeded) require.Nil(t, txInfo) }) diff --git a/engine/chain/internal/arc/request.go b/engine/chain/internal/arc/request.go index 5f992dd5..69cdce45 100644 --- a/engine/chain/internal/arc/request.go +++ b/engine/chain/internal/arc/request.go @@ -5,7 +5,8 @@ import ( "errors" "net" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/models" "github.com/go-resty/resty/v2" @@ -30,7 +31,7 @@ func (s *Service) prepareARCRequest(ctx context.Context) *resty.Request { func (s *Service) wrapRequestError(err error) error { var e net.Error if errors.As(err, &e) { - return spverrors.ErrARCUnreachable.Wrap(e) + return chainerrors.ErrARCUnreachable.Wrap(e) } return spverrors.ErrInternal.Wrap(err) } diff --git a/engine/chain/internal/arc/service.go b/engine/chain/internal/arc/service.go index a28cc37f..35d69710 100644 --- a/engine/chain/internal/arc/service.go +++ b/engine/chain/internal/arc/service.go @@ -1,23 +1,50 @@ package arc import ( - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/internal/ef" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/go-resty/resty/v2" "github.com/rs/zerolog" ) // Service for arc requests. type Service struct { - logger zerolog.Logger - httpClient *resty.Client - arcCfg chainmodels.ARCConfig + logger zerolog.Logger + httpClient *resty.Client + arcCfg chainmodels.ARCConfig + efConverter interface { + Convert(ctx context.Context, tx *sdk.Transaction) (string, error) + } } // NewARCService creates a new arc service. func NewARCService(logger zerolog.Logger, httpClient *resty.Client, arcCfg chainmodels.ARCConfig) *Service { - return &Service{ + service := &Service{ logger: logger, httpClient: httpClient, arcCfg: arcCfg, } + + if arcCfg.TxsGetter == nil { + logger.Warn().Msg("No transactions getter provided. Unsourced transactions will be broadcasted as raw hex.") + service.efConverter = &noTxsGettersEFConverter{} + } else { + service.efConverter = ef.NewConverter(arcCfg.TxsGetter) + } + + return service +} + +type noTxsGettersEFConverter struct{} + +func (n *noTxsGettersEFConverter) Convert(_ context.Context, tx *sdk.Transaction) (string, error) { + efHex, err := tx.EFHex() + if err != nil { + return "", chainerrors.ErrEFConversion.Wrap(err) + } + return efHex, nil } diff --git a/engine/chain/internal/bhs/service.go b/engine/chain/internal/bhs/service.go index e944e773..283298da 100644 --- a/engine/chain/internal/bhs/service.go +++ b/engine/chain/internal/bhs/service.go @@ -1,7 +1,7 @@ package bhs import ( - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/go-resty/resty/v2" "github.com/rs/zerolog" ) diff --git a/engine/chain/internal/combined_txs_getter.go b/engine/chain/internal/combined_txs_getter.go new file mode 100644 index 00000000..92595f02 --- /dev/null +++ b/engine/chain/internal/combined_txs_getter.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "maps" + "slices" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "iter" +) + +// CombineTxsGetters creates a new CombinedTxsGetter +func CombineTxsGetters(txsGetters ...chainmodels.TransactionsGetter) chainmodels.TransactionsGetter { + getters := filterNilGetters(txsGetters...) + + if len(getters) == 0 { + return &emptyGetter{} + } + if len(getters) == 1 { + return getters[0] + } + return &combinedTxsGetter{ + txsGetters: getters, + } +} + +type combinedTxsGetter struct { + txsGetters []chainmodels.TransactionsGetter +} + +// GetTransactions gets transactions from all provided TransactionsGetters in order +// the first tx getter is queried for all transactions, the second tx getter is queried only for the missing transactions and so on +func (ctg *combinedTxsGetter) GetTransactions(ctx context.Context, ids iter.Seq[string]) ([]*sdk.Transaction, error) { + missingTxs := map[string]bool{} + for id := range ids { + missingTxs[id] = true + } + var transactions []*sdk.Transaction + for _, getter := range ctg.txsGetters { + if len(missingTxs) == 0 { + break + } + if getter == nil { + return nil, spverrors.Newf("nil transactions getter") + } + txs, err := getter.GetTransactions(ctx, maps.Keys(missingTxs)) + if err != nil { + return nil, chainerrors.ErrGetTransactionsByTxsGetter.Wrap(err) + } + for _, tx := range txs { + txID := tx.TxID().String() + if _, exists := missingTxs[txID]; !exists { + // This transaction was already fetched by another getter + continue + } + delete(missingTxs, txID) + transactions = append(transactions, tx) + } + } + return transactions, nil +} + +type emptyGetter struct{} + +func (ctg *emptyGetter) GetTransactions(_ context.Context, _ iter.Seq[string]) ([]*sdk.Transaction, error) { + return nil, nil +} + +func filterNilGetters(txsGetters ...chainmodels.TransactionsGetter) []chainmodels.TransactionsGetter { + return slices.DeleteFunc(txsGetters, func(getter chainmodels.TransactionsGetter) bool { + return getter == nil + }) +} diff --git a/engine/chain/internal/combined_txs_getter_test.go b/engine/chain/internal/combined_txs_getter_test.go new file mode 100644 index 00000000..b98fb33b --- /dev/null +++ b/engine/chain/internal/combined_txs_getter_test.go @@ -0,0 +1,143 @@ +package internal_test + +import ( + "context" + "errors" + "testing" + "time" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain/internal" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/stretchr/testify/require" + "iter" +) + +type mockTxsGetter struct { + transactions []*sdk.Transaction + returnError error + applyTimeout bool +} + +func (m *mockTxsGetter) GetTransactions(ctx context.Context, _ iter.Seq[string]) ([]*sdk.Transaction, error) { + if m.applyTimeout { + <-ctx.Done() + return nil, ctx.Err() + } + if m.returnError != nil { + return nil, m.returnError + } + return m.transactions, nil +} + +const ( + tx1Hex = "0100000002c646da06628a5846b3c1ba79936bb0b2ba019ecd8acc2281441bd1aa4408f3cc000000006a4730440220500357d7e85c623405afd767db11d2da20b50c6fcf97c0db61c70867748e3ea802201c5fef8d7d1f9b4f24a5e5ccb84071a9b1f9808b5d475be21dbe475ce167006041210217ff58e102ed361d4946bafb257afc724af3c50ab108a4333d585ae81f230095ffffffff46324e8a83f0bcddd70358d8dd8d42613529dc633a14c001233bc8be959642df000000006b483045022100afd4023dd47cda9e7f6704fffa8740e29a07a41a0c5d4884043477bbb60082d902202b4921eb1ef7d8fe828c8213f98e1cbecea2c6acdd864a516ef29769cc6423b541210321d3a9b13c6c0b3d1e16bdc4c0990749c52cb394f6f06e314b1186c9c53d603affffffff0214000000000000001976a91405b1321ee4478faa0a3d0aae8893c149811ec68e88ac13000000000000001976a91420aeb57d4809d74b7bb78ccbb7eb07fc9c59e1cc88ac00000000" + tx2Hex = "0100000002779082a94ec3c113337afee5667b69d64d7d7591472c92a4cde37db15719ea45010000006a473044022038ecc2b1482df117871722b634a24cf83d18008a26ba2e98f342ab8b1df4dfed02202e0b1e989726134910013329b244dee9b7b8e48a3ba2eb5926bb375ca67233b2412102d8d04ed1c01919f0973e1091e054edb7f3af469929ae5e12d01af32f5595b5ccffffffffe071ef01d65fcbf6b0882ea2216a67928aa05b6ffa34ecd32fc950e96acad520010000006a47304402205db7b79a3075e7bebbbbacaa9fb583e1b008a82deaafd4512ea8b84bb1d4dca3022044a6a0a102499521333bcb8ca4f34025e8c0c80f3bd79abbe58f1aa0af7c71db4121023ecd8b030eb23991cc5abe718a78ec80abba740a6cd199037b13f1ab4e0c7376ffffffff0314000000000000001976a914975f6eef3229c0896ada2ead550702af3fda512888ac04100000000000001976a914486633d2c0e1eb6f7fa79c525c4e93d0a8b4c20688ac6d000000000000001976a91490b7fc2adfec1d9daa0622f857a06d9bb12ee21888ac00000000" + tx3Hex = "01000000026d9a56036235bb5b5e39b04b6f188c74bc45189a9122f235cad5f0c4b668817d010000006a47304402205bad758ddad1816827d2f8d683d055c95652fa3e8902c0af1a2009b039e360350220152ac3f8417ef311ff61636fa91e99782a77e4d1d192c33fb2f1f245913b879f412103daf4a6e60ee877fa7e0639e0a4f416e5c80dfa05cd17762d77c62391a3322a52ffffffff08b11a9c1c6534f7d07d5367d4d7b073ca7803139122cbdc06f5e77463746310010000006a47304402204c8d367127e9d68b79c4d40bded75e773194ced7ab07345df966d5cbabca460102204d029f791d80b75d59fd2ba7b9e186e1d449e2e4e5c3d03a0f3e9f98b4bd132041210330278625061e9bb6104e959851e6698f2b96333cbd7262ee3c22410f2e5cf4a1ffffffff0314000000000000001976a9149df7244a4edade5f8d06ad392d568bc62218fe3988ac34120000000000001976a9144d4dec9ae2199860fc6ebe9cd446472f32f6ffd388acb5000000000000001976a914fd8154ad427ca5cce7209c121a29452add41e25d88ac00000000" +) + +func TestCombinedTxGetter(t *testing.T) { + tx1 := fromHex(tx1Hex) + tx2 := fromHex(tx2Hex) + tx3 := fromHex(tx3Hex) + tests := map[string]struct { + getters []chainmodels.TransactionsGetter + requestedTXs []*sdk.Transaction + expectedTXs []*sdk.Transaction + }{ + "Transactions from single getter": { + getters: []chainmodels.TransactionsGetter{ + &mockTxsGetter{transactions: []*sdk.Transaction{tx1, tx2}}, + }, + requestedTXs: []*sdk.Transaction{tx1, tx2}, + expectedTXs: []*sdk.Transaction{tx1, tx2}, + }, + "Transactions from two getters": { + getters: []chainmodels.TransactionsGetter{ + &mockTxsGetter{transactions: []*sdk.Transaction{tx1, tx2}}, + &mockTxsGetter{transactions: []*sdk.Transaction{tx3}}, + }, + requestedTXs: []*sdk.Transaction{tx1, tx2, tx3}, + expectedTXs: []*sdk.Transaction{tx1, tx2, tx3}, + }, + "GetTransactions with single getter behaves as this getter itself": { + getters: []chainmodels.TransactionsGetter{ + &mockTxsGetter{transactions: []*sdk.Transaction{tx1}}, + }, + requestedTXs: []*sdk.Transaction{}, + expectedTXs: []*sdk.Transaction{tx1}, + }, + "CombinedTxsGetter with no getters": { + getters: []chainmodels.TransactionsGetter{}, + requestedTXs: []*sdk.Transaction{tx1}, + expectedTXs: []*sdk.Transaction{}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + getter := internal.CombineTxsGetters(test.getters...) + transactions, err := getter.GetTransactions(context.Background(), ids(test.requestedTXs...)) + + require.NoError(t, err) + require.Equal(t, len(test.expectedTXs), len(transactions)) + shouldAllContain(t, transactions, ids(test.expectedTXs...)) + }) + } +} + +func TestCombinedTxGetterErrorCases(t *testing.T) { + tx1 := fromHex(tx1Hex) + + t.Run("Getter returns error", func(t *testing.T) { + expectedErr := errors.New("some error") + getter := internal.CombineTxsGetters(&mockTxsGetter{returnError: expectedErr}) + + transactions, err := getter.GetTransactions(context.Background(), ids(tx1)) + + require.ErrorIs(t, err, expectedErr) + require.Nil(t, transactions) + }) + + t.Run("Getter interrupted by ctx timeout ", func(t *testing.T) { + getter := internal.CombineTxsGetters(&mockTxsGetter{applyTimeout: true}) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + transactions, err := getter.GetTransactions(ctx, ids(tx1)) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, transactions) + }) +} + +func fromHex(hex string) *sdk.Transaction { + tx, _ := sdk.NewTransactionFromHex(hex) + return tx +} + +func id(tx *sdk.Transaction) string { + return tx.TxID().String() +} + +func ids(txs ...*sdk.Transaction) iter.Seq[string] { + return func(yield func(string) bool) { + for _, tx := range txs { + if !yield(id(tx)) { + return + } + } + } +} + +func shouldAllContain(t *testing.T, transactions []*sdk.Transaction, expectedTxIDs iter.Seq[string]) { + txs := make(map[string]bool) + for _, tx := range transactions { + txs[id(tx)] = true + } + for expectedTxID := range expectedTxIDs { + if _, exists := txs[expectedTxID]; !exists { + require.Failf(t, "transaction %s not found", expectedTxID) + } + } +} diff --git a/engine/chain/internal/ef/converter.go b/engine/chain/internal/ef/converter.go index a3ea6c7e..36d6d55f 100644 --- a/engine/chain/internal/ef/converter.go +++ b/engine/chain/internal/ef/converter.go @@ -4,22 +4,17 @@ import ( "context" sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "iter" ) -// TransactionsGetter is an interface for getting transactions by their IDs -type TransactionsGetter interface { - GetTransactions(ctx context.Context, ids iter.Seq[string]) ([]*sdk.Transaction, error) -} - // Converter provides a method to convert a transaction to EFHex format type Converter struct { - txsGetter TransactionsGetter + txsGetter chainmodels.TransactionsGetter } // NewConverter creates a new instance of Converter -func NewConverter(txsGetter TransactionsGetter) *Converter { +func NewConverter(txsGetter chainmodels.TransactionsGetter) *Converter { return &Converter{txsGetter: txsGetter} } diff --git a/engine/chain/internal/junglebus/fetch_transaction.go b/engine/chain/internal/junglebus/fetch_transaction.go index 3a73c360..7c03d471 100644 --- a/engine/chain/internal/junglebus/fetch_transaction.go +++ b/engine/chain/internal/junglebus/fetch_transaction.go @@ -3,6 +3,8 @@ package junglebus import ( "context" "fmt" + "net/http" + "strings" sdk "github.com/bitcoin-sv/go-sdk/transaction" "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" @@ -24,14 +26,20 @@ func (s *Service) FetchTransaction(ctx context.Context, txID string) (*sdk.Trans return nil, spverrors.ErrInternal.Wrap(err) } - if response.StatusCode() != 200 { + switch response.StatusCode() { + case http.StatusOK: + tx, err := sdk.NewTransactionFromBytes(result.Transaction) + if err != nil { + return nil, chainerrors.ErrJunglebusParseTransaction.Wrap(err) + } + return tx, nil + case http.StatusNotFound: + textContent := string(response.Body()) + if strings.Contains(textContent, "tx-not-found") { + return nil, chainerrors.ErrJunglebusTxNotFound + } + return nil, chainerrors.ErrJunglebusFailure.Wrap(spverrors.Newf("junglebus returned 404 with body %s", textContent)) + default: return nil, chainerrors.ErrJunglebusFailure.Wrap(spverrors.Newf("junglebus returned status code %d", response.StatusCode())) } - - tx, err := sdk.NewTransactionFromBytes(result.Transaction) - if err != nil { - return nil, chainerrors.ErrJunglebusParseTransaction.Wrap(err) - } - - return tx, nil } diff --git a/engine/chain/internal/junglebus/fetch_transaction_test.go b/engine/chain/internal/junglebus/fetch_transaction_test.go deleted file mode 100644 index f6120435..00000000 --- a/engine/chain/internal/junglebus/fetch_transaction_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package junglebus_test - -import ( - "context" - "testing" - "time" - - chainerrors "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" - "github.com/bitcoin-sv/spv-wallet/engine/chain/internal/junglebus" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "github.com/bitcoin-sv/spv-wallet/engine/tester" - "github.com/bitcoin-sv/spv-wallet/models/bsv" - "github.com/stretchr/testify/require" -) - -/** -NOTE: switch httpClient to resty.New() tu call actual ARC server -*/ - -func TestJunglebusFetchTransaction(t *testing.T) { - t.Run("Request for transaction", func(t *testing.T) { - httpClient := junglebusMockActivate(false) - - service := junglebus.NewJunglebusService(tester.Logger(t), httpClient) - - tx, err := service.FetchTransaction(context.Background(), knownTx) - - require.NoError(t, err) - require.NotNil(t, tx) - require.Equal(t, bsv.Satoshis(39), bsv.Satoshis(tx.TotalOutputSatoshis())) - }) - - t.Run("Request for invalid transaction", func(t *testing.T) { - httpClient := junglebusMockActivate(false) - - service := junglebus.NewJunglebusService(tester.Logger(t), httpClient) - - tx, err := service.FetchTransaction(context.Background(), "wrong-txID") - - require.Error(t, err) - require.Nil(t, tx) - require.ErrorIs(t, err, chainerrors.ErrJunglebusFailure) - }) -} - -func TestJunglebusFetchTransactionTimeouts(t *testing.T) { - t.Run("FetchTransaction interrupted by ctx timeout", func(t *testing.T) { - httpClient := junglebusMockActivate(true) - - service := junglebus.NewJunglebusService(tester.Logger(t), httpClient) - - ctx, cancel := context.WithTimeout(context.Background(), 1) - defer cancel() - - tx, err := service.FetchTransaction(ctx, knownTx) - - require.Error(t, err) - require.ErrorIs(t, err, spverrors.ErrInternal) - require.ErrorIs(t, err, context.DeadlineExceeded) - require.Nil(t, tx) - }) - - t.Run("FetchTransaction interrupted by resty timeout", func(t *testing.T) { - httpClient := junglebusMockActivate(true) - httpClient.SetTimeout(1 * time.Millisecond) - - service := junglebus.NewJunglebusService(tester.Logger(t), httpClient) - - tx, err := service.FetchTransaction(context.Background(), knownTx) - - require.Error(t, err) - require.ErrorIs(t, err, spverrors.ErrInternal) - require.ErrorIs(t, err, context.DeadlineExceeded) - require.Nil(t, tx) - }) -} diff --git a/engine/chain/internal/junglebus/junglebus_mock_test.go b/engine/chain/internal/junglebus/junglebus_mock_test.go deleted file mode 100644 index f6173ea0..00000000 --- a/engine/chain/internal/junglebus/junglebus_mock_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package junglebus_test - -import ( - "fmt" - "net/http" - "time" - - "github.com/go-resty/resty/v2" - "github.com/jarcoal/httpmock" -) - -const knownTx = "ea47e03186e59f8947d847e4eeaacde294a0a2db4d5e33b128430f2e2ee91015" -const wrongTxID = "wrong-txID" - -func junglebusMockActivate(applyTimeout bool) *resty.Client { - transport := httpmock.NewMockTransport() - client := resty.New() - client.GetClient().Transport = transport - - responder := func(status int, content string) func(req *http.Request) (*http.Response, error) { - return func(req *http.Request) (*http.Response, error) { - if applyTimeout { - time.Sleep(100 * time.Millisecond) - } - res := httpmock.NewStringResponse(status, content) - res.Header.Set("Content-Type", "application/json") - return res, nil - } - } - - transport.RegisterResponder("GET", fmt.Sprintf("https://junglebus.gorillapool.io/v1/transaction/get/%s", knownTx), responder(http.StatusOK, `{ - "id": "ea47e03186e59f8947d847e4eeaacde294a0a2db4d5e33b128430f2e2ee91015", - "transaction": "AQAAAALGRtoGYopYRrPBunmTa7CyugGezYrMIoFEG9GqRAjzzAAAAABqRzBEAiBQA1fX6FxiNAWv12fbEdLaILUMb8+XwNthxwhndI4+qAIgHF/vjX0fm08kpeXMuEBxqbH5gItdR1viHb5HXOFnAGBBIQIX/1jhAu02HUlGuvslevxySvPFCrEIpDM9WFroHyMAlf////9GMk6Kg/C83dcDWNjdjUJhNSncYzoUwAEjO8i+lZZC3wAAAABrSDBFAiEAr9QCPdR82p5/ZwT/+odA4poHpBoMXUiEBDR3u7YAgtkCICtJIese99j+goyCE/mOHL7Oosas3YZKUW7yl2nMZCO1QSEDIdOpsTxsCz0eFr3EwJkHScUss5T28G4xSxGGycU9YDr/////AhQAAAAAAAAAGXapFAWxMh7kR4+qCj0KroiTwUmBHsaOiKwTAAAAAAAAABl2qRQgrrV9SAnXS3u3jMu36wf8nFnhzIisAAAAAA==", - "block_hash": "00000000000000000867325526c1f6d578dd535117f787fe2f67d78d1c5ccd7d", - "block_height": 825476, - "block_time": 1704283115, - "block_index": 610, - "addresses": [ - "1MyizGwAJJxxm35rN3yU3KTcW88DmqQ4de", - "1NExg8ukZBtFoCTkNRsXaXKKo8owQRz2JM", - "1X6ejopTnDoKxYxXgjnpMp8C7VuwfcyT1", - "13yov8U51qN6WTZJ9EFnS2JvbRhhLsdDby" - ], - "inputs": [], - "outputs": [ - "76a91405b1321ee4478faa0a3d0aae8893c149811ec68e88ac", - "76a91420aeb57d4809d74b7bb78ccbb7eb07fc9c59e1cc88ac" - ], - "input_types": [], - "output_types": [ - "pubkeyhash" - ], - "contexts": [], - "sub_contexts": [], - "data": [], - "merkle_proof": null - }`), - ) - - transport.RegisterResponder( - "GET", - fmt.Sprintf("https://junglebus.gorillapool.io/v1/transaction/get/%s", wrongTxID), - httpmock.NewStringResponder(http.StatusNotFound, `encoding/hex: odd length hex string`), - ) - - return client -} diff --git a/engine/chain/internal/junglebus/txs_getter.go b/engine/chain/internal/junglebus/txs_getter.go new file mode 100644 index 00000000..abcf476f --- /dev/null +++ b/engine/chain/internal/junglebus/txs_getter.go @@ -0,0 +1,32 @@ +package junglebus + +import ( + "context" + "errors" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + chainerrors "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "iter" +) + +// GetTransactions implements chainmodels.TransactionsGetter interface to allow fetching transactions from Junglebus +func (s *Service) GetTransactions(ctx context.Context, ids iter.Seq[string]) ([]*sdk.Transaction, error) { + var transactions []*sdk.Transaction + for id := range ids { + select { + case <-ctx.Done(): + return nil, spverrors.ErrCtxInterrupted.Wrap(ctx.Err()) + default: + tx, err := s.FetchTransaction(ctx, id) + if errors.Is(err, chainerrors.ErrJunglebusTxNotFound) { + continue + } + if err != nil { + return nil, err + } + transactions = append(transactions, tx) + } + } + return transactions, nil +} diff --git a/engine/chain/models/arc_config.go b/engine/chain/models/arc_config.go index 33a6ad40..ffd5612c 100644 --- a/engine/chain/models/arc_config.go +++ b/engine/chain/models/arc_config.go @@ -1,8 +1,29 @@ package chainmodels +import ( + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "iter" +) + +// TransactionsGetter is an interface for getting transactions by their IDs +type TransactionsGetter interface { + GetTransactions(ctx context.Context, ids iter.Seq[string]) ([]*sdk.Transaction, error) +} + +// ARCCallbackConfig is the configuration for spv-wallet's endpoint for ARC to callback. +type ARCCallbackConfig struct { + URL string + Token string +} + // ARCConfig is the configuration for the ARC API. type ARCConfig struct { URL string Token string DeploymentID string + Callback *ARCCallbackConfig + UseJunglebus bool + TxsGetter TransactionsGetter } diff --git a/engine/chain/models/arc_error.go b/engine/chain/models/arc_error.go index 5897a65f..8881a22b 100644 --- a/engine/chain/models/arc_error.go +++ b/engine/chain/models/arc_error.go @@ -15,6 +15,9 @@ type ArcError struct { // Error returns the error string it's the implementation of the error interface. func (a *ArcError) Error() string { + if a.IsEmpty() { + return "ARC error: empty (or not in json) response" + } return fmt.Sprintf("ARC error: %s %s", a.Title, a.TxID, a.Detail) } diff --git a/engine/chain/models/tx_info.go b/engine/chain/models/tx_info.go index 5d0961de..ea2c3cb3 100644 --- a/engine/chain/models/tx_info.go +++ b/engine/chain/models/tx_info.go @@ -1,14 +1,16 @@ package chainmodels +import "time" + // TXInfo is the struct that represents the transaction information from ARC type TXInfo struct { - BlockHash string `json:"blockHash,omitempty"` - BlockHeight int64 `json:"blockHeight,omitempty"` - ExtraInfo string `json:"extraInfo,omitempty"` - MerklePath string `json:"merklePath,omitempty"` - Timestamp string `json:"timestamp,omitempty"` - TXStatus TXStatus `json:"txStatus,omitempty"` - TxID string `json:"txid,omitempty"` + BlockHash string `json:"blockHash,omitempty"` + BlockHeight int64 `json:"blockHeight,omitempty"` + ExtraInfo string `json:"extraInfo,omitempty"` + MerklePath string `json:"merklePath,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` + TXStatus TXStatus `json:"txStatus,omitempty"` + TxID string `json:"txid,omitempty"` } // Found presents a convention to indicate that the transaction is known by ARC diff --git a/engine/chain/models/txstatus.go b/engine/chain/models/txstatus.go index 6ebddd73..bbd66ef3 100644 --- a/engine/chain/models/txstatus.go +++ b/engine/chain/models/txstatus.go @@ -21,14 +21,24 @@ const ( SentToNetwork TXStatus = "SENT_TO_NETWORK" // 6 // AcceptedByNetwork status means that transaction has been accepted by a connected Bitcoin node on the ZMQ interface. If metamorph is not connected to ZQM, this status will never by set. AcceptedByNetwork TXStatus = "ACCEPTED_BY_NETWORK" // 7 - // SeenOnNetwork status means that transaction has been seen on the Bitcoin network and propagated to other nodes. This status is set when metamorph receives an INV message for the transaction from another node than it was sent to. - SeenOnNetwork TXStatus = "SEEN_ON_NETWORK" // 8 - // Mined status means that transaction has been mined into a block by a mining node. - Mined TXStatus = "MINED" // 9 // SeenInOrphanMempool means that transaction has been sent to at least 1 Bitcoin node but parent transaction was not found. SeenInOrphanMempool TXStatus = "SEEN_IN_ORPHAN_MEMPOOL" // 10 - // Confirmed status means that transaction is marked as confirmed when it is in a block with 100 blocks built on top of that block. - Confirmed TXStatus = "CONFIRMED" // 108 + // SeenOnNetwork status means that transaction has been seen on the Bitcoin network and propagated to other nodes. This status is set when metamorph receives an INV message for the transaction from another node than it was sent to. + SeenOnNetwork TXStatus = "SEEN_ON_NETWORK" // 8 + // DoubleSpendAttempted status means that transaction has been attempted to be double spent. + DoubleSpendAttempted = "DOUBLE_SPEND_ATTEMPTED" // Rejected status means that transaction has been rejected by the Bitcoin network. Rejected TXStatus = "REJECTED" // 109 + // Mined status means that transaction has been mined into a block by a mining node. + Mined TXStatus = "MINED" // 9 ) + +// IsMined returns true if the transaction has been mined +func (t TXStatus) IsMined() bool { + return t == Mined +} + +// IsProblematic returns true if the transaction is problematic (e.g rejected, double spend, unknown) +func (t TXStatus) IsProblematic() bool { + return t == Rejected || t == DoubleSpendAttempted || t == Unknown || t == SeenInOrphanMempool +} diff --git a/engine/chainstate/arc_deafult.go b/engine/chainstate/arc_deafult.go deleted file mode 100644 index 0cb8196b..00000000 --- a/engine/chainstate/arc_deafult.go +++ /dev/null @@ -1,9 +0,0 @@ -package chainstate - -func defaultArcConfig() *broadcastConfig { - return &broadcastConfig{ - ArcAPIs: []string{ - "https://arc.taal.com", - }, - } -} diff --git a/engine/chainstate/broadcast.go b/engine/chainstate/broadcast.go deleted file mode 100644 index 1065df25..00000000 --- a/engine/chainstate/broadcast.go +++ /dev/null @@ -1,95 +0,0 @@ -package chainstate - -import ( - "context" - "fmt" - "sync" - "time" -) - -var ( - - // broadcastQuestionableErrors are a list of errors that are not good broadcast responses, - // but need to be checked differently - broadcastQuestionableErrors = []string{ - "missing inputs", // The transaction has been sent to at least 1 Bitcoin node but parent transaction was not found. This status means that inputs are currently missing, but the transaction is not yet rejected. - } - - /* - TXN_ALREADY_KNOWN (suppressed - returns as success: true) - TXN_ALREADY_IN_MEMPOOL (suppressed - returns as success: true) - TXN_MEMPOOL_CONFLICT - NON_FINAL_POOL_FULL - TOO_LONG_NON_FINAL_CHAIN - BAD_TXNS_INPUTS_TOO_LARGE - BAD_TXNS_INPUTS_SPENT - NON_BIP68_FINAL - TOO_LONG_VALIDATION_TIME - BAD_TXNS_NONSTANDARD_INPUTS - ABSURDLY_HIGH_FEE - DUST - TX_FEE_TOO_LOW - */ -) - -// broadcast will broadcast using a standard strategy -// -// NOTE: if successful (in-mempool), no error will be returned -func (c *Client) broadcast(ctx context.Context, id, hex string, format HexFormatFlag, timeout time.Duration, resultsChannel chan *BroadcastResult) { - // Create a context (to cancel or timeout) - ctxWithCancel, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - var wg sync.WaitGroup - - for _, broadcastProvider := range createActiveProviders(id, hex, format) { - wg.Add(1) - go func(provider txBroadcastProvider) { - defer wg.Done() - resultsChannel <- broadcastToProvider(ctxWithCancel, ctx, provider, id, c, timeout) - }(broadcastProvider) - } - - wg.Wait() - close(resultsChannel) -} - -func createActiveProviders(txID, txHex string, format HexFormatFlag) []txBroadcastProvider { - providers := make([]txBroadcastProvider, 0, 1) - - pvdr := broadcastClientProvider{txID: txID, txHex: txHex, format: format} - providers = append(providers, &pvdr) - - return providers -} - -func broadcastToProvider(ctx, fallbackCtx context.Context, provider txBroadcastProvider, txID string, - c *Client, fallbackTimeout time.Duration, -) *BroadcastResult { - failure := provider.broadcast(ctx, c) - - if failure != nil { - checkMempool := containsAny(failure.Error.Error(), broadcastQuestionableErrors) - - if !checkMempool { // return original failure - return &BroadcastResult{ - Provider: provider.getName(), - Failure: failure, - } - } - - // check in Mempool as fallback - if transaction is there -> GREAT SUCCESS - if _, err := c.QueryTransaction(fallbackCtx, txID, requiredInMempool, fallbackTimeout); err != nil { - return &BroadcastResult{ - Provider: provider.getName(), - Failure: &BroadcastFailure{ - InvalidTx: failure.InvalidTx, - Error: fmt.Errorf("query tx failed: %w, initial error: %s", err, failure.Error.Error()), - }, - } - } - } - - // successful broadcasted or found in mempool - return &BroadcastResult{Provider: provider.getName()} -} diff --git a/engine/chainstate/broadcast_client_init.go b/engine/chainstate/broadcast_client_init.go deleted file mode 100644 index 9a1770fd..00000000 --- a/engine/chainstate/broadcast_client_init.go +++ /dev/null @@ -1,42 +0,0 @@ -package chainstate - -import ( - "context" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "github.com/bitcoin-sv/spv-wallet/engine/utils" - "github.com/bitcoin-sv/spv-wallet/models/bsv" -) - -func (c *Client) broadcastClientInit(ctx context.Context) error { - - bc := c.options.config.broadcastClient - if bc == nil { - err := spverrors.Newf("broadcast client is not configured") - return err - } - - if c.isFeeQuotesEnabled() { - // get the lowest fee - var feeQuotes []*broadcast.FeeQuote - feeQuotes, err := bc.GetFeeQuote(ctx) - if err != nil { - return spverrors.Wrapf(err, "failed to get fee quotes from broadcast client") - } - if len(feeQuotes) == 0 { - return spverrors.Newf("no fee quotes returned from broadcast client") - } - c.options.logger.Info().Msgf("got %d fee quote(s) from broadcast client", len(feeQuotes)) - fees := make([]bsv.FeeUnit, len(feeQuotes)) - for index, fee := range feeQuotes { - fees[index] = bsv.FeeUnit{ - Satoshis: int(fee.MiningFee.Satoshis), - Bytes: int(fee.MiningFee.Bytes), - } - } - c.options.config.feeUnit = utils.LowestFee(fees, c.options.config.feeUnit) - } - - return nil -} diff --git a/engine/chainstate/broadcast_providers.go b/engine/chainstate/broadcast_providers.go deleted file mode 100644 index 2c63f888..00000000 --- a/engine/chainstate/broadcast_providers.go +++ /dev/null @@ -1,81 +0,0 @@ -package chainstate - -import ( - "context" - "errors" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" -) - -// generic broadcast provider -type txBroadcastProvider interface { - getName() string - broadcast(ctx context.Context, c *Client) *BroadcastFailure -} - -// BroadcastClient provider -type broadcastClientProvider struct { - txID, txHex string - format HexFormatFlag -} - -func (provider *broadcastClientProvider) getName() string { - return ProviderBroadcastClient -} - -func (provider *broadcastClientProvider) broadcast(ctx context.Context, c *Client) *BroadcastFailure { - logger := c.options.logger - - logger.Debug(). - Str("txID", provider.txID). - Msgf("executing broadcast request for %s", provider.getName()) - - tx := broadcast.Transaction{ - Hex: provider.txHex, - } - - formatOpt := broadcast.WithRawFormat() - if provider.format.Contains(Ef) { - formatOpt = broadcast.WithEfFormat() - - logger.Debug(). - Str("txID", provider.txID). - Msgf("broadcast with broadcast-client in Extended Format") - } else { - logger.Debug(). - Str("txID", provider.txID). - Msgf("broadcast with broadcast-client in RawTx format") - } - - result, err := c.BroadcastClient().SubmitTransaction( - ctx, - &tx, - formatOpt, - broadcast.WithCallback(c.options.config.callbackURL, c.options.config.callbackToken), - ) - - if err != nil { - var arcError *broadcast.ArcError - if errors.As(err, &arcError) { - logger.Debug(). - Str("txID", provider.txID). - Msgf("error broadcast request for %s failed: %s", provider.getName(), arcError.Error()) - - return &BroadcastFailure{ - InvalidTx: arcError.IsRejectedTransaction(), - Error: arcError, - } - } - - return &BroadcastFailure{ - InvalidTx: false, - Error: err, - } - } - - logger.Debug(). - Str("txID", provider.txID). - Msgf("result broadcast request for %s blockhash: %s status: %s", provider.getName(), result.BlockHash, result.TxStatus.String()) - - return nil -} diff --git a/engine/chainstate/broadcast_test.go b/engine/chainstate/broadcast_test.go deleted file mode 100644 index 1a1a0920..00000000 --- a/engine/chainstate/broadcast_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package chainstate - -import ( - "context" - "testing" - "time" - - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_doesErrorContain(t *testing.T) { - t.Run("valid contains", func(t *testing.T) { - success := containsAny("this is the test message", []string{"another", "test message"}) - assert.Equal(t, true, success) - }) - - t.Run("valid contains - equal case", func(t *testing.T) { - success := containsAny("this is the TEST message", []string{"another", "test message"}) - assert.Equal(t, true, success) - }) - - t.Run("does not contain", func(t *testing.T) { - success := containsAny("this is the test message", []string{"another", "nope"}) - assert.Equal(t, false, success) - }) -} - -// TestClient_Broadcast_BroadcastClient will test the method Broadcast() with BroadcastClient -func TestClient_Broadcast_BroadcastClient(t *testing.T) { - t.Parallel() - - t.Run("broadcast - success (broadcast-client)", func(t *testing.T) { - // given - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - c := NewTestClient( - context.Background(), t, - WithBroadcastClient(bc), - ) - - // when - res := c.Broadcast( - context.Background(), broadcastExample1TxID, broadcastExample1TxHex, RawTx, defaultBroadcastTimeOut, - ) - - // then - require.NotNil(t, res) - require.Nil(t, res.Failure) - - assert.Equal(t, ProviderBroadcastClient, res.Provider) - }) - - t.Run("broadcast - success (multiple broadcast-client)", func(t *testing.T) { - // given - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockFailure). - WithMockArc(broadcast_client_mock.MockFailure). - WithMockArc(broadcast_client_mock.MockSuccess). - WithMockArc(broadcast_client_mock.MockFailure). - Build() - c := NewTestClient( - context.Background(), t, - WithBroadcastClient(bc), - ) - - // when - res := c.Broadcast( - context.Background(), broadcastExample1TxID, broadcastExample1TxHex, RawTx, 1*time.Second, - ) - - // then - require.NotNil(t, res) - require.Nil(t, res.Failure) - - assert.Equal(t, ProviderBroadcastClient, res.Provider) - }) -} diff --git a/engine/chainstate/broadcast_utils.go b/engine/chainstate/broadcast_utils.go deleted file mode 100644 index 229645a0..00000000 --- a/engine/chainstate/broadcast_utils.go +++ /dev/null @@ -1,56 +0,0 @@ -package chainstate - -import ( - "errors" - "strings" -) - -// containsAny checks if the given string contains any of the provided substrings -func containsAny(s string, substr []string) bool { - lower := strings.ToLower(s) - for _, str := range substr { - if strings.Contains(lower, str) { - return true - } - } - return false -} - -func groupBroadcastResults(results []*BroadcastResult) *BroadcastResult { - switch len(results) { - case 0: - return nil - case 1: - return results[0] - default: - return &BroadcastResult{ - Provider: ProviderAll, - Failure: groupBroadcastFailures(results), - } - } -} - -func groupBroadcastFailures(results []*BroadcastResult) *BroadcastFailure { - invalidTx := false - var err error - - for _, r := range results { - if r.Failure == nil { - continue - } - if r.Failure.InvalidTx { - invalidTx = true - } - - err = errors.Join(err, r.Failure.Error) - } - - if err != nil { - return &BroadcastFailure{ - InvalidTx: invalidTx, - Error: err, - } - } - - return nil -} diff --git a/engine/chainstate/chainstate.go b/engine/chainstate/chainstate.go deleted file mode 100644 index e0eea3f7..00000000 --- a/engine/chainstate/chainstate.go +++ /dev/null @@ -1,85 +0,0 @@ -/* -Package chainstate is the on-chain data service abstraction layer -*/ -package chainstate - -import ( - "context" - "time" - - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" -) - -// HexFormatFlag transaction hex format -type HexFormatFlag byte - -const ( - // RawTx is the raw transaction format - RawTx HexFormatFlag = 1 << iota // 1 - // Ef is the Extended transaction format - Ef -) - -// Contains checks if the flag contains specific bytes -func (flag HexFormatFlag) Contains(other HexFormatFlag) bool { - return (flag & other) == other -} - -// SupportedBroadcastFormats returns supported formats based on active providers -func (c *Client) SupportedBroadcastFormats() HexFormatFlag { - return RawTx | Ef -} - -// BroadcastResult contains data about broadcasting to provider -type BroadcastResult struct { - Provider string - Failure *BroadcastFailure -} - -// BroadcastFailure contains data about broadcast failure -type BroadcastFailure struct { - InvalidTx bool - Error error -} - -// Broadcast will attempt to broadcast a transaction using the given providers -func (c *Client) Broadcast(ctx context.Context, id, txHex string, format HexFormatFlag, timeout time.Duration) *BroadcastResult { - results := make(chan *BroadcastResult) - go c.broadcast(ctx, id, txHex, format, timeout, results) - - failures := make([]*BroadcastResult, 0) - - for r := range results { - if r.Failure != nil { - failures = append(failures, r) - } else { - return r // one successful result is sufficient, and we consider the entire broadcast process complete. We disregard failures from other providers - } - } - - return groupBroadcastResults(failures) -} - -// QueryTransaction will get the transaction info from all providers returning the "first" valid result -// -// Note: this is slow, but follows a specific order: ARC -> WhatsOnChain -func (c *Client) QueryTransaction( - ctx context.Context, id string, requiredIn RequiredIn, timeout time.Duration, -) (transaction *TransactionInfo, err error) { - if c.options.metrics != nil { - end := c.options.metrics.TrackQueryTransaction() - defer func() { - success := err == nil - end(success) - }() - } - - // Basic validation - if len(id) < 50 { - return nil, spverrors.ErrInvalidTransactionID - } else if !c.validRequirement(requiredIn) { - return nil, spverrors.ErrInvalidRequirements - } - - return c.query(ctx, id, requiredIn, timeout) -} diff --git a/engine/chainstate/chainstate_test.go b/engine/chainstate/chainstate_test.go deleted file mode 100644 index 03e3c35a..00000000 --- a/engine/chainstate/chainstate_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package chainstate - -import ( - "context" - "testing" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" -) - -// NewTestClient returns a test client -func NewTestClient(ctx context.Context, t *testing.T, opts ...ClientOps) ClientInterface { - logger := zerolog.Nop() - c, err := NewClient( - ctx, append(opts, WithDebugging(), WithLogger(&logger))..., - ) - require.NoError(t, err) - require.NotNil(t, c) - return c -} diff --git a/engine/chainstate/client.go b/engine/chainstate/client.go deleted file mode 100644 index 8b5357d5..00000000 --- a/engine/chainstate/client.go +++ /dev/null @@ -1,146 +0,0 @@ -package chainstate - -import ( - "context" - "time" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - "github.com/bitcoin-sv/spv-wallet/engine/logging" - "github.com/bitcoin-sv/spv-wallet/engine/metrics" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "github.com/bitcoin-sv/spv-wallet/models/bsv" - "github.com/rs/zerolog" -) - -type ( - - // Client is the client (configuration) - Client struct { - options *clientOptions - } - - // clientOptions holds all the configuration for the client - clientOptions struct { - config *syncConfig // Configuration for broadcasting and other chain-state actions - debug bool // For extra logs and additional debug information - logger *zerolog.Logger // Logger interface - metrics *metrics.Metrics // For collecting metrics (if enabled) - userAgent string // Custom user agent for outgoing HTTP Requests - } - - // syncConfig holds all the configuration about the different sync processes - syncConfig struct { - callbackURL string // Broadcast callback URL - callbackToken string // Broadcast callback access token - excludedProviders []string // List of provider names - httpClient HTTPInterface // Custom HTTP client (for example WOC) - broadcastClientConfig *broadcastConfig // Broadcast client configuration - network Network // Current network (mainnet, testnet, stn) - queryTimeout time.Duration // Timeout for transaction query - broadcastClient broadcast.Client // Broadcast client - feeUnit *bsv.FeeUnit // The lowest fees among all miners - feeQuotes bool // If set, feeUnit will be updated with fee quotes from miner's - } - - broadcastConfig struct { - ArcAPIs []string - } -) - -// NewClient creates a new client for all on-chain functionality -// -// If no options are given, it will use the defaultClientOptions() -func NewClient(ctx context.Context, opts ...ClientOps) (ClientInterface, error) { - // Create a new client with defaults - client := &Client{options: defaultClientOptions()} - - // Overwrite defaults with any set by user - for _, opt := range opts { - opt(client.options) - } - - // Set logger if not set - if client.options.logger == nil { - client.options.logger = logging.GetDefaultLogger() - } - - if err := client.initActiveProvider(ctx); err != nil { - return nil, err - } - - if err := client.checkFeeUnit(); err != nil { - return nil, err - } - - // Return the client - return client, nil -} - -// Debug will set the debug flag -func (c *Client) Debug(on bool) { - c.options.debug = on -} - -// DebugLog will display verbose logs -func (c *Client) DebugLog(text string) { - c.options.logger.Debug().Msg(text) -} - -// IsDebug will return if debugging is enabled -func (c *Client) IsDebug() bool { - return c.options.debug -} - -// HTTPClient will return the HTTP client -func (c *Client) HTTPClient() HTTPInterface { - return c.options.config.httpClient -} - -// Network will return the current network -func (c *Client) Network() Network { - return c.options.config.network -} - -// BroadcastClient will return the BroadcastClient client -func (c *Client) BroadcastClient() broadcast.Client { - return c.options.config.broadcastClient -} - -// QueryTimeout will return the query timeout -func (c *Client) QueryTimeout() time.Duration { - return c.options.config.queryTimeout -} - -// FeeUnit will return feeUnit -func (c *Client) FeeUnit() *bsv.FeeUnit { - return c.options.config.feeUnit -} - -func (c *Client) isFeeQuotesEnabled() bool { - return c.options.config.feeQuotes -} - -func (c *Client) initActiveProvider(ctx context.Context) error { - return c.broadcastClientInit(ctx) -} - -func (c *Client) checkFeeUnit() error { - feeUnit := c.options.config.feeUnit - switch { - case feeUnit == nil: - return spverrors.Newf("no fee unit found") - case !feeUnit.IsValid(): - return spverrors.Newf("invalid fee unit found: %s", feeUnit) - case feeUnit.IsZero(): - c.options.logger.Warn().Msg("fee unit suggests no fees (free)") - default: - var feeUnitSource string - if c.isFeeQuotesEnabled() { - feeUnitSource = "fee quotes" - } else { - feeUnitSource = "configured fee_unit" - } - c.options.logger.Info().Msgf("using fee unit: %s from %s", feeUnit, feeUnitSource) - } - return nil -} diff --git a/engine/chainstate/client_options.go b/engine/chainstate/client_options.go deleted file mode 100644 index 52ecb5ea..00000000 --- a/engine/chainstate/client_options.go +++ /dev/null @@ -1,131 +0,0 @@ -package chainstate - -import ( - "time" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - "github.com/bitcoin-sv/spv-wallet/engine/metrics" - "github.com/bitcoin-sv/spv-wallet/models/bsv" - "github.com/rs/zerolog" -) - -// ClientOps allow functional options to be supplied -// that overwrite default client options. -type ClientOps func(c *clientOptions) - -// defaultClientOptions will return an clientOptions struct with the default settings -// -// Useful for starting with the default and then modifying as needed -func defaultClientOptions() *clientOptions { - // Set the default options - return &clientOptions{ - config: &syncConfig{ - httpClient: nil, - broadcastClientConfig: defaultArcConfig(), - network: MainNet, - queryTimeout: defaultQueryTimeOut, - broadcastClient: nil, - feeQuotes: true, - feeUnit: nil, // fee has to be set explicitly or via fee quotes - }, - debug: false, - metrics: nil, - } -} - -// WithDebugging will enable debugging mode -func WithDebugging() ClientOps { - return func(c *clientOptions) { - c.debug = true - } -} - -// WithHTTPClient will set a custom HTTP client -func WithHTTPClient(client HTTPInterface) ClientOps { - return func(c *clientOptions) { - if client != nil { - c.config.httpClient = client - } - } -} - -// WithQueryTimeout will set a different timeout for transaction querying -func WithQueryTimeout(timeout time.Duration) ClientOps { - return func(c *clientOptions) { - if timeout > 0 { - c.config.queryTimeout = timeout - } - } -} - -// WithUserAgent will set the custom user agent -func WithUserAgent(agent string) ClientOps { - return func(c *clientOptions) { - if len(agent) > 0 { - c.userAgent = agent - } - } -} - -// WithNetwork will set the network to use -func WithNetwork(network Network) ClientOps { - return func(c *clientOptions) { - if len(network) > 0 { - c.config.network = network - } - } -} - -// WithLogger will set a custom logger -func WithLogger(customLogger *zerolog.Logger) ClientOps { - return func(c *clientOptions) { - if customLogger != nil { - c.logger = customLogger - } - } -} - -// WithExcludedProviders will set a list of excluded providers -func WithExcludedProviders(providers []string) ClientOps { - return func(c *clientOptions) { - if len(providers) > 0 { - c.config.excludedProviders = providers - } - } -} - -// WithFeeQuotes will set feeQuotes flag as true -func WithFeeQuotes(enabled bool) ClientOps { - return func(c *clientOptions) { - c.config.feeQuotes = enabled - } -} - -// WithFeeUnit will set the fee unit -func WithFeeUnit(feeUnit *bsv.FeeUnit) ClientOps { - return func(c *clientOptions) { - c.config.feeUnit = feeUnit - } -} - -// WithBroadcastClient will set broadcast client APIs -func WithBroadcastClient(client broadcast.Client) ClientOps { - return func(c *clientOptions) { - c.config.broadcastClient = client - } -} - -// WithCallback will set broadcast callback settings -func WithCallback(callbackURL, callbackAuthToken string) ClientOps { - return func(c *clientOptions) { - c.config.callbackURL = callbackURL - c.config.callbackToken = callbackAuthToken - } -} - -// WithMetrics will set metrics -func WithMetrics(metrics *metrics.Metrics) ClientOps { - return func(c *clientOptions) { - c.metrics = metrics - } -} diff --git a/engine/chainstate/client_options_test.go b/engine/chainstate/client_options_test.go deleted file mode 100644 index 518693aa..00000000 --- a/engine/chainstate/client_options_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package chainstate - -import ( - "context" - "net/http" - "testing" - "time" - - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestWithDebugging will test the method WithDebugging() -func TestWithDebugging(t *testing.T) { - - t.Run("get opts", func(t *testing.T) { - opt := WithDebugging() - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("apply opts", func(t *testing.T) { - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - - opts := []ClientOps{ - WithDebugging(), - WithBroadcastClient(bc), - } - c, err := NewClient(context.Background(), opts...) - require.NotNil(t, c) - require.NoError(t, err) - - assert.Equal(t, true, c.IsDebug()) - }) -} - -// TestWithHTTPClient will test the method WithHTTPClient() -func TestWithHTTPClient(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithHTTPClient(nil) - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying nil", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithHTTPClient(nil) - opt(options) - assert.Nil(t, options.config.httpClient) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - customClient := &http.Client{} - opt := WithHTTPClient(customClient) - opt(options) - assert.Equal(t, customClient, options.config.httpClient) - }) -} - -// TestWithBroadcastClient will test the method WithBroadcastClient() -func TestWithBroadcastClient(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithBroadcastClient(nil) - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying nil", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithBroadcastClient(nil) - opt(options) - assert.Nil(t, options.config.broadcastClient) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - customClient := broadcast_client_mock.Builder().WithMockArc(broadcast_client_mock.MockSuccess).Build() - opt := WithBroadcastClient(customClient) - opt(options) - assert.Equal(t, customClient, options.config.broadcastClient) - }) -} - -// TestWithQueryTimeout will test the method WithQueryTimeout() -func TestWithQueryTimeout(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithQueryTimeout(0) - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying empty value", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithQueryTimeout(0) - opt(options) - assert.Equal(t, time.Duration(0), options.config.queryTimeout) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithQueryTimeout(10 * time.Second) - opt(options) - assert.Equal(t, 10*time.Second, options.config.queryTimeout) - }) -} - -// TestWithNetwork will test the method WithNetwork() -func TestWithNetwork(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithNetwork("") - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying empty string", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithNetwork("") - opt(options) - assert.Equal(t, Network(""), options.config.network) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithNetwork(TestNet) - opt(options) - assert.Equal(t, TestNet, options.config.network) - }) -} - -// TestWithUserAgent will test the method WithUserAgent() -func TestWithUserAgent(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithUserAgent("") - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying empty string", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithUserAgent("") - opt(options) - assert.Equal(t, "", options.userAgent) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithUserAgent("test agent") - opt(options) - assert.Equal(t, "test agent", options.userAgent) - }) -} - -// TestWithLogger will test the method WithLogger() -func TestWithLogger(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithLogger(nil) - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying nil", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithLogger(nil) - opt(options) - assert.Nil(t, options.logger) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - customLogger := zerolog.Nop() - opt := WithLogger(&customLogger) - opt(options) - assert.Equal(t, &customLogger, options.logger) - }) -} - -// TestWithExcludedProviders will test the method WithExcludedProviders() -func TestWithExcludedProviders(t *testing.T) { - t.Parallel() - - t.Run("check type", func(t *testing.T) { - opt := WithExcludedProviders(nil) - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("test applying empty string", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithExcludedProviders([]string{""}) - opt(options) - assert.Equal(t, []string{""}, options.config.excludedProviders) - }) - - t.Run("test applying option", func(t *testing.T) { - options := &clientOptions{ - config: &syncConfig{}, - } - opt := WithExcludedProviders([]string{ProviderBroadcastClient}) - opt(options) - assert.Equal(t, 1, len(options.config.excludedProviders)) - assert.Equal(t, ProviderBroadcastClient, options.config.excludedProviders[0]) - }) -} diff --git a/engine/chainstate/client_test.go b/engine/chainstate/client_test.go deleted file mode 100644 index 54fe052d..00000000 --- a/engine/chainstate/client_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package chainstate - -import ( - "context" - "net/http" - "testing" - "time" - - broadcast_client "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client" - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestNewClient will test the method NewClient() -func TestNewClient(t *testing.T) { - t.Parallel() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - - t.Run("basic defaults", func(t *testing.T) { - c, err := NewClient( - context.Background(), - WithBroadcastClient(bc), - ) - require.NoError(t, err) - require.NotNil(t, c) - assert.Equal(t, false, c.IsDebug()) - assert.Equal(t, MainNet, c.Network()) - assert.Nil(t, c.HTTPClient()) - }) - - t.Run("custom http client", func(t *testing.T) { - customClient := &http.Client{} - c, err := NewClient( - context.Background(), - WithHTTPClient(customClient), - WithBroadcastClient(bc), - ) - require.NoError(t, err) - require.NotNil(t, c) - assert.NotNil(t, c.HTTPClient()) - assert.Equal(t, customClient, c.HTTPClient()) - }) - - t.Run("custom broadcast client", func(t *testing.T) { - arcConfig := broadcast_client.ArcClientConfig{ - Token: "", - APIUrl: "https://arc.gorillapool.io", - } - logger := zerolog.Nop() - customClient := broadcast_client.Builder().WithArc(arcConfig, &logger).Build() - require.NotNil(t, customClient) - c, err := NewClient( - context.Background(), - WithBroadcastClient(customClient), - ) - require.NoError(t, err) - require.NotNil(t, c) - assert.NotNil(t, c.BroadcastClient()) - assert.Equal(t, customClient, c.BroadcastClient()) - }) - - t.Run("custom query timeout", func(t *testing.T) { - timeout := 55 * time.Second - c, err := NewClient( - context.Background(), - WithQueryTimeout(timeout), - WithBroadcastClient(bc), - ) - require.NoError(t, err) - require.NotNil(t, c) - assert.Equal(t, timeout, c.QueryTimeout()) - }) - - t.Run("custom network - test", func(t *testing.T) { - c, err := NewClient( - context.Background(), - WithNetwork(TestNet), - WithBroadcastClient(bc), - ) - require.NoError(t, err) - require.NotNil(t, c) - assert.Equal(t, TestNet, c.Network()) - }) -} diff --git a/engine/chainstate/definitions.go b/engine/chainstate/definitions.go deleted file mode 100644 index 901acc2a..00000000 --- a/engine/chainstate/definitions.go +++ /dev/null @@ -1,144 +0,0 @@ -package chainstate - -import ( - "time" -) - -// Chainstate configuration defaults -const ( - defaultBroadcastTimeOut = 15 * time.Second - defaultFalsePositiveRate = 0.01 - defaultFeeLastCheckIgnore = 2 * time.Minute - defaultMaxNumberOfDestinations = 100000 - defaultQueryTimeOut = 15 * time.Second - whatsOnChainRateLimitWithKey = 20 -) - -const ( - // FilterBloom is for bloom filters - FilterBloom = "bloom" - - // FilterRegex is for regex filters - FilterRegex = "regex" -) - -// Internal network names -const ( - mainNet = "mainnet" // Main Public Bitcoin network - mainNetAlt = "main" // Main Public Bitcoin network - stn = "stn" // BitcoinSV Public Stress Test Network (https://bitcoinscaling.io/) - testNet = "testnet" // Public test network - testNetAlt = "test" // Public test network -) - -// Requirements and providers -const ( - requiredInMempool = "mempool" // Requirement for tx query (has to be >= mempool) - requiredOnChain = "on-chain" // Requirement for tx query (has to be == on-chain) -) - -// List of providers -const ( - ProviderAll = "all" // All providers (used for errors etc) - ProviderBroadcastClient = "broadcastclient" // Query & broadcast provider for configured miners - ProviderNone = "none" // No providers (used to indicate no providers) -) - -// BlockInfo is the response info about a returned block -type BlockInfo struct { - Bits string `json:"bits"` - ChainWork string `json:"chainwork"` - CoinbaseTx CoinbaseTxInfo `json:"coinbaseTx"` - Confirmations int64 `json:"confirmations"` - Difficulty float64 `json:"difficulty"` - Hash string `json:"hash"` - Height int64 `json:"height"` - MedianTime int64 `json:"mediantime"` - MerkleRoot string `json:"merkleroot"` - Miner string `json:"Bmgpool"` - NextBlockHash string `json:"nextblockhash"` - Nonce int64 `json:"nonce"` - Pages Page `json:"pages"` - PreviousBlockHash string `json:"previousblockhash"` - Size int64 `json:"size"` - Time int64 `json:"time"` - TotalFees float64 `json:"totalFees"` - Tx []string `json:"tx"` - TxCount int64 `json:"txcount"` - Version int64 `json:"version"` - VersionHex string `json:"versionHex"` -} - -// CoinbaseTxInfo is the coinbase tx info inside the BlockInfo -type CoinbaseTxInfo struct { - BlockHash string `json:"blockhash"` - BlockTime int64 `json:"blocktime"` - Confirmations int64 `json:"confirmations"` - Hash string `json:"hash"` - Hex string `json:"hex"` - LockTime int64 `json:"locktime"` - Size int64 `json:"size"` - Time int64 `json:"time"` - TxID string `json:"txid"` - Version int64 `json:"version"` - Vin []VinInfo `json:"vin"` - Vout []VoutInfo `json:"vout"` -} - -// Page is used as a subtype for BlockInfo -type Page struct { - Size int64 `json:"size"` - URI []string `json:"uri"` -} - -// VinInfo is the vin info inside the CoinbaseTxInfo -type VinInfo struct { - Coinbase string `json:"coinbase"` - ScriptSig ScriptSigInfo `json:"scriptSig"` - Sequence int64 `json:"sequence"` - TxID string `json:"txid"` - Vout int64 `json:"vout"` -} - -// VoutInfo is the vout info inside the CoinbaseTxInfo -type VoutInfo struct { - N int64 `json:"n"` - ScriptPubKey ScriptPubKeyInfo `json:"scriptPubKey"` - Value float64 `json:"value"` -} - -// ScriptSigInfo is the scriptSig info inside the VinInfo -type ScriptSigInfo struct { - Asm string `json:"asm"` - Hex string `json:"hex"` -} - -// ScriptPubKeyInfo is the scriptPubKey info inside the VoutInfo -type ScriptPubKeyInfo struct { - Addresses []string `json:"addresses"` - Asm string `json:"asm"` - Hex string `json:"hex"` - IsTruncated bool `json:"isTruncated"` - OpReturn string `json:"-"` // todo: support this (can be an object of key/vals based on the op return data) - ReqSigs int64 `json:"reqSigs"` - Type string `json:"type"` -} - -// TxInfo is the response info about a returned tx -type TxInfo struct { - BlockHash string `json:"blockhash"` - BlockHeight int64 `json:"blockheight"` - BlockTime int64 `json:"blocktime"` - Confirmations int64 `json:"confirmations"` - Hash string `json:"hash"` - Hex string `json:"hex"` - LockTime int64 `json:"locktime"` - Size int64 `json:"size"` - Time int64 `json:"time"` - TxID string `json:"txid"` - Version int64 `json:"version"` - Vin []VinInfo `json:"vin"` - Vout []VoutInfo `json:"vout"` - - Error string `json:"error"` -} diff --git a/engine/chainstate/interface.go b/engine/chainstate/interface.go deleted file mode 100644 index 42f7608e..00000000 --- a/engine/chainstate/interface.go +++ /dev/null @@ -1,42 +0,0 @@ -package chainstate - -import ( - "context" - "net/http" - "time" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - "github.com/bitcoin-sv/spv-wallet/models/bsv" -) - -// HTTPInterface is the HTTP client interface -type HTTPInterface interface { - Do(req *http.Request) (*http.Response, error) -} - -// ChainService is the chain related methods -type ChainService interface { - SupportedBroadcastFormats() HexFormatFlag - Broadcast(ctx context.Context, id, txHex string, format HexFormatFlag, timeout time.Duration) *BroadcastResult - QueryTransaction( - ctx context.Context, id string, requiredIn RequiredIn, timeout time.Duration, - ) (*TransactionInfo, error) -} - -// ProviderServices is the chainstate providers interface -type ProviderServices interface { - BroadcastClient() broadcast.Client -} - -// ClientInterface is the chainstate client interface -type ClientInterface interface { - ChainService - ProviderServices - Debug(on bool) - DebugLog(text string) - HTTPClient() HTTPInterface - IsDebug() bool - Network() Network - QueryTimeout() time.Duration - FeeUnit() *bsv.FeeUnit -} diff --git a/engine/chainstate/mock_const.go b/engine/chainstate/mock_const.go deleted file mode 100644 index ef7e66e6..00000000 --- a/engine/chainstate/mock_const.go +++ /dev/null @@ -1,19 +0,0 @@ -package chainstate - -import ( - "github.com/bitcoin-sv/spv-wallet/models/bsv" -) - -const ( - // Dummy transaction data - broadcastExample1TxID = "15d31d00ed7533a83d7ab206115d7642812ec04a2cbae4248365febb82576ff3" - broadcastExample1TxHex = "0100000001018d7ab1a0f0253120a0cb284e4170b47e5f83f70faaba5b0b55bbeeef624b45010000006b483045022100d5b0dddf76da9088e21cf1277f064dc7832c3da666732f003ee48f2458142e9a02201fe725a1c455b2bd964779391ae105b87730881f211cd299ca36d70d74d715ab412103673dffd80561b87825658f74076da805c238e8c47f25b5d804893c335514d074ffffffff02c4090000000000001976a914777242b335bc7781f43e1b05c60d8c2f2d08b44c88ac962e0000000000001976a91467d93a70ac575e15abb31bc8272a00ab1495d48388ac00000000" - onChainExample1TxID = "908c26f8227fa99f1b26f99a19648653a1382fb3b37b03870e9c138894d29b3b" - onChainExampleArcTxID = "a11b9e1ee08e264f9add02e4afa40dad3c00a23f250ac04449face095c68fab7" -) - -// MockDefaultFee is a mock default fee used for assertions -var MockDefaultFee = &bsv.FeeUnit{ - Satoshis: 1, - Bytes: 1000, -} diff --git a/engine/chainstate/network.go b/engine/chainstate/network.go deleted file mode 100644 index d6f13ac6..00000000 --- a/engine/chainstate/network.go +++ /dev/null @@ -1,30 +0,0 @@ -package chainstate - -// Network is the supported Bitcoin networks -type Network string - -// Supported networks -const ( - MainNet Network = mainNet // Main public network - StressTestNet Network = stn // Stress Test Network (https://bitcoinscaling.io/) - TestNet Network = testNet // Test public network -) - -// String is the string version of network -func (n Network) String() string { - return string(n) -} - -// Alternate is the alternate string version -func (n Network) Alternate() string { - switch n { - case MainNet: - return mainNetAlt - case TestNet: - return testNetAlt - case StressTestNet: - return stn - default: - return "" - } -} diff --git a/engine/chainstate/network_test.go b/engine/chainstate/network_test.go deleted file mode 100644 index 14d2640a..00000000 --- a/engine/chainstate/network_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package chainstate - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestNetwork_String will test the method String() -func TestNetwork_String(t *testing.T) { - t.Parallel() - - t.Run("test all networks", func(t *testing.T) { - assert.Equal(t, mainNet, MainNet.String()) - assert.Equal(t, stn, StressTestNet.String()) - assert.Equal(t, testNet, TestNet.String()) - }) - - t.Run("unknown network", func(t *testing.T) { - un := Network("") - assert.Equal(t, "", un.String()) - }) -} - -// TestNetwork_Alternate will test the method Alternate() -func TestNetwork_Alternate(t *testing.T) { - t.Parallel() - - t.Run("test all networks", func(t *testing.T) { - assert.Equal(t, mainNetAlt, MainNet.Alternate()) - assert.Equal(t, stn, StressTestNet.Alternate()) - assert.Equal(t, testNetAlt, TestNet.Alternate()) - }) - - t.Run("unknown network", func(t *testing.T) { - un := Network("") - assert.Equal(t, "", un.Alternate()) - }) -} diff --git a/engine/chainstate/requirements.go b/engine/chainstate/requirements.go deleted file mode 100644 index b1baf05c..00000000 --- a/engine/chainstate/requirements.go +++ /dev/null @@ -1,33 +0,0 @@ -package chainstate - -// RequiredIn is the requirements for querying transaction information -type RequiredIn string - -const ( - // RequiredInMempool is the transaction in mempool? (minimum requirement for a valid response) - RequiredInMempool RequiredIn = requiredInMempool - - // RequiredOnChain is the transaction in on-chain? (minimum requirement for a valid response) - RequiredOnChain RequiredIn = requiredOnChain -) - -// ValidRequirement will return valid if the requirement is known -func (c *Client) validRequirement(requirement RequiredIn) bool { - return requirement == RequiredOnChain || requirement == RequiredInMempool -} - -func checkRequirement(requirement RequiredIn, id string, txInfo *TransactionInfo, onChainCondition bool) bool { - switch requirement { - case RequiredInMempool: - return txInfo.ID == id - case RequiredOnChain: - return onChainCondition - default: - return false - } -} - -func checkRequirementArc(requirement RequiredIn, id string, txInfo *TransactionInfo) bool { - isConfirmedOnChain := len(txInfo.BlockHash) > 0 && txInfo.TxStatus != "" - return checkRequirement(requirement, id, txInfo, isConfirmedOnChain) -} diff --git a/engine/chainstate/transaction.go b/engine/chainstate/transaction.go deleted file mode 100644 index 8e24d3e3..00000000 --- a/engine/chainstate/transaction.go +++ /dev/null @@ -1,68 +0,0 @@ -package chainstate - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - trx "github.com/bitcoin-sv/go-sdk/transaction" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" -) - -// query will try ALL providers in order and return the first "valid" response based on requirements -func (c *Client) query(ctx context.Context, id string, requiredIn RequiredIn, - timeout time.Duration, -) (*TransactionInfo, error) { - // Create a context (to cancel or timeout) - ctxWithCancel, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - txInfo, err := queryBroadcastClient( - ctxWithCancel, c, id, - ) - if err != nil { - return nil, err - } - if !checkRequirementArc(requiredIn, id, txInfo) { - return nil, spverrors.ErrCouldNotFindTransaction - } - - return txInfo, nil -} - -// queryBroadcastClient will submit a query transaction request to a go-broadcast-client -func queryBroadcastClient(ctx context.Context, client ClientInterface, id string) (*TransactionInfo, error) { - resp, err := client.BroadcastClient().QueryTransaction(ctx, id) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return nil, spverrors.ErrBroadcastUnreachable - } - var arcError *broadcast.ArcError - if errors.As(err, &arcError) { - if arcError.IsRejectedTransaction() { - return nil, spverrors.ErrBroadcastRejectedTransaction.Wrap(err) - } - } - return nil, spverrors.ErrCouldNotFindTransaction.Wrap(err) - } - - if resp == nil || !strings.EqualFold(resp.TxID, id) { - return nil, spverrors.ErrTransactionIDMismatch - } - - bump, err := trx.NewMerklePathFromHex(resp.BaseTxResponse.MerklePath) - if err != nil { - return nil, spverrors.ErrBroadcastWrongBUMPResponse.Wrap(err) - } - - return &TransactionInfo{ - BlockHash: resp.BlockHash, - BlockHeight: resp.BlockHeight, - ID: resp.TxID, - Provider: resp.Miner, - TxStatus: resp.TxStatus, - BUMP: bump, - }, nil -} diff --git a/engine/chainstate/transaction_info.go b/engine/chainstate/transaction_info.go deleted file mode 100644 index f9e4b90e..00000000 --- a/engine/chainstate/transaction_info.go +++ /dev/null @@ -1,16 +0,0 @@ -package chainstate - -import ( - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - trx "github.com/bitcoin-sv/go-sdk/transaction" -) - -// TransactionInfo is the universal information about the transaction found from a chain provider -type TransactionInfo struct { - BlockHash string `json:"block_hash,omitempty"` // Block hash of the transaction - BlockHeight int64 `json:"block_height"` // Block height of the transaction - ID string `json:"id"` // Transaction ID (Hex) - Provider string `json:"provider,omitempty"` // Provider is our internal source - BUMP *trx.MerklePath `json:"bump,omitempty"` // Merkle proof in BUMP format - TxStatus broadcast.TxStatus `json:"tx_status,omitempty"` // Status of the transaction -} diff --git a/engine/chainstate/transaction_test.go b/engine/chainstate/transaction_test.go deleted file mode 100644 index 4eb84a2d..00000000 --- a/engine/chainstate/transaction_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package chainstate - -import ( - "context" - "testing" - - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" - broadcast_fixtures "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock/fixtures" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestClient_Transaction will test the method QueryTransaction() -func TestClient_Transaction(t *testing.T) { - t.Parallel() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - - t.Run("error - missing id", func(t *testing.T) { - // given - c := NewTestClient(context.Background(), t, WithBroadcastClient(bc)) - - // when - info, err := c.QueryTransaction( - context.Background(), "", RequiredOnChain, defaultQueryTimeOut, - ) - - // then - require.Error(t, err) - require.Nil(t, info) - assert.ErrorIs(t, err, spverrors.ErrInvalidTransactionID) - }) - - t.Run("error - missing requirements", func(t *testing.T) { - // given - c := NewTestClient(context.Background(), t, WithBroadcastClient(bc)) - - // when - info, err := c.QueryTransaction( - context.Background(), onChainExample1TxID, - "", defaultQueryTimeOut, - ) - - // then - require.Error(t, err) - require.Nil(t, info) - assert.ErrorIs(t, err, spverrors.ErrInvalidRequirements) - }) -} - -func TestClient_Transaction_BroadcastClient(t *testing.T) { - t.Parallel() - - t.Run("query transaction success - broadcastClient", func(t *testing.T) { - // given - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - c := NewTestClient( - context.Background(), t, - WithBroadcastClient(bc), - ) - - // when - info, err := c.QueryTransaction( - context.Background(), onChainExampleArcTxID, - RequiredInMempool, defaultQueryTimeOut, - ) - - // then - require.NoError(t, err) - require.NotNil(t, info) - assert.Equal(t, onChainExampleArcTxID, info.ID) - assert.Equal(t, broadcast_fixtures.TxBlockHash, info.BlockHash) - assert.Equal(t, broadcast_fixtures.TxBlockHeight, info.BlockHeight) - assert.Equal(t, broadcast_fixtures.ProviderMain, info.Provider) - }) - - t.Run("valid - stress test network - broadcastClient", func(t *testing.T) { - // given - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - c := NewTestClient( - context.Background(), t, - WithBroadcastClient(bc), - WithNetwork(StressTestNet), - ) - - // when - info, err := c.QueryTransaction( - context.Background(), onChainExampleArcTxID, - RequiredInMempool, defaultQueryTimeOut, - ) - - // then - require.NoError(t, err) - require.NotNil(t, info) - assert.Equal(t, onChainExampleArcTxID, info.ID) - assert.Equal(t, broadcast_fixtures.TxBlockHash, info.BlockHash) - assert.Equal(t, broadcast_fixtures.TxBlockHeight, info.BlockHeight) - assert.Equal(t, broadcast_fixtures.ProviderMain, info.Provider) - }) - - t.Run("valid - test network - broadcast", func(t *testing.T) { - // given - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - c := NewTestClient( - context.Background(), t, - WithBroadcastClient(bc), - WithNetwork(TestNet), - ) - - // when - info, err := c.QueryTransaction( - context.Background(), onChainExampleArcTxID, - RequiredInMempool, defaultQueryTimeOut, - ) - - // then - require.NoError(t, err) - require.NotNil(t, info) - assert.Equal(t, onChainExampleArcTxID, info.ID) - assert.Equal(t, broadcast_fixtures.TxBlockHash, info.BlockHash) - assert.Equal(t, broadcast_fixtures.TxBlockHeight, info.BlockHeight) - assert.Equal(t, broadcast_fixtures.ProviderMain, info.Provider) - }) -} diff --git a/engine/chainstate/types.go b/engine/chainstate/types.go deleted file mode 100644 index 234658ef..00000000 --- a/engine/chainstate/types.go +++ /dev/null @@ -1,19 +0,0 @@ -package chainstate - -// TransactionType tx types -type TransactionType string - -// Metanet type -const Metanet TransactionType = "metanet" - -// PubKeyHash type -const PubKeyHash TransactionType = "pubkeyhash" - -// PlanariaB type -const PlanariaB TransactionType = "planaria-b" - -// PlanariaD type -const PlanariaD TransactionType = "planaria-d" - -// RareCandyFrogCartel type -const RareCandyFrogCartel TransactionType = "rarecandy-frogcartel" diff --git a/engine/client.go b/engine/client.go index 2b73ea91..227a65a0 100644 --- a/engine/client.go +++ b/engine/client.go @@ -7,8 +7,7 @@ import ( "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/go-paymail/server" "github.com/bitcoin-sv/spv-wallet/engine/chain" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/cluster" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/logging" @@ -19,6 +18,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" "github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines" + "github.com/bitcoin-sv/spv-wallet/models/bsv" "github.com/go-resty/resty/v2" "github.com/mrz1836/go-cachestore" "github.com/rs/zerolog" @@ -35,7 +35,6 @@ type ( clientOptions struct { cacheStore *cacheStoreOptions // Configuration options for Cachestore (ristretto, redis, etc.) cluster *clusterOptions // Configuration options for the cluster coordinator - chainstate *chainstateOptions // Configuration options for Chainstate (broadcast, sync, etc.) dataStore *dataStoreOptions // Configuration options for the DataStore (PostgreSQL, etc.) debug bool // If the client is in debug mode encryptionKey string // Encryption key for encrypting sensitive information (IE: paymail xPub) (hex encoded key) @@ -53,22 +52,7 @@ type ( chainService chain.Service // Chain service arcConfig chainmodels.ARCConfig // Configuration for ARC bhsConfig chainmodels.BHSConfig // Configuration for BHS - txCallbackConfig *txCallbackConfig // Configuration for TX callback received from ARC; disabled if nil - } - - txCallbackConfig struct { - URL string // URL for the callback - Token string // Token for the callback - } - - // chainstateOptions holds the chainstate configuration and client - chainstateOptions struct { - chainstate.ClientInterface // Client for Chainstate - options []chainstate.ClientOps // List of options - broadcasting bool // Default value for all transactions - broadcastInstant bool // Default value for all transactions - paymailP2P bool // Default value for all transactions - syncOnChain bool // Default value for all transactions + feeUnit *bsv.FeeUnit // Fee unit for transactions } // cacheStoreOptions holds the cache configuration and client @@ -168,11 +152,6 @@ func NewClient(ctx context.Context, opts ...ClientOps) (ClientInterface, error) return nil, err } - // Load the Chainstate client - if err = client.loadChainstate(ctx); err != nil { - return nil, err - } - // Load the Paymail client and service (if does not exist) if err = client.loadPaymailComponents(); err != nil { return nil, err @@ -210,6 +189,12 @@ func NewClient(ctx context.Context, opts ...ClientOps) (ClientInterface, error) } } + if client.options.feeUnit == nil { + if err = client.askForFeeUnit(ctx); err != nil { + return nil, err + } + } + // Return the client return client, nil } @@ -261,14 +246,6 @@ func (c *Client) Cluster() cluster.ClientInterface { return nil } -// Chainstate will return the Chainstate service IF: exists and is enabled -func (c *Client) Chainstate() chainstate.ClientInterface { - if c.options.chainstate != nil && c.options.chainstate.ClientInterface != nil { - return c.options.chainstate.ClientInterface - } - return nil -} - // Close will safely close any open connections (cache, datastore, etc.) func (c *Client) Close(ctx context.Context) error { @@ -314,27 +291,12 @@ func (c *Client) Debug(on bool) { cs.Debug(on) } - // Set debugging on the Chainstate - if ch := c.Chainstate(); ch != nil { - ch.Debug(on) - } - // Set debugging on the Datastore if ds := c.Datastore(); ds != nil { ds.Debug(on) } } -// DefaultSyncConfig will return the default sync config from the client defaults (for chainstate) -func (c *Client) DefaultSyncConfig() *SyncConfig { - return &SyncConfig{ - Broadcast: c.options.chainstate.broadcasting, - BroadcastInstant: c.options.chainstate.broadcastInstant, - PaymailP2P: c.options.chainstate.paymailP2P, - SyncOnChain: c.options.chainstate.syncOnChain, - } -} - // GetModelNames will return the model names that have been loaded func (c *Client) GetModelNames() []string { return c.options.models.modelNames @@ -413,3 +375,8 @@ func (c *Client) LogBHSReadiness(ctx context.Context) { logger.Info().Msg("Block Headers Service is ready to verify transactions.") } } + +// FeeUnit will return the fee unit used for transactions +func (c *Client) FeeUnit() bsv.FeeUnit { + return *c.options.feeUnit +} diff --git a/engine/client_internal.go b/engine/client_internal.go index 93d396c4..4f5d9fe7 100644 --- a/engine/client_internal.go +++ b/engine/client_internal.go @@ -6,7 +6,6 @@ import ( "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/go-paymail/server" "github.com/bitcoin-sv/spv-wallet/engine/chain" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" "github.com/bitcoin-sv/spv-wallet/engine/cluster" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/notifications" @@ -37,19 +36,6 @@ func (c *Client) loadCluster(ctx context.Context) (err error) { return } -// loadChainstate will load chainstate configuration and start the Chainstate client -func (c *Client) loadChainstate(ctx context.Context) (err error) { - // Load chainstate if a custom interface was NOT provided - if c.options.chainstate.ClientInterface == nil { - c.options.chainstate.options = append(c.options.chainstate.options, chainstate.WithUserAgent(c.UserAgent())) - c.options.chainstate.options = append(c.options.chainstate.options, chainstate.WithHTTPClient(c.options.httpClient.GetClient())) - c.options.chainstate.options = append(c.options.chainstate.options, chainstate.WithMetrics(c.options.metrics)) - c.options.chainstate.ClientInterface, err = chainstate.NewClient(ctx, c.options.chainstate.options...) - } - - return -} - // loadDatastore will load the Datastore and start the Datastore client // // NOTE: this will run database migrations if the options was set @@ -207,6 +193,7 @@ func (c *Client) loadTransactionOutlinesService() error { func (c *Client) loadChainService() { if c.options.chainService == nil { logger := c.Logger().With().Str("subservice", "chain").Logger() + c.options.arcConfig.TxsGetter = newSDKTxGetter(c) c.options.chainService = chain.NewChainService(logger, c.options.httpClient, c.options.arcConfig, c.options.bhsConfig) } } @@ -288,3 +275,13 @@ func (c *Client) loadDefaultPaymailConfig() (err error) { ) return } + +func (c *Client) askForFeeUnit(ctx context.Context) error { + feeUnit, err := c.Chain().GetFeeUnit(ctx) + if err != nil { + return spverrors.ErrAskingForFeeUnit.Wrap(err) + } + c.options.feeUnit = feeUnit + c.Logger().Info().Msgf("Fee unit set by ARC policy: %d satoshis per %d bytes", feeUnit.Satoshis, feeUnit.Bytes) + return nil +} diff --git a/engine/client_options.go b/engine/client_options.go index 017951df..310b41d5 100644 --- a/engine/client_options.go +++ b/engine/client_options.go @@ -6,11 +6,9 @@ import ( "strings" "time" - "github.com/bitcoin-sv/go-broadcast-client/broadcast" "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/go-paymail/server" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/cluster" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/logging" @@ -42,16 +40,6 @@ func defaultClientOptions() *clientOptions { // By default check input utxos (unless disabled by the user) iuc: true, - // Blank chainstate config - chainstate: &chainstateOptions{ - ClientInterface: nil, - options: []chainstate.ClientOps{}, - broadcasting: true, // Enabled by default for new users - broadcastInstant: true, // Enabled by default for new users - paymailP2P: true, // Enabled by default for new users - syncOnChain: true, // Enabled by default for new users - }, - cluster: &clusterOptions{ options: []cluster.ClientOps{}, }, @@ -177,7 +165,6 @@ func WithDebugging() ClientOps { // Enable debugging on other services c.cacheStore.options = append(c.cacheStore.options, cachestore.WithDebugging()) - c.chainstate.options = append(c.chainstate.options, chainstate.WithDebugging()) c.dataStore.options = append(c.dataStore.options, datastore.WithDebugging()) } } @@ -225,9 +212,7 @@ func WithLogger(customLogger *zerolog.Logger) ClientOps { c.logger = customLogger // Enable the logger on all SPV Wallet Engine services - chainstateLogger := customLogger.With().Str("subservice", "chainstate").Logger() taskManagerLogger := customLogger.With().Str("subservice", "taskManager").Logger() - c.chainstate.options = append(c.chainstate.options, chainstate.WithLogger(&chainstateLogger)) c.taskManager.options = append(c.taskManager.options, taskmanager.WithLogger(&taskManagerLogger)) // Enable the logger on all external services @@ -534,38 +519,6 @@ func WithClusterClient(clusterClient cluster.ClientInterface) ClientOps { } } -// ----------------------------------------------------------------- -// CHAIN-STATE -// ----------------------------------------------------------------- - -// WithCustomChainstate will set the chainstate -func WithCustomChainstate(chainState chainstate.ClientInterface) ClientOps { - return func(c *clientOptions) { - if chainState != nil { - c.chainstate.ClientInterface = chainState - } - } -} - -// WithChainstateOptions will set chainstate defaults -func WithChainstateOptions(broadcasting, broadcastInstant, paymailP2P, syncOnChain bool) ClientOps { - return func(c *clientOptions) { - c.chainstate.broadcasting = broadcasting - c.chainstate.broadcastInstant = broadcastInstant - c.chainstate.paymailP2P = paymailP2P - c.chainstate.syncOnChain = syncOnChain - } -} - -// WithExcludedProviders will set a list of excluded providers -func WithExcludedProviders(providers []string) ClientOps { - return func(c *clientOptions) { - if len(providers) > 0 { - c.chainstate.options = append(c.chainstate.options, chainstate.WithExcludedProviders(providers)) - } - } -} - // ----------------------------------------------------------------- // NOTIFICATIONS // ----------------------------------------------------------------- @@ -579,46 +532,21 @@ func WithNotifications() ClientOps { } } -// WithFeeQuotes will find the lowest fee instead of using the fee set by the WithFeeUnit function -func WithFeeQuotes(enabled bool) ClientOps { - return func(c *clientOptions) { - c.chainstate.options = append(c.chainstate.options, chainstate.WithFeeQuotes(enabled)) - } -} - -// WithFeeUnit will set the fee unit to use for broadcasting -func WithFeeUnit(feeUnit *bsv.FeeUnit) ClientOps { - return func(c *clientOptions) { - c.chainstate.options = append(c.chainstate.options, chainstate.WithFeeUnit(feeUnit)) - } -} - -// WithBroadcastClient will set broadcast client -func WithBroadcastClient(broadcastClient broadcast.Client) ClientOps { - return func(c *clientOptions) { - c.chainstate.options = append(c.chainstate.options, chainstate.WithBroadcastClient(broadcastClient)) - } -} +// ----------------------------------------------------------------- +// CHAIN +// ----------------------------------------------------------------- -// WithCallback set callback settings -func WithCallback(callbackURL string, callbackToken string) ClientOps { +// WithCustomFeeUnit will set the custom fee unit for transactions +func WithCustomFeeUnit(feeUnit bsv.FeeUnit) ClientOps { return func(c *clientOptions) { - c.txCallbackConfig = &txCallbackConfig{ - URL: callbackURL, - Token: callbackToken, - } - c.chainstate.options = append(c.chainstate.options, chainstate.WithCallback(callbackURL, callbackToken)) + c.feeUnit = &feeUnit } } -// WithARC set ARC url params -func WithARC(url, token, deploymentID string) ClientOps { +// WithARC sets all the ARC options needed for broadcasting, querying transactions etc. +func WithARC(arcCfg chainmodels.ARCConfig) ClientOps { return func(c *clientOptions) { - c.arcConfig = chainmodels.ARCConfig{ - URL: url, - Token: token, - DeploymentID: deploymentID, - } + c.arcConfig = arcCfg } } diff --git a/engine/client_options_test.go b/engine/client_options_test.go index e9bc91b3..f51400b5 100644 --- a/engine/client_options_test.go +++ b/engine/client_options_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/logging" @@ -152,9 +151,6 @@ func TestWithEncryption(t *testing.T) { // TestWithRedis will test the method WithRedis() func TestWithRedis(t *testing.T) { testLogger := zerolog.Nop() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() t.Run("check type", func(t *testing.T) { opt := WithRedis(nil) @@ -173,7 +169,7 @@ func TestWithRedis(t *testing.T) { URL: cachestore.RedisPrefix + "localhost:6379", }), WithSQLite(tester.SQLiteTestConfig(false, true)), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) @@ -197,7 +193,7 @@ func TestWithRedis(t *testing.T) { URL: "localhost:6379", }), WithSQLite(tester.SQLiteTestConfig(false, true)), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) @@ -213,9 +209,6 @@ func TestWithRedis(t *testing.T) { // TestWithRedisConnection will test the method WithRedisConnection() func TestWithRedisConnection(t *testing.T) { testLogger := zerolog.Nop() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() t.Run("check type", func(t *testing.T) { opt := WithRedisConnection(nil) @@ -228,7 +221,7 @@ func TestWithRedisConnection(t *testing.T) { WithTaskqConfig(taskmanager.DefaultTaskQConfig(tester.RandomTablePrefix())), WithRedisConnection(nil), WithSQLite(tester.SQLiteTestConfig(false, true)), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) @@ -250,7 +243,7 @@ func TestWithRedisConnection(t *testing.T) { WithTaskqConfig(taskmanager.DefaultTaskQConfig(tester.RandomTablePrefix())), WithRedisConnection(client), WithSQLite(tester.SQLiteTestConfig(false, true)), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) @@ -266,9 +259,6 @@ func TestWithRedisConnection(t *testing.T) { // TestWithFreeCache will test the method WithFreeCache() func TestWithFreeCache(t *testing.T) { t.Parallel() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() t.Run("check type", func(t *testing.T) { opt := WithFreeCache() @@ -282,7 +272,7 @@ func TestWithFreeCache(t *testing.T) { WithFreeCache(), WithTaskqConfig(taskmanager.DefaultTaskQConfig(testQueueName)), WithSQLite(&datastore.SQLiteConfig{Shared: true}), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger)) require.NoError(t, err) require.NotNil(t, tc) @@ -298,9 +288,6 @@ func TestWithFreeCache(t *testing.T) { func TestWithFreeCacheConnection(t *testing.T) { t.Parallel() testLogger := zerolog.Nop() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() t.Run("check type", func(t *testing.T) { opt := WithFreeCacheConnection(nil) @@ -313,7 +300,7 @@ func TestWithFreeCacheConnection(t *testing.T) { WithFreeCacheConnection(nil), WithTaskqConfig(taskmanager.DefaultTaskQConfig(testQueueName)), WithSQLite(&datastore.SQLiteConfig{Shared: true}), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) @@ -333,7 +320,7 @@ func TestWithFreeCacheConnection(t *testing.T) { WithFreeCacheConnection(fc), WithTaskqConfig(taskmanager.DefaultTaskQConfig(testQueueName)), WithSQLite(&datastore.SQLiteConfig{Shared: true}), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) @@ -388,9 +375,6 @@ func TestWithPaymailClient(t *testing.T) { func TestWithTaskQ(t *testing.T) { t.Parallel() testLogger := zerolog.Nop() - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() // todo: test cases where config is nil, or cannot load TaskQ @@ -425,7 +409,7 @@ func TestWithTaskQ(t *testing.T) { URL: cachestore.RedisPrefix + "localhost:6379", }), WithSQLite(tester.SQLiteTestConfig(false, true)), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), WithLogger(&testLogger), ) require.NoError(t, err) diff --git a/engine/ef_tx.go b/engine/ef_tx.go deleted file mode 100644 index e97e8774..00000000 --- a/engine/ef_tx.go +++ /dev/null @@ -1,75 +0,0 @@ -package engine - -import ( - "context" - - trx "github.com/bitcoin-sv/go-sdk/transaction" -) - -// ToEfHex generates Extended Format hex of transaction -func ToEfHex(ctx context.Context, tx *Transaction, store TransactionGetter) (efHex string, ok bool) { - sdkTx := tx.parsedTx - - if sdkTx == nil { - var err error - sdkTx, err = trx.NewTransactionFromHex(tx.Hex) - if err != nil { - return "", false - } - } - - needToHydrate := false - for _, input := range sdkTx.Inputs { - if input.SourceTXID == nil { - needToHydrate = true - break - } - } - - if needToHydrate { - if ok := hydrate(ctx, sdkTx, store); !ok { - return "", false - } - } - - ef, err := sdkTx.EFHex() - if err != nil { - return "", false - } - - return ef, true -} - -func hydrate(ctx context.Context, tx *trx.Transaction, store TransactionGetter) (ok bool) { - txToGet := make([]string, 0, len(tx.Inputs)) - - for _, input := range tx.Inputs { - txToGet = append(txToGet, input.SourceTXID.String()) - } - - parentTxs, err := store.GetTransactionsByIDs(ctx, txToGet) - if err != nil { - return false - } - if len(parentTxs) != len(tx.Inputs) { - return false - } - - for _, input := range tx.Inputs { - prevTxID := input.SourceTXID.String() - pTx := find(parentTxs, func(tx *Transaction) bool { return tx.ID == prevTxID }) - - pbtTx, err := trx.NewTransactionFromHex((*pTx).Hex) - if err != nil { - return false - } - - o := pbtTx.Outputs[input.SourceTxOutIndex] - input.SetSourceTxOutput(&trx.TransactionOutput{ - Satoshis: o.Satoshis, - LockingScript: o.LockingScript, - }) - } - - return true -} diff --git a/engine/examples/client/broadcast_miners/broadcast_miners.go b/engine/examples/client/broadcast_miners/broadcast_miners.go deleted file mode 100644 index 7030f563..00000000 --- a/engine/examples/client/broadcast_miners/broadcast_miners.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - broadcastclient "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client" - "github.com/bitcoin-sv/spv-wallet/engine" - "github.com/bitcoin-sv/spv-wallet/engine/logging" -) - -func buildBroadcastClient() broadcast.Client { - logger := logging.GetDefaultLogger() - builder := broadcastclient.Builder().WithArc( - broadcastclient.ArcClientConfig{ - APIUrl: "https://tapi.taal.com/arc", - Token: os.Getenv("SPV_WALLET_TAAL_API_KEY"), - }, - logger, - ) - - return builder.Build() -} - -func main() { - ctx := context.Background() - - client, err := engine.NewClient( - ctx, - engine.WithBroadcastClient(buildBroadcastClient()), - ) - if err != nil { - log.Fatalln("error: " + err.Error()) - } - - defer client.Close(ctx) - - log.Println("client loaded!", client.UserAgent()) -} diff --git a/engine/examples/client/chainstate/chainstate.go b/engine/examples/client/chainstate/chainstate.go deleted file mode 100644 index 2b1ee0d2..00000000 --- a/engine/examples/client/chainstate/chainstate.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "context" - "log" - - "github.com/bitcoin-sv/spv-wallet/engine" -) - -func main() { - client, err := engine.NewClient( - context.Background(), // Set context - engine.WithDebugging(), // Enable debugging (verbose logs) - engine.WithChainstateOptions(true, true, true, true), // Broadcasting enabled by default - ) - if err != nil { - log.Fatalln("error: " + err.Error()) - } - - defer func() { - _ = client.Close(context.Background()) - }() - - log.Println("client loaded!", client.UserAgent(), "debugging: ", client.IsDebug()) -} diff --git a/engine/examples/client/custom_rates/custom_rates.go b/engine/examples/client/custom_rates/custom_rates.go deleted file mode 100644 index ee2d9f36..00000000 --- a/engine/examples/client/custom_rates/custom_rates.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "time" - - "github.com/bitcoin-sv/go-broadcast-client/broadcast" - broadcastclient "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client" - "github.com/bitcoin-sv/spv-wallet/engine" - "github.com/bitcoin-sv/spv-wallet/engine/logging" -) - -func buildBroadcastClient() broadcast.Client { - logger := logging.GetDefaultLogger() - builder := broadcastclient.Builder().WithArc( - broadcastclient.ArcClientConfig{ - APIUrl: "https://tapi.taal.com/arc", - Token: os.Getenv("SPV_WALLET_TAAL_API_KEY"), - }, - logger, - ) - - return builder.Build() -} - -func main() { - ctx := context.Background() - const testXPub = "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J" - - client, err := engine.NewClient( - ctx, - engine.WithAutoMigrate(engine.BaseModels...), - engine.WithBroadcastClient(buildBroadcastClient()), - ) - if err != nil { - log.Fatalln("error: " + err.Error()) - } - - defer client.Close(ctx) - - xpub, err := client.NewXpub(ctx, testXPub) - if err != nil { - log.Fatalln("error: " + err.Error()) - } - - draft, err := client.NewTransaction(ctx, xpub.RawXpub(), &engine.TransactionConfig{ - ExpiresIn: 10 * time.Second, - SendAllTo: &engine.TransactionOutput{To: os.Getenv("SPV_WALLET_MY_PAYMAIL")}, - }) - if err != nil { - log.Fatalln("error: " + err.Error()) - } - - // Custom fee - log.Println("fee unit", draft.Configuration.FeeUnit) -} diff --git a/engine/interface.go b/engine/interface.go index 121f5afe..7f5c9dde 100644 --- a/engine/interface.go +++ b/engine/interface.go @@ -6,8 +6,7 @@ import ( "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/spv-wallet/engine/chain" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/cluster" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/metrics" @@ -15,6 +14,7 @@ import ( paymailclient "github.com/bitcoin-sv/spv-wallet/engine/paymail" "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" "github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines" + "github.com/bitcoin-sv/spv-wallet/models/bsv" "github.com/mrz1836/go-cachestore" "github.com/rs/zerolog" ) @@ -51,7 +51,6 @@ type AdminService interface { type ClientService interface { Cachestore() cachestore.ClientInterface Cluster() cluster.ClientInterface - Chainstate() chainstate.ClientInterface Datastore() datastore.ClientInterface Logger() *zerolog.Logger Notifications() *notifications.Notifications @@ -203,7 +202,6 @@ type ClientInterface interface { AuthenticateAccessKey(ctx context.Context, pubAccessKey string) (*AccessKey, error) Close(ctx context.Context) error Debug(on bool) - DefaultSyncConfig() *SyncConfig IsDebug() bool IsEncryptionKeySet() bool IsIUCEnabled() bool @@ -216,4 +214,5 @@ type ClientInterface interface { GetWebhooks(ctx context.Context) ([]notifications.ModelWebhook, error) Chain() chain.Service LogBHSReadiness(ctx context.Context) + FeeUnit() bsv.FeeUnit } diff --git a/engine/model_draft_transactions.go b/engine/model_draft_transactions.go index 74dc8e5d..bec36a17 100644 --- a/engine/model_draft_transactions.go +++ b/engine/model_draft_transactions.go @@ -64,7 +64,8 @@ func newDraftTransaction(rawXpubKey string, config *TransactionConfig, opts ...M } if config.FeeUnit == nil { - draft.Configuration.FeeUnit = draft.Client().Chainstate().FeeUnit() + unit := draft.Client().FeeUnit() + draft.Configuration.FeeUnit = &unit } err := draft.createTransactionHex(context.Background()) @@ -345,7 +346,8 @@ func (m *DraftTransaction) prepareSeparateUtxos(ctx context.Context, opts []Mode // Reserve and Get utxos for the transaction var reservedUtxos []*Utxo - feePerByte := float64(m.Configuration.FeeUnit.Satoshis / m.Configuration.FeeUnit.Bytes) + //TODO: Fixme in new transaction-flow + feePerByte := float64(m.Configuration.FeeUnit.Satoshis) / float64(m.Configuration.FeeUnit.Bytes) reserveSatoshis := satoshisNeeded + m.estimateFee(m.Configuration.FeeUnit, 0) if reserveSatoshis <= dustLimit && !m.containsOpReturn() { diff --git a/engine/model_draft_transactions_test.go b/engine/model_draft_transactions_test.go index 3abdd5a1..f91b870d 100644 --- a/engine/model_draft_transactions_test.go +++ b/engine/model_draft_transactions_test.go @@ -14,7 +14,6 @@ import ( "github.com/bitcoin-sv/go-sdk/script" trx "github.com/bitcoin-sv/go-sdk/transaction" sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" xtester "github.com/bitcoin-sv/spv-wallet/engine/tester/paymailmock" "github.com/bitcoin-sv/spv-wallet/engine/utils" @@ -27,6 +26,11 @@ const ( testDraftLockingScript = "76a9140692ed78f6988968ce612f620894997cc7edf1ad88ac" ) +var mockDefaultFee = bsv.FeeUnit{ + Satoshis: 1, + Bytes: 1000, +} + // TestDraftTransaction_newDraftTransaction will test the method newDraftTransaction() func TestDraftTransaction_newDraftTransaction(t *testing.T) { t.Run("nil config, panic", func(t *testing.T) { @@ -46,7 +50,7 @@ func TestDraftTransaction_newDraftTransaction(t *testing.T) { expires := time.Now().UTC().Add(defaultDraftTxExpiresIn) draftTx, err := newDraftTransaction( testXPub, &TransactionConfig{ - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())..., ) @@ -57,7 +61,7 @@ func TestDraftTransaction_newDraftTransaction(t *testing.T) { assert.WithinDurationf(t, expires, draftTx.ExpiresAt, 1*time.Second, "within 1 second") assert.Equal(t, DraftStatusDraft, draftTx.Status) assert.Equal(t, testXPubID, draftTx.XpubID) - assert.Equal(t, *chainstate.MockDefaultFee, *draftTx.Configuration.FeeUnit) + assert.Equal(t, mockDefaultFee, *draftTx.Configuration.FeeUnit) }) } @@ -68,7 +72,7 @@ func TestDraftTransaction_GetModelName(t *testing.T) { defer deferMe() prepareAdditionalModels(ctx, t, client, false) draftTx, err := newDraftTransaction(testXPub, &TransactionConfig{ - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())...) require.NoError(t, err) @@ -88,7 +92,7 @@ func TestDraftTransaction_getOutputSatoshis(t *testing.T) { ChangeDestinations: []*Destination{{ LockingScript: testLockingScript, }}, - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())..., ) @@ -109,7 +113,7 @@ func TestDraftTransaction_getOutputSatoshis(t *testing.T) { ChangeDestinations: []*Destination{{ LockingScript: testLockingScript, }}, - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())..., ) @@ -131,7 +135,7 @@ func TestDraftTransaction_getOutputSatoshis(t *testing.T) { }, { LockingScript: testTxInScriptPubKey, }}, - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())..., ) @@ -157,7 +161,7 @@ func TestDraftTransaction_getOutputSatoshis(t *testing.T) { }, { LockingScript: testTxScriptPubKey1, }}, - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())..., ) @@ -296,7 +300,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { assert.Equal(t, uint64((startingBalance - txAmount - expectedFee)), draftTransaction.Configuration.ChangeSatoshis) assert.Equal(t, uint64(expectedFee), draftTransaction.Configuration.Fee) - assert.Equal(t, *chainstate.MockDefaultFee, *draftTransaction.Configuration.FeeUnit) + assert.Equal(t, mockDefaultFee, *draftTransaction.Configuration.FeeUnit) assert.Equal(t, 1, len(draftTransaction.Configuration.Inputs)) assert.Equal(t, testLockingScript, draftTransaction.Configuration.Inputs[0].ScriptPubKey) diff --git a/engine/model_transaction_config.go b/engine/model_transaction_config.go index 7d7831d2..61cd0e88 100644 --- a/engine/model_transaction_config.go +++ b/engine/model_transaction_config.go @@ -29,7 +29,7 @@ type TransactionConfig struct { ChangeSatoshis uint64 `json:"change_satoshis" toml:"change_satoshis" yaml:"change_satoshis"` // The satoshis used for change ExpiresIn time.Duration `json:"expires_in" toml:"expires_in" yaml:"expires_in"` // The expiration time for the draft and utxos Fee uint64 `json:"fee" toml:"fee" yaml:"fee"` // The fee used for the transaction (auto generated) - FeeUnit *bsv.FeeUnit `json:"fee_unit" toml:"fee_unit" yaml:"fee_unit"` // Fee unit to use (overrides chainstate if set) + FeeUnit *bsv.FeeUnit `json:"fee_unit" toml:"fee_unit" yaml:"fee_unit"` // Fee unit to use for this transaction (prevents usage of fee units configured at the server side) FromUtxos []*UtxoPointer `json:"from_utxos" toml:"from_utxos" yaml:"from_utxos"` // Use these specific utxos for the transaction IncludeUtxos []*UtxoPointer `json:"include_utxos" toml:"include_utxos" yaml:"include_utxos"` // Include these utxos for the transaction, among others necessary if more is needed for fees Inputs []*TransactionInput `json:"inputs" toml:"inputs" yaml:"inputs"` // All transaction inputs diff --git a/engine/model_transaction_config_test.go b/engine/model_transaction_config_test.go index 51981cc2..659adb88 100644 --- a/engine/model_transaction_config_test.go +++ b/engine/model_transaction_config_test.go @@ -5,7 +5,6 @@ import ( "fmt" "testing" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" "github.com/bitcoin-sv/spv-wallet/engine/paymail/testabilities" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" @@ -49,7 +48,7 @@ var ( ChangeSatoshis: 124, ExpiresIn: defaultDraftTxExpiresIn, Fee: 12, - FeeUnit: chainstate.MockDefaultFee, + FeeUnit: &mockDefaultFee, Inputs: nil, Outputs: nil, } diff --git a/engine/model_transactions.go b/engine/model_transactions.go index 87cc43dd..13f49843 100644 --- a/engine/model_transactions.go +++ b/engine/model_transactions.go @@ -4,7 +4,7 @@ import ( "context" trx "github.com/bitcoin-sv/go-sdk/transaction" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/utils" ) @@ -244,15 +244,14 @@ func (m *Transaction) SetBUMP(mp *trx.MerklePath) { // UpdateFromBroadcastStatus converts ARC transaction status to engineTxStatus and updates if needed func (m *Transaction) UpdateFromBroadcastStatus(bStatus chainmodels.TXStatus) { - switch bStatus { - case chainmodels.Mined, chainmodels.Confirmed: + switch { + case bStatus.IsMined(): m.TxStatus = TxStatusMined - case chainmodels.SeenInOrphanMempool, chainmodels.Rejected: + case bStatus.IsProblematic(): m.TxStatus = TxStatusProblematic - case chainmodels.Unknown, chainmodels.Queued, chainmodels.Received, chainmodels.Stored, chainmodels.AnnouncedToNetwork, chainmodels.RequestedByNetwork, chainmodels.SentToNetwork, chainmodels.AcceptedByNetwork, chainmodels.SeenOnNetwork: - // don't change current TXStatus on these ARC Statuses default: - // unexpected statuses + // don't change current TXStatus on these ARC Statuses + m.client.Logger().Debug().Str("txID", m.ID).Str("status", string(bStatus)).Msg("ARC returned neutral status; Transaction status will not be updated") } } diff --git a/engine/model_transactions_test.go b/engine/model_transactions_test.go index 4bc2575e..bdded4c2 100644 --- a/engine/model_transactions_test.go +++ b/engine/model_transactions_test.go @@ -24,8 +24,6 @@ var ( testTxInScriptPubKey = "76a914e069bd2e2fe3ea702c40d5e65b491b734c01686788ac" testTxScriptPubKey1 = "76a91413473d21dc9e1fb392f05a028b447b165a052d4d88ac" testTxScriptPubKey2 = "76a91455decebedd9a6c2c2d32cf0ee77e2640c3955d3488ac" - testTxScriptSigID = "104cc87da1c6a6d3ce3e0dcffa92533c32d66818871a443b2d8b2933278dbb65" - testTxScriptSigOut = "76a914e069bd2e2fe3ea702c40d5e65b491b734c01686788ac" testSTAStxHex = "0100000002e0d43ab21510a337ca66a58744c11c1bc9519ef733d54cb4d1824c7e8ed3fde9000000006a47304402203342239754aac17471c1fc7bda7f60685d729a2fdf0db0fbef2185fa16f41956022043926c24a3a516727e910408f74f3cb4fc1b94a2e90d5927d7ae030ef5ff18c7412102f892d89cd0e522a0ff3bac1195a4eeba76c366be3b10f3ffea915fd4d4b2bdf1ffffffffd99b8883f6bf6faf2205488470173fc824cdf9b6445dbcc8490488554ee31c44020000006b483045022100bb2062177404040bceab92125dba929871314998ced25ed13ddf9f71bfc7c522022036429c265e0cb2c4d8b49728726ec7d62fbe81084000a1cde14cee193943f9dc4121032639cbd16258e6b0788a0e17eb899ea0f0c65e44a09d35a19f081769737d7525ffffffff04dc05000000000000fd800676a9140311c6e2114620d68ddfc71519c1a00e0bf9d10b88ac6976aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01007e818b21414136d08c5ed2bf3ba048afe6dcaebafeffffffffffffffffffffffffffffff007d976e7c5296a06394677768827601249301307c7e23022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798027e7c7e7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e21038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b9218ad547f7701207f01207f7701247f517f7801007e8102fd00a063546752687f7801007e817f727e7b01177f777b557a766471567a577a786354807e7e676d68aa880067765158a569765187645294567a5379587a7e7e78637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6867567a6876aa587a7d54807e577a597a5a7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa587a7d877663516752687c72879b69537a647500687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75537f7c0376a9148801147f775379645579887567726881766968789263556753687a76026c057f7701147f8263517f7c766301007e817f7c6775006877686b537992635379528763547a6b547a6b677c6b567a6b537a7c717c71716868547a587f7c81547a557964936755795187637c686b687c547f7701207f75748c7a7669765880748c7a76567a876457790376a9147e7c7e557967041976a9147c7e0288ac687e7e5579636c766976748c7a9d58807e6c0376a9147e748c7a7e6c7e7e676c766b8263828c007c80517e846864745aa0637c748c7a76697d937b7b58807e56790376a9147e748c7a7e55797e7e6868686c567a5187637500678263828c007c80517e846868647459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e687459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e68687c537a9d547963557958807e041976a91455797e0288ac7e7e68aa87726d77776a1492a08b11fc38494853997469a4cb62bfe8aa3d990101066567344970754cde7c2056534e207c2065396664643338653765346338326431623434636435333366373965353163393162316363313434383761353636636133376133313031356232336164346530207c2068747470733a2f2f666972656261736573746f726167652e676f6f676c65617069732e636f6d2f76302f622f6d757369636172746465762f6f2f6e667441737365747325324664363861363331322d633138632d346634392d623332612d3535363933636239306265665f323530783235303f616c743d6d65646961207c2033207c2031207c2073666161647366736466207cdc05000000000000fd800676a9140311c6e2114620d68ddfc71519c1a00e0bf9d10b88ac6976aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01007e818b21414136d08c5ed2bf3ba048afe6dcaebafeffffffffffffffffffffffffffffff007d976e7c5296a06394677768827601249301307c7e23022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798027e7c7e7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e21038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b9218ad547f7701207f01207f7701247f517f7801007e8102fd00a063546752687f7801007e817f727e7b01177f777b557a766471567a577a786354807e7e676d68aa880067765158a569765187645294567a5379587a7e7e78637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6867567a6876aa587a7d54807e577a597a5a7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa587a7d877663516752687c72879b69537a647500687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75537f7c0376a9148801147f775379645579887567726881766968789263556753687a76026c057f7701147f8263517f7c766301007e817f7c6775006877686b537992635379528763547a6b547a6b677c6b567a6b537a7c717c71716868547a587f7c81547a557964936755795187637c686b687c547f7701207f75748c7a7669765880748c7a76567a876457790376a9147e7c7e557967041976a9147c7e0288ac687e7e5579636c766976748c7a9d58807e6c0376a9147e748c7a7e6c7e7e676c766b8263828c007c80517e846864745aa0637c748c7a76697d937b7b58807e56790376a9147e748c7a7e55797e7e6868686c567a5187637500678263828c007c80517e846868647459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e687459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e68687c537a9d547963557958807e041976a91455797e0288ac7e7e68aa87726d77776a1492a08b11fc38494853997469a4cb62bfe8aa3d990101066567344970754cde7c2056534e207c2065396664643338653765346338326431623434636435333366373965353163393162316363313434383761353636636133376133313031356232336164346530207c2068747470733a2f2f666972656261736573746f726167652e676f6f676c65617069732e636f6d2f76302f622f6d757369636172746465762f6f2f6e667441737365747325324664363861363331322d633138632d346634392d623332612d3535363933636239306265665f323530783235303f616c743d6d65646961207c2033207c2032207c2073666161647366736466207cdc05000000000000fd800676a9140311c6e2114620d68ddfc71519c1a00e0bf9d10b88ac6976aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01007e818b21414136d08c5ed2bf3ba048afe6dcaebafeffffffffffffffffffffffffffffff007d976e7c5296a06394677768827601249301307c7e23022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798027e7c7e7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e21038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b9218ad547f7701207f01207f7701247f517f7801007e8102fd00a063546752687f7801007e817f727e7b01177f777b557a766471567a577a786354807e7e676d68aa880067765158a569765187645294567a5379587a7e7e78637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6867567a6876aa587a7d54807e577a597a5a7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa587a7d877663516752687c72879b69537a647500687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75537f7c0376a9148801147f775379645579887567726881766968789263556753687a76026c057f7701147f8263517f7c766301007e817f7c6775006877686b537992635379528763547a6b547a6b677c6b567a6b537a7c717c71716868547a587f7c81547a557964936755795187637c686b687c547f7701207f75748c7a7669765880748c7a76567a876457790376a9147e7c7e557967041976a9147c7e0288ac687e7e5579636c766976748c7a9d58807e6c0376a9147e748c7a7e6c7e7e676c766b8263828c007c80517e846864745aa0637c748c7a76697d937b7b58807e56790376a9147e748c7a7e55797e7e6868686c567a5187637500678263828c007c80517e846868647459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e687459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e68687c537a9d547963557958807e041976a91455797e0288ac7e7e68aa87726d77776a1492a08b11fc38494853997469a4cb62bfe8aa3d990101066567344970754cde7c2056534e207c2065396664643338653765346338326431623434636435333366373965353163393162316363313434383761353636636133376133313031356232336164346530207c2068747470733a2f2f666972656261736573746f726167652e676f6f676c65617069732e636f6d2f76302f622f6d757369636172746465762f6f2f6e667441737365747325324664363861363331322d633138632d346634392d623332612d3535363933636239306265665f323530783235303f616c743d6d65646961207c2033207c2033207c2073666161647366736466207cad180000000000001976a9147190407e487fd53c7504031956c9b995bb8dfd3988ac00000000" testSTAStxID = "76a4f090140242a34c41fc2ac1936b140dc0efad65b8a61fed32227c13ff11f4" testSTASLockingScript = "76a9140311c6e2114620d68ddfc71519c1a00e0bf9d10b88ac6976aa607f5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7c5f7f7c5e7f7c5d7f7c5c7f7c5b7f7c5a7f7c597f7c587f7c577f7c567f7c557f7c547f7c537f7c527f7c517f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01007e818b21414136d08c5ed2bf3ba048afe6dcaebafeffffffffffffffffffffffffffffff007d976e7c5296a06394677768827601249301307c7e23022079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798027e7c7e7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c8276638c687f7c7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e01417e21038ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b9218ad547f7701207f01207f7701247f517f7801007e8102fd00a063546752687f7801007e817f727e7b01177f777b557a766471567a577a786354807e7e676d68aa880067765158a569765187645294567a5379587a7e7e78637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6878637c8c7c53797e577a7e6867567a6876aa587a7d54807e577a597a5a7a786354807e6f7e7eaa727c7e676d6e7eaa7c687b7eaa587a7d877663516752687c72879b69537a647500687c7b547f77517f7853a0916901247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77788c6301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f777852946301247f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e816854937f77686877517f7c52797d8b9f7c53a09b91697c76638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6876638c7c587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f777c6863587f77517f7c01007e817602fc00a06302fd00a063546752687f7c01007e81687f7768587f517f7801007e817602fc00a06302fd00a063546752687f7801007e81727e7b7b687f75537f7c0376a9148801147f775379645579887567726881766968789263556753687a76026c057f7701147f8263517f7c766301007e817f7c6775006877686b537992635379528763547a6b547a6b677c6b567a6b537a7c717c71716868547a587f7c81547a557964936755795187637c686b687c547f7701207f75748c7a7669765880748c7a76567a876457790376a9147e7c7e557967041976a9147c7e0288ac687e7e5579636c766976748c7a9d58807e6c0376a9147e748c7a7e6c7e7e676c766b8263828c007c80517e846864745aa0637c748c7a76697d937b7b58807e56790376a9147e748c7a7e55797e7e6868686c567a5187637500678263828c007c80517e846868647459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e687459a0637c748c7a76697d937b7b58807e55790376a9147e748c7a7e55797e7e68687c537a9d547963557958807e041976a91455797e0288ac7e7e68aa87726d77776a1492a08b11fc38494853997469a4cb62bfe8aa3d990101066567344970754cde7c2056534e207c2065396664643338653765346338326431623434636435333366373965353163393162316363313434383761353636636133376133313031356232336164346530207c2068747470733a2f2f666972656261736573746f726167652e676f6f676c65617069732e636f6d2f76302f622f6d757369636172746465762f6f2f6e667441737365747325324664363861363331322d633138632d346634392d623332612d3535363933636239306265665f323530783235303f616c743d6d65646961207c2033207c2031207c2073666161647366736466207c" diff --git a/engine/model_utxos.go b/engine/model_utxos.go index fec9f0ab..723a5b4e 100644 --- a/engine/model_utxos.go +++ b/engine/model_utxos.go @@ -3,6 +3,7 @@ package engine import ( "context" "fmt" + "math" "time" "github.com/bitcoin-sv/spv-wallet/engine/datastore" @@ -213,7 +214,7 @@ reserveUtxoLoop: *utxos = append(*utxos, utxo) // add fee for this new input - feeNeeded += uint64(float64(size) * feePerByte) + feeNeeded += uint64(math.Ceil(float64(size) * feePerByte)) if reservedSatoshis >= (satoshis + feeNeeded) { break reserveUtxoLoop } diff --git a/engine/paymail/paymail_test.go b/engine/paymail/paymail_test.go index 1e5eb6a0..2a7e5ee9 100644 --- a/engine/paymail/paymail_test.go +++ b/engine/paymail/paymail_test.go @@ -5,7 +5,6 @@ import ( "testing" "time" - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/engine/datastore" @@ -243,10 +242,6 @@ func Test_GetCapabilities(t *testing.T) { cacheKeyCapabilities = "paymail-capabilities-" ) - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - t.Run("valid response - no cache found", func(t *testing.T) { given := testabilities.Given(t) @@ -265,9 +260,8 @@ func Test_GetCapabilities(t *testing.T) { engine.WithRedisConnection(redisClient), engine.WithTaskqConfig(taskmanager.DefaultTaskQConfig(testQueueName)), engine.WithSQLite(&datastore.SQLiteConfig{Shared: true}), - engine.WithChainstateOptions(false, false, false, false), engine.WithDebugging(), - engine.WithBroadcastClient(bc), + engine.WithCustomFeeUnit(bsv.FeeUnit{Satoshis: 1, Bytes: 1000}), engine.WithLogger(&logger), ) require.NoError(t, err) @@ -311,9 +305,8 @@ func Test_GetCapabilities(t *testing.T) { engine.WithRedisConnection(redisClient), engine.WithTaskqConfig(taskmanager.DefaultTaskQConfig(testQueueName)), engine.WithSQLite(&datastore.SQLiteConfig{Shared: true}), - engine.WithChainstateOptions(false, false, false, false), engine.WithDebugging(), - engine.WithBroadcastClient(bc), + engine.WithCustomFeeUnit(bsv.FeeUnit{Satoshis: 1, Bytes: 1000}), engine.WithLogger(&logger), ) require.NoError(t, err) diff --git a/engine/record_tx_strategy_outgoing_tx.go b/engine/record_tx_strategy_outgoing_tx.go index 2fa5bc13..ee9084cf 100644 --- a/engine/record_tx_strategy_outgoing_tx.go +++ b/engine/record_tx_strategy_outgoing_tx.go @@ -125,7 +125,12 @@ func _hydrateOutgoingWithDraft(ctx context.Context, tx *Transaction) error { } if draft.Configuration.Sync == nil { - draft.Configuration.Sync = tx.Client().DefaultSyncConfig() + draft.Configuration.Sync = &SyncConfig{ + Broadcast: true, + BroadcastInstant: true, + PaymailP2P: true, + SyncOnChain: true, + } } tx.draftTransaction = draft diff --git a/engine/sdk_tx_getter.go b/engine/sdk_tx_getter.go new file mode 100644 index 00000000..288183d5 --- /dev/null +++ b/engine/sdk_tx_getter.go @@ -0,0 +1,59 @@ +package engine + +import ( + "context" + "slices" + "time" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "iter" +) + +// loadFromDBTimeout - within this time transactions should be loaded from the database +const loadFromDBTimeout = 20 * time.Second + +type sdkTxGetter struct { + client *Client +} + +func newSDKTxGetter(client *Client) *sdkTxGetter { + return &sdkTxGetter{client: client} +} + +func (g *sdkTxGetter) GetTransactions(ctx context.Context, ids iter.Seq[string]) ([]*sdk.Transaction, error) { + db := g.client.Datastore().DB() + + queryIDsCtx, cancel := context.WithTimeout(ctx, loadFromDBTimeout) + defer cancel() + + var hexes []struct { + Hex string + } + + idsSlice := slices.Collect(ids) + if len(idsSlice) == 0 { + return nil, nil + } + + err := db. + WithContext(queryIDsCtx). + Model(&Transaction{}). + Where("id IN (?)", idsSlice). + Find(&hexes). + Error + + if err != nil { + return nil, spverrors.Wrapf(err, "Cannot get transactions by IDs from database") + } + + transactions := make([]*sdk.Transaction, 0, len(hexes)) + for _, record := range hexes { + tx, err := sdk.NewTransactionFromHex(record.Hex) + if err != nil { + return nil, spverrors.Wrapf(err, "Cannot parse transaction hex") + } + transactions = append(transactions, tx) + } + return transactions, nil +} diff --git a/engine/spv_wallet_engine_suite_test.go b/engine/spv_wallet_engine_suite_test.go index 28edaaf5..01b2e730 100644 --- a/engine/spv_wallet_engine_suite_test.go +++ b/engine/spv_wallet_engine_suite_test.go @@ -7,10 +7,10 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" "github.com/bitcoin-sv/spv-wallet/engine/tester" + "github.com/bitcoin-sv/spv-wallet/models/bsv" embeddedPostgres "github.com/fergusstrange/embedded-postgres" "github.com/rs/zerolog" "github.com/stretchr/testify/require" @@ -31,6 +31,8 @@ const ( testQueueName = "test_queue" ) +var mockFeeUnit = bsv.FeeUnit{Satoshis: 1, Bytes: 1000} + // dbTestCase is a database test case type dbTestCase struct { name string @@ -89,9 +91,6 @@ func (ts *EmbeddedDBTestSuite) createTestClient(ctx context.Context, database da tablePrefix string, mockDB, mockRedis bool, opts ...ClientOps, ) (*TestingClient, error) { var err error - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() // Start the suite tc := &TestingClient{ @@ -113,7 +112,7 @@ func (ts *EmbeddedDBTestSuite) createTestClient(ctx context.Context, database da // Switch on database types if database == datastore.SQLite { - opts = append(opts, WithBroadcastClient(bc), WithSQLite(&datastore.SQLiteConfig{ + opts = append(opts, WithCustomFeeUnit(mockFeeUnit), WithSQLite(&datastore.SQLiteConfig{ CommonConfig: datastore.CommonConfig{ MaxConnectionIdleTime: 0, MaxConnectionTime: 0, @@ -206,10 +205,6 @@ func (ts *EmbeddedDBTestSuite) createTestClient(ctx context.Context, database da // //nolint:nolintlint,unparam,gci // opts is the way, but not yet being used func (ts *EmbeddedDBTestSuite) genericDBClient(t *testing.T, database datastore.Engine, taskManagerEnabled bool, opts ...ClientOps) *TestingClient { - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() - prefix := tester.RandomTablePrefix() if opts == nil { @@ -217,10 +212,9 @@ func (ts *EmbeddedDBTestSuite) genericDBClient(t *testing.T, database datastore. } opts = append(opts, WithDebugging(), - WithChainstateOptions(false, false, false, false), WithAutoMigrate(BaseModels...), WithAutoMigrate(&PaymailAddress{}), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), ) if taskManagerEnabled { opts = append(opts, WithTaskqConfig(taskmanager.DefaultTaskQConfig(prefix+"_queue"))) @@ -243,15 +237,13 @@ func (ts *EmbeddedDBTestSuite) genericDBClient(t *testing.T, database datastore. // // NOTE: you need to close the client: ts.Close() func (ts *EmbeddedDBTestSuite) genericMockedDBClient(t *testing.T, database datastore.Engine) *TestingClient { - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockSuccess). - Build() prefix := tester.RandomTablePrefix() tc, err := ts.createTestClient( context.Background(), database, prefix, true, true, WithDebugging(), - withTaskManagerMockup(), WithBroadcastClient(bc), + withTaskManagerMockup(), + WithCustomFeeUnit(mockFeeUnit), ) require.NoError(t, err) require.NotNil(t, tc) diff --git a/engine/spv_wallet_engine_test.go b/engine/spv_wallet_engine_test.go index 57b19f5f..8f19080c 100644 --- a/engine/spv_wallet_engine_test.go +++ b/engine/spv_wallet_engine_test.go @@ -6,15 +6,8 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" - broadcast_client_mock "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client-mock" compat "github.com/bitcoin-sv/go-sdk/compat/bip32" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" - "github.com/bitcoin-sv/go-sdk/script" - trx "github.com/bitcoin-sv/go-sdk/transaction" - sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash" - "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" "github.com/bitcoin-sv/spv-wallet/engine/datastore" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" "github.com/bitcoin-sv/spv-wallet/engine/tester" "github.com/mrz1836/go-cache" @@ -62,17 +55,13 @@ func DefaultClientOpts(debug, shared bool) []ClientOps { tqc := taskmanager.DefaultTaskQConfig(tester.RandomTablePrefix()) tqc.MaxNumWorker = 2 tqc.MaxNumFetcher = 2 - bc := broadcast_client_mock.Builder(). - WithMockArc(broadcast_client_mock.MockNilQueryTxResp). - Build() opts := make([]ClientOps, 0) opts = append( opts, WithTaskqConfig(tqc), WithSQLite(tester.SQLiteTestConfig(debug, shared)), - WithChainstateOptions(false, false, false, false), - WithBroadcastClient(bc), + WithCustomFeeUnit(mockFeeUnit), ) if debug { opts = append(opts, WithDebugging()) @@ -139,39 +128,6 @@ func CloseClient(ctx context.Context, t *testing.T, client ClientInterface) { require.NoError(t, client.Close(ctx)) } -// CreateFakeFundingTransaction will create a valid (fake) transaction for funding -func CreateFakeFundingTransaction(t *testing.T, masterKey *compat.ExtendedKey, - destinations []*Destination, satoshis uint64, -) string { - // Create new tx - rawTx := trx.NewTransaction() - txErr := rawTx.AddInputFrom(testTxScriptSigID, 0, testTxScriptSigOut, satoshis+354, nil) - require.NoError(t, txErr) - - // Loop all destinations - for _, destination := range destinations { - s, err := script.NewFromHex(destination.LockingScript) - require.NoError(t, err) - require.NotNil(t, s) - - rawTx.AddOutput(&trx.TransactionOutput{ - Satoshis: satoshis, - LockingScript: s, - }) - } - - // Get private key - privateKey, err := compat.GetPrivateKeyFromHDKey(masterKey) - require.NoError(t, err) - require.NotNil(t, privateKey) - - err = rawTx.Sign() - require.NoError(t, err) - - // Return the tx hex - return rawTx.String() -} - // CreateNewXPub will create a new xPub and return all the information to use the xPub func CreateNewXPub(ctx context.Context, t *testing.T, engineClient ClientInterface, opts ...ModelOps, @@ -195,15 +151,3 @@ func CreateNewXPub(ctx context.Context, t *testing.T, engineClient ClientInterfa return masterKey, xPub, rawXPub } - -// GetUnlockingScript will get a locking script for valid fake transactions -func GetUnlockingScript(tx *trx.Transaction, inputIndex uint32, privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) { - sigHashFlags := sighash.AllForkID - - sc, err := p2pkh.Unlock(privateKey, &sigHashFlags) - if err != nil { - return nil, spverrors.Wrapf(err, "failed to create unlocking script") - } - - return sc, nil -} diff --git a/engine/spverrors/definitions.go b/engine/spverrors/definitions.go index 90fec40e..99fbac91 100644 --- a/engine/spverrors/definitions.go +++ b/engine/spverrors/definitions.go @@ -22,6 +22,9 @@ var ErrNotAnAdminKey = models.SPVError{Message: "xpub provided is not an admin k // ErrInternal is a generic error that something weird went wrong var ErrInternal = models.SPVError{Message: "internal server error", StatusCode: 500, Code: "error-internal-server-error"} +// ErrCtxInterrupted is when context is interrupted (canceled or deadline exceeded) +var ErrCtxInterrupted = models.SPVError{Message: "context interrupted", StatusCode: 500, Code: "error-ctx-interrupted"} + // ErrInvalidOrMissingToken is when callback token from headers is invalid or missing var ErrInvalidOrMissingToken = models.SPVError{Message: "invalid or missing bearer token", StatusCode: 401, Code: "error-unauthorized-token-invalid-or-missing"} @@ -179,6 +182,9 @@ var ErrGetCapabilities = models.SPVError{Message: "failed to get paymail capabil // ////////////////////////////////// TRANSACTION ERRORS +// ErrParseTransactionFromHex is when error occurred during parsing transaction from hex +var ErrParseTransactionFromHex = models.SPVError{Message: "error parsing transaction from hex", StatusCode: 500, Code: "error-transaction-parse-from-hex"} + // ErrCouldNotFindTransaction is an error when a transaction could not be found var ErrCouldNotFindTransaction = models.SPVError{Message: "transaction not found", StatusCode: 404, Code: "error-transaction-not-found"} @@ -376,26 +382,8 @@ var ErrRouteMethodNotAllowed = models.SPVError{Message: "method not allowed", St // ////////////////////////////////// BROADCAST ERRORS -// ErrBroadcastUnreachable is when broadcast server cannot be requested -var ErrBroadcastUnreachable = models.SPVError{Message: "broadcast server cannot be requested", StatusCode: 404, Code: "error-broadcast-unreachable"} - -// ErrBroadcastWrongBUMPResponse is when broadcast server returned wrong BUMP response -var ErrBroadcastWrongBUMPResponse = models.SPVError{Message: "broadcast server returned wrong BUMP response", StatusCode: 400, Code: "error-broadcast-wrong-bump-response"} - -// ErrBroadcastRejectedTransaction is when broadcast server rejected transaction -var ErrBroadcastRejectedTransaction = models.SPVError{Message: "broadcast rejected transaction", StatusCode: 400, Code: "error-broadcast-rejected-transaction"} - -// ErrARCUnreachable is when ARC cannot be requested -var ErrARCUnreachable = models.SPVError{Message: "ARC cannot be requested", StatusCode: 500, Code: "error-broadcast-unreachable"} - -// ErrARCUnauthorized is when ARC returns unauthorized -var ErrARCUnauthorized = models.SPVError{Message: "ARC returned unauthorized", StatusCode: 500, Code: "error-broadcast-unauthorized"} - -// ErrARCParseResponse is when ARC response cannot be parsed -var ErrARCParseResponse = models.SPVError{Message: "ARC response cannot be parsed", StatusCode: 500, Code: "error-broadcast-parse-response"} - -// ErrARCGenericError is when ARC returns generic error (according to documentation - status code: 409) -var ErrARCGenericError = models.SPVError{Message: "ARC returned generic error", StatusCode: 500, Code: "error-broadcast-generic-error"} +// ErrAskingForFeeUnit is when error occurred during asking for fee unit +var ErrAskingForFeeUnit = models.SPVError{Message: "error during asking for fee unit", StatusCode: 500, Code: "error-asking-for-fee-unit"} -// ErrARCUnsupportedStatusCode is when ARC returns unsupported status code -var ErrARCUnsupportedStatusCode = models.SPVError{Message: "ARC returned unsupported status code", StatusCode: 500, Code: "error-broadcast-unsupported-status-code"} +// ErrBroadcast is when broadcast error occurred +var ErrBroadcast = models.SPVError{Message: "broadcast error", StatusCode: 500, Code: "error-broadcast"} diff --git a/engine/tx_broadcast.go b/engine/tx_broadcast.go index e5b8fc91..7f0c00e8 100644 --- a/engine/tx_broadcast.go +++ b/engine/tx_broadcast.go @@ -4,43 +4,31 @@ import ( "context" "fmt" - "github.com/bitcoin-sv/spv-wallet/engine/chainstate" + sdk "github.com/bitcoin-sv/go-sdk/transaction" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" ) -func broadcastTransaction(ctx context.Context, tx *Transaction) error { - client := tx.Client() - chainstateSrv := client.Chainstate() +func broadcastTransaction(ctx context.Context, txModel *Transaction) error { + client := txModel.Client() - defer recoverAndLog(tx.Client().Logger()) + defer recoverAndLog(txModel.Client().Logger()) unlock, err := newWriteLock( - ctx, fmt.Sprintf(lockKeyProcessBroadcastTx, tx.GetID()), client.Cachestore(), + ctx, fmt.Sprintf(lockKeyProcessBroadcastTx, txModel.GetID()), client.Cachestore(), ) defer unlock() if err != nil { return err } - txHex := tx.Hex - hexFormat := chainstate.RawTx - if chainstateSrv.SupportedBroadcastFormats().Contains(chainstate.Ef) { - // try to convert to EF, with rawRx as a fallback - efHex, ok := ToEfHex(ctx, tx, client) - if ok { - txHex = efHex - hexFormat = chainstate.Ef - } + tx, err := sdk.NewTransactionFromHex(txModel.Hex) + if err != nil { + return spverrors.ErrParseTransactionFromHex.Wrap(err) } - br := chainstateSrv.Broadcast(ctx, tx.ID, txHex, hexFormat, defaultBroadcastTimeout) - - if br.Failure != nil { - if br.Failure.InvalidTx { - return spverrors.ErrBroadcastRejectedTransaction.Wrap((br.Failure.Error)) - } - return br.Failure.Error + _, err = client.Chain().Broadcast(ctx, tx) + if err != nil { + return spverrors.ErrBroadcast.Wrap(err) } - return nil } diff --git a/engine/tx_sync_task.go b/engine/tx_sync_task.go index e205ff4b..273f4d9c 100644 --- a/engine/tx_sync_task.go +++ b/engine/tx_sync_task.go @@ -6,7 +6,7 @@ import ( "time" "github.com/bitcoin-sv/go-sdk/transaction" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/chain/errors" "github.com/rs/zerolog" ) @@ -48,7 +48,7 @@ func processSyncTransactions(ctx context.Context, client *Client) { defer cancel() var delayForBroadcastedTx time.Time - if client.options.txCallbackConfig != nil { + if client.options.arcConfig.Callback != nil { delayForBroadcastedTx = timeForReceivingCallback() } else { delayForBroadcastedTx = timeForMineTransaction() @@ -92,7 +92,7 @@ func processSyncTransactions(ctx context.Context, client *Client) { txInfo, err := client.Chain().QueryTransaction(ctx, txID) if err != nil { - if errors.Is(err, spverrors.ErrARCUnreachable) { + if errors.Is(err, chainerrors.ErrARCUnreachable) { // checking subsequent transactions is pointless if the broadcast server (ARC) is unreachable, will try again in the next cycle logger.Warn().Msgf("%s", err.Error()) return @@ -109,15 +109,18 @@ func processSyncTransactions(ctx context.Context, client *Client) { continue } - bump, err := transaction.NewMerklePathFromHex(txInfo.MerklePath) - if err != nil { + tx.UpdateFromBroadcastStatus(txInfo.TXStatus) + + if bump, err := transaction.NewMerklePathFromHex(txInfo.MerklePath); err != nil { //ARC sometimes returns a TXStatus SEEN_ON_NETWORK, but with zero data - logger.Warn().Err(err).Str("txID", txID).Msg("Cannot parse BUMP") + logger.Warn().Err(err).Str("txID", txID).Str("ARCTxStatus", string(txInfo.TXStatus)).Msg("Cannot parse BUMP") + } else { + tx.SetBUMP(bump) } + tx.BlockHash = txInfo.BlockHash tx.BlockHeight = uint64(txInfo.BlockHeight) - tx.SetBUMP(bump) - tx.UpdateFromBroadcastStatus(txInfo.TXStatus) + saveTx() } } @@ -138,7 +141,7 @@ func _handleUnknownTX(ctx context.Context, tx *Transaction, logger *zerolog.Logg return TxStatusBroadcasted } - if errors.Is(err, spverrors.ErrBroadcastRejectedTransaction) { + if errors.Is(err, chainerrors.ErrARCProblematicStatus) { return TxStatusProblematic } diff --git a/go.mod b/go.mod index 2818d7b9..94e9050e 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,6 @@ require ( github.com/99designs/gqlgen v0.17.55 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/aws/aws-sdk-go v1.55.5 - github.com/bitcoin-sv/go-broadcast-client v0.21.0 github.com/bitcoin-sv/go-paymail v0.21.0 github.com/bitcoin-sv/go-sdk v1.1.9 github.com/bitcoin-sv/spv-wallet/models v0.28.0 @@ -120,7 +119,6 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 - github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 diff --git a/go.sum b/go.sum index 5f9bcff0..4166afc7 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bitcoin-sv/go-broadcast-client v0.21.0 h1:QzDT0letnsQQ9rgVwpAuUN/WutjnBOftOiqSzvY796g= -github.com/bitcoin-sv/go-broadcast-client v0.21.0/go.mod h1:BwLnKMTMbL9uSQSBV9NwveWgpohniZXz6tesikIG8JA= github.com/bitcoin-sv/go-paymail v0.21.0 h1:xcyCWBpXG79Bek+uEC9HknPC9McFzafDKNRiHBi3110= github.com/bitcoin-sv/go-paymail v0.21.0/go.mod h1:CzDCfKjxMI0Ve5Z4V7IuCUP+BXS4PuJ4A7TAQVbESmw= github.com/bitcoin-sv/go-sdk v1.1.9 h1:N/LlZUMHNYKjEBuY72c3XSlzUI/q7IN34R0p6J0Qtjc= @@ -273,7 +271,6 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= diff --git a/mappings/fee_unit.go b/mappings/fee_unit.go index d02401ea..c4f0a37a 100644 --- a/mappings/fee_unit.go +++ b/mappings/fee_unit.go @@ -12,7 +12,7 @@ func MapToFeeUnitContract(fu *bsv.FeeUnit) (fc *response.FeeUnit) { } return &response.FeeUnit{ - Satoshis: fu.Satoshis, + Satoshis: int(fu.Satoshis), Bytes: fu.Bytes, } } @@ -24,7 +24,7 @@ func MapFeeUnitModelToEngine(fu *response.FeeUnit) (fc *bsv.FeeUnit) { } return &bsv.FeeUnit{ - Satoshis: fu.Satoshis, + Satoshis: bsv.Satoshis(fu.Satoshis), Bytes: fu.Bytes, } } diff --git a/mappings/fee_unit_old.go b/mappings/fee_unit_old.go index d62aecd6..7719cf40 100644 --- a/mappings/fee_unit_old.go +++ b/mappings/fee_unit_old.go @@ -12,7 +12,7 @@ func MapToOldFeeUnitContract(fu *bsv.FeeUnit) (fc *models.FeeUnit) { } return &models.FeeUnit{ - Satoshis: fu.Satoshis, + Satoshis: int(fu.Satoshis), Bytes: fu.Bytes, } } @@ -24,7 +24,7 @@ func MapOldFeeUnitModelToEngine(fu *models.FeeUnit) (fc *bsv.FeeUnit) { } return &bsv.FeeUnit{ - Satoshis: fu.Satoshis, + Satoshis: bsv.Satoshis(fu.Satoshis), Bytes: fu.Bytes, } } diff --git a/models/bsv/fee_unit.go b/models/bsv/fee_unit.go index 5bd35079..54c42646 100644 --- a/models/bsv/fee_unit.go +++ b/models/bsv/fee_unit.go @@ -2,13 +2,11 @@ package bsv import "fmt" -// FeeUnit displays the amount of Satoshis neededz -// for a specific amount of Bytes in a transaction +// FeeUnit specifies how much satoshis will be paid per specific amount of bytes in a transaction // see https://github.com/bitcoin-sv-specs/brfc-misc/tree/master/feespec -// Imported from deprecated go-bt library type FeeUnit struct { - Satoshis int `json:"satoshis"` // Fee in satoshis of the amount of Bytes - Bytes int `json:"bytes"` // Number of bytes that the Fee covers + Satoshis Satoshis `json:"satoshis"` // Fee in satoshis of the amount of Bytes + Bytes int `json:"bytes"` // Number of bytes that the Fee covers } // IsLowerThan compare two fee units diff --git a/tests/tests.go b/tests/tests.go index f71fec63..1b75c129 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -32,8 +32,7 @@ func (ts *TestSuite) BaseSetupSuite() { cfg.DebugProfiling = false cfg.Logging.Level = zerolog.LevelDebugValue cfg.Logging.Format = "console" - cfg.ARC.UseFeeQuotes = false - cfg.ARC.FeeUnit = &config.FeeUnitConfig{ + cfg.CustomFeeUnit = &config.FeeUnitConfig{ Satoshis: 1, Bytes: 1000, }