decred.org/dcrdex@v1.0.5/client/asset/firo/firo.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package firo
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"math"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"decred.org/dcrdex/client/asset"
    17  	"decred.org/dcrdex/client/asset/btc"
    18  	"decred.org/dcrdex/dex"
    19  	"decred.org/dcrdex/dex/dexnet"
    20  	dexbtc "decred.org/dcrdex/dex/networks/btc"
    21  	dexfiro "decred.org/dcrdex/dex/networks/firo"
    22  
    23  	"github.com/btcsuite/btcd/btcec/v2"
    24  	"github.com/btcsuite/btcd/btcutil"
    25  	"github.com/btcsuite/btcd/chaincfg"
    26  	"github.com/btcsuite/btcd/wire"
    27  )
    28  
    29  const (
    30  	version = 0
    31  	// Zcoin XZC
    32  	BipID = 136
    33  	//  https://github.com/firoorg/firo/releases/tag/v0.14.14.1
    34  	minNetworkVersion   = 141401
    35  	walletTypeRPC       = "firodRPC"
    36  	walletTypeElectrum  = "electrumRPC"
    37  	estimateFeeConfs    = 2 // 2 blocks should be enough
    38  	needElectrumVersion = "4.1.5"
    39  )
    40  
    41  var (
    42  	configOpts = append(btc.RPCConfigOpts("Firo", "8888"), []*asset.ConfigOption{
    43  		{
    44  			Key:          "fallbackfee",
    45  			DisplayName:  "Fallback fee rate",
    46  			Description:  "Firo's 'fallbackfee' rate. Units: FIRO/kB",
    47  			DefaultValue: strconv.FormatFloat(dexfiro.DefaultFee*1000/1e8, 'f', -1, 64),
    48  		},
    49  		{
    50  			Key:         "feeratelimit",
    51  			DisplayName: "Highest acceptable fee rate",
    52  			Description: "This is the highest network fee rate you are willing to " +
    53  				"pay on swap transactions. If feeratelimit is lower than a market's " +
    54  				"maxfeerate, you will not be able to trade on that market with this " +
    55  				"wallet.  Units: FIRO/kB",
    56  			DefaultValue: strconv.FormatFloat(dexfiro.DefaultFeeRateLimit*1000/1e8, 'f', -1, 64),
    57  		},
    58  		{
    59  			Key:         "txsplit",
    60  			DisplayName: "Pre-split funding inputs",
    61  			Description: "When placing an order, create a \"split\" transaction to fund the order without locking more of the wallet balance than " +
    62  				"necessary. Otherwise, excess funds may be reserved to fund the order until the first swap contract is broadcast " +
    63  				"during match settlement, or the order is canceled. This an extra transaction for which network mining fees are paid. " +
    64  				"Used only for standing-type orders, e.g. limit orders without immediate time-in-force.",
    65  			IsBoolean: true,
    66  			// DefaultValue is false
    67  		},
    68  	}...)
    69  	// WalletInfo defines some general information about a Firo wallet.
    70  	WalletInfo = &asset.WalletInfo{
    71  		Name:              "Firo",
    72  		SupportedVersions: []uint32{version},
    73  		UnitInfo:          dexfiro.UnitInfo,
    74  		AvailableWallets: []*asset.WalletDefinition{
    75  			{
    76  				Type:              walletTypeRPC,
    77  				Tab:               "Firo Core (external)",
    78  				Description:       "Connect to firod",
    79  				DefaultConfigPath: dexbtc.SystemConfigPath("firo"),
    80  				ConfigOpts:        configOpts,
    81  				MultiFundingOpts:  btc.MultiFundingOpts,
    82  			},
    83  			{
    84  				Type:             walletTypeElectrum,
    85  				Tab:              "Electrum-Firo (external)",
    86  				Description:      "Use an external Electrum-Firo Wallet",
    87  				ConfigOpts:       append(btc.ElectrumConfigOpts, btc.CommonConfigOpts("FIRO", true)...),
    88  				MultiFundingOpts: btc.MultiFundingOpts,
    89  			},
    90  		},
    91  	}
    92  )
    93  
    94  func init() {
    95  	asset.Register(BipID, &Driver{})
    96  }
    97  
    98  // Driver implements asset.Driver.
    99  type Driver struct{}
   100  
   101  // Open creates the FIRO exchange wallet. Start the wallet with its Run method.
   102  func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) {
   103  	return NewWallet(cfg, logger, network)
   104  }
   105  
   106  // DecodeCoinID creates a human-readable representation of a coin ID
   107  // for Firo.
   108  func (d *Driver) DecodeCoinID(coinID []byte) (string, error) {
   109  	// Firo and Bitcoin have the same tx hash and output format.
   110  	return (&btc.Driver{}).DecodeCoinID(coinID)
   111  }
   112  
   113  // Info returns basic information about the wallet and asset.
   114  func (d *Driver) Info() *asset.WalletInfo {
   115  	return WalletInfo
   116  }
   117  
   118  // MinLotSize calculates the minimum bond size for a given fee rate that avoids
   119  // dust outputs on the swap and refund txs, assuming the maxFeeRate doesn't
   120  // change.
   121  func (d *Driver) MinLotSize(maxFeeRate uint64) uint64 {
   122  	return dexbtc.MinLotSize(maxFeeRate, false)
   123  }
   124  
   125  // NewWallet is the exported constructor by which the DEX will import the
   126  // exchange wallet. The wallet will shut down when the provided context is
   127  // canceled. The configPath can be an empty string, in which case the standard
   128  // system location of the firod config file is assumed.
   129  func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) {
   130  	var params *chaincfg.Params
   131  	switch network {
   132  	case dex.Mainnet:
   133  		params = dexfiro.MainNetParams
   134  	case dex.Testnet:
   135  		params = dexfiro.TestNetParams
   136  	case dex.Regtest:
   137  		params = dexfiro.RegressionNetParams
   138  	default:
   139  		return nil, fmt.Errorf("unknown network ID %v", network)
   140  	}
   141  
   142  	cloneCFG := &btc.BTCCloneCFG{
   143  		WalletCFG:                cfg,
   144  		MinNetworkVersion:        minNetworkVersion,
   145  		WalletInfo:               WalletInfo,
   146  		Symbol:                   "firo",
   147  		Logger:                   logger,
   148  		Network:                  network,
   149  		ChainParams:              params,
   150  		Ports:                    dexfiro.NetPorts,
   151  		DefaultFallbackFee:       dexfiro.DefaultFee,
   152  		DefaultFeeRateLimit:      dexfiro.DefaultFeeRateLimit,
   153  		Segwit:                   false,
   154  		InitTxSize:               dexbtc.InitTxSize,
   155  		InitTxSizeBase:           dexbtc.InitTxSizeBase,
   156  		LegacyBalance:            cfg.Type == walletTypeRPC,
   157  		LegacyRawFeeLimit:        true, // sendrawtransaction Has single arg allowhighfees
   158  		ArglessChangeAddrRPC:     true, // getrawchangeaddress has No address-type arg
   159  		OmitAddressType:          true, // getnewaddress has No address-type arg
   160  		LegacySignTxRPC:          true, // No signrawtransactionwithwallet RPC
   161  		BooleanGetBlockRPC:       true, // Use bool true/false text for verbose param
   162  		LegacyValidateAddressRPC: true, // use validateaddress to read 'ismine' bool
   163  		SingularWallet:           true, // one wallet/node
   164  		UnlockSpends:             true, // Firo chain wallet does Not unlock coins after sendrawtransaction
   165  		AssetID:                  BipID,
   166  		FeeEstimator:             estimateFee,
   167  		ExternalFeeEstimator:     externalFeeRate,
   168  		AddressDecoder:           decodeAddress,
   169  		PrivKeyFunc:              nil, // set only for walletTypeRPC below
   170  		BlockDeserializer:        nil, // set only for walletTypeRPC below
   171  	}
   172  
   173  	switch cfg.Type {
   174  	case walletTypeRPC:
   175  		var exw *btc.ExchangeWalletFullNode
   176  		cloneCFG.PrivKeyFunc = func(addr string) (*btcec.PrivateKey, error) {
   177  			return privKeyForAddress(exw, addr)
   178  		}
   179  		cloneCFG.BlockDeserializer = func(blk []byte) (*wire.MsgBlock, error) {
   180  			return deserializeBlock(params, blk)
   181  		}
   182  		var err error
   183  		exw, err = btc.BTCCloneWallet(cloneCFG)
   184  		return exw, err
   185  	case walletTypeElectrum:
   186  		// override Ports - no default ports
   187  		cloneCFG.Ports = dexbtc.NetPorts{}
   188  		ver, err := dex.SemverFromString(needElectrumVersion)
   189  		if err != nil {
   190  			return nil, err
   191  		}
   192  		cloneCFG.MinElectrumVersion = *ver
   193  		return btc.ElectrumWallet(cloneCFG)
   194  	default:
   195  		return nil, fmt.Errorf("unknown wallet type %q for firo", cfg.Type)
   196  	}
   197  }
   198  
   199  /******************************************************************************
   200                               Helper Functions
   201  ******************************************************************************/
   202  
   203  // decodeAddress decodes a Firo address. For normal transparent addresses this
   204  // just uses btcd: btcutil.DecodeAddress.
   205  func decodeAddress(address string, net *chaincfg.Params) (btcutil.Address, error) {
   206  	if isExxAddress(address) {
   207  		return decodeExxAddress(address, net)
   208  	}
   209  	decAddr, err := btcutil.DecodeAddress(address, net)
   210  	if err != nil {
   211  		return nil, err
   212  	}
   213  	if !decAddr.IsForNet(net) {
   214  		return nil, errors.New("wrong network")
   215  	}
   216  	return decAddr, nil
   217  }
   218  
   219  // rpcCaller is satisfied by ExchangeWalletFullNode (baseWallet), providing
   220  // direct RPC requests.
   221  type rpcCaller interface {
   222  	CallRPC(method string, args []any, thing any) error
   223  }
   224  
   225  // privKeyForAddress is Firo's dumpprivkey RPC which calls dumpprivkey once
   226  // to get a One Time Authorization (OTA) which is appended to a second call
   227  // for the same address to authorize the caller.
   228  func privKeyForAddress(c rpcCaller, addr string) (*btcec.PrivateKey, error) {
   229  	const methodDumpPrivKey = "dumpprivkey"
   230  	var privkeyStr string
   231  	err := c.CallRPC(methodDumpPrivKey, []any{addr}, &privkeyStr)
   232  	if err == nil { // really, expect an error...
   233  		return nil, errors.New("firo dumpprivkey: no authorization challenge")
   234  	}
   235  
   236  	errStr := err.Error()
   237  	searchStr := "authorization code is: "
   238  	i0 := strings.Index(errStr, searchStr) // TODO: use CutPrefix when Go 1.20 is min
   239  	if i0 == -1 {
   240  		return nil, err
   241  	}
   242  	i := i0 + len(searchStr)
   243  	auth := errStr[i : i+4]
   244  	/// fmt.Printf("OTA: %s\n", auth)
   245  
   246  	err = c.CallRPC(methodDumpPrivKey, []any{addr, auth}, &privkeyStr)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  
   251  	wif, err := btcutil.DecodeWIF(privkeyStr)
   252  	if err != nil {
   253  		return nil, err
   254  	}
   255  
   256  	return wif.PrivKey, nil
   257  }
   258  
   259  // NOTE: btc.(*baseWallet).feeRate calls the local and external fee estimators
   260  // in sequence, applying the limits configured in baseWallet.
   261  
   262  func estimateFee(ctx context.Context, rr btc.RawRequester, _ uint64) (uint64, error) {
   263  	confArg, err := json.Marshal(estimateFeeConfs)
   264  	if err != nil {
   265  		return 0, err
   266  	}
   267  	resp, err := rr.RawRequest(ctx, "estimatefee", []json.RawMessage{confArg})
   268  	if err != nil {
   269  		return 0, err
   270  	}
   271  	var feeRate float64
   272  	err = json.Unmarshal(resp, &feeRate)
   273  	if err != nil {
   274  		return 0, err
   275  	}
   276  	if feeRate <= 0 {
   277  		return 0, nil
   278  	}
   279  	// Keep this check
   280  	if feeRate > dexfiro.DefaultFeeRateLimit/1e5 {
   281  		return dexfiro.DefaultFee, nil
   282  	}
   283  	return uint64(math.Round(feeRate * 1e5)), nil
   284  }
   285  
   286  // externalFeeRate returns a fee rate for the network. If an error is
   287  // encountered fetching the testnet fee rate, we will try to return the
   288  // mainnet fee rate.
   289  func externalFeeRate(ctx context.Context, net dex.Network) (uint64, error) {
   290  	const mainnetURI = "https://explorer.firo.org/insight-api-zcoin/utils/estimatefee"
   291  	var uri string
   292  	if net == dex.Testnet {
   293  		uri = "https://testexplorer.firo.org/insight-api-zcoin/utils/estimatefee"
   294  	} else {
   295  		uri = "https://explorer.firo.org/insight-api-zcoin/utils/estimatefee"
   296  	}
   297  	feeRate, err := fetchExternalFee(ctx, uri)
   298  	if err == nil || net != dex.Testnet {
   299  		return feeRate, err
   300  	}
   301  	return fetchExternalFee(ctx, mainnetURI)
   302  }
   303  
   304  // fetchExternalFee calls 'estimatefee' API on Firo block explorer for
   305  // the network. API returned float value is converted into sats/byte.
   306  func fetchExternalFee(ctx context.Context, uri string) (uint64, error) {
   307  	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
   308  	defer cancel()
   309  	var resp map[string]float64
   310  	if err := dexnet.Get(ctx, uri, &resp); err != nil {
   311  		return 0, err
   312  	}
   313  	if resp == nil {
   314  		return 0, errors.New("null response")
   315  	}
   316  
   317  	firoPerKilobyte, ok := resp["2"] // field '2': n.nnnn
   318  	if !ok {
   319  		return 0, errors.New("no fee rate in response")
   320  	}
   321  	if firoPerKilobyte <= 0 {
   322  		return 0, fmt.Errorf("zero or negative fee rate")
   323  	}
   324  	return uint64(math.Round(firoPerKilobyte * 1e5)), nil // FIRO/kB => firo-sat/B
   325  }