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 }