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 }