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