-
Notifications
You must be signed in to change notification settings - Fork 92
/
electrum.go
364 lines (324 loc) · 11.6 KB
/
electrum.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
// This code is available on the terms of the project LICENSE.md file,
// also available online at https://blueoakcouncil.org/license/1.0.0.
package btc
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"decred.org/dcrdex/client/asset"
"decred.org/dcrdex/client/asset/btc/electrum"
"decred.org/dcrdex/dex"
dexbtc "decred.org/dcrdex/dex/networks/btc"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
)
// ExchangeWalletElectrum is the asset.Wallet for an external Electrum wallet.
type ExchangeWalletElectrum struct {
*baseWallet
ew *electrumWallet
}
var _ asset.Wallet = (*ExchangeWalletElectrum)(nil)
var _ asset.FeeRater = (*ExchangeWalletElectrum)(nil)
var _ asset.Sweeper = (*ExchangeWalletElectrum)(nil)
// ElectrumWallet creates a new ExchangeWalletElectrum for the provided
// configuration, which must contain the necessary details for accessing the
// Electrum wallet's RPC server in the WalletCFG.Settings map.
func ElectrumWallet(cfg *BTCCloneCFG) (*ExchangeWalletElectrum, error) {
clientCfg, err := readRPCWalletConfig(cfg.WalletCFG.Settings, cfg.Symbol, cfg.Network, cfg.Ports)
if err != nil {
return nil, err
}
btc, err := newUnconnectedWallet(cfg, &clientCfg.WalletConfig)
if err != nil {
return nil, err
}
rpcCfg := &clientCfg.RPCConfig
ewc := electrum.NewWalletClient(rpcCfg.RPCUser, rpcCfg.RPCPass, "http://"+rpcCfg.RPCBind)
ew := newElectrumWallet(ewc, &electrumWalletConfig{
params: cfg.ChainParams,
log: cfg.Logger.SubLogger("ELECTRUM"),
addrDecoder: cfg.AddressDecoder,
addrStringer: cfg.AddressStringer,
txDeserializer: cfg.TxDeserializer,
txSerializer: cfg.TxSerializer,
segwit: cfg.Segwit,
rpcCfg: rpcCfg,
})
btc.node = ew
eew := &ExchangeWalletElectrum{
baseWallet: btc,
ew: ew,
}
btc.estimateFee = eew.feeRate // use ExchangeWalletElectrum override, not baseWallet's
return eew, nil
}
// DepositAddress returns an address for depositing funds into the exchange
// wallet. The address will be unused but not necessarily new. Use NewAddress to
// request a new address, but it should be used immediately.
func (btc *ExchangeWalletElectrum) DepositAddress() (string, error) {
return btc.ew.wallet.GetUnusedAddress(btc.ew.ctx)
}
// RedemptionAddress gets an address for use in redeeming the counterparty's
// swap. This would be included in their swap initialization. The address will
// be unused but not necessarily new because these addresses often go unused.
func (btc *ExchangeWalletElectrum) RedemptionAddress() (string, error) {
return btc.ew.wallet.GetUnusedAddress(btc.ew.ctx)
}
// Connect connects to the Electrum wallet's RPC server and an electrum server
// directly. Goroutines are started to monitor for new blocks and server
// connection changes. Satisfies the dex.Connector interface.
func (btc *ExchangeWalletElectrum) Connect(ctx context.Context) (*sync.WaitGroup, error) {
wg, err := btc.connect(ctx) // prepares btc.ew.chainV via btc.node.connect()
if err != nil {
return nil, err
}
commands, err := btc.ew.wallet.Commands(ctx)
if err != nil {
return nil, err
}
var hasFreezeUTXO bool
for i := range commands {
if commands[i] == "freeze_utxo" {
hasFreezeUTXO = true
break
}
}
if !hasFreezeUTXO {
return nil, errors.New("wallet does not support the freeze_utxo command")
}
serverFeats, err := btc.ew.chain().Features(ctx)
if err != nil {
return nil, err
}
// TODO: for chainforks with the same genesis hash (BTC -> BCH), compare a
// block hash at some post-fork height.
if genesis := btc.chainParams.GenesisHash; genesis != nil && genesis.String() != serverFeats.Genesis {
return nil, fmt.Errorf("wanted genesis hash %v, got %v (wrong network)",
genesis.String(), serverFeats.Genesis)
}
wg.Add(1)
go func() {
defer wg.Done()
btc.watchBlocks(ctx) // ExchangeWalletElectrum override
btc.cancelRedemptionSearches()
}()
wg.Add(1)
go func() {
defer wg.Done()
btc.monitorPeers(ctx)
}()
return wg, nil
}
// Sweep sends all the funds in the wallet to an address.
func (btc *ExchangeWalletElectrum) Sweep(address string, feeSuggestion uint64) (asset.Coin, error) {
addr, err := btc.decodeAddr(address, btc.chainParams)
if err != nil {
return nil, fmt.Errorf("address decode error: %w", err)
}
pkScript, err := txscript.PayToAddrScript(addr)
if err != nil {
return nil, fmt.Errorf("PayToAddrScript error: %w", err)
}
txRaw, err := btc.ew.sweep(btc.ew.ctx, address, feeSuggestion)
if err != nil {
return nil, err
}
msgTx, err := btc.deserializeTx(txRaw)
if err != nil {
return nil, err
}
txHash := msgTx.TxHash()
for vout, txOut := range msgTx.TxOut {
if bytes.Equal(txOut.PkScript, pkScript) {
return newOutput(&txHash, uint32(vout), uint64(txOut.Value)), nil
}
}
// Well, the txn is sent, so let's at least direct the user to the txid even
// though we failed to find the output with the expected pkScript. Perhaps
// the Electrum wallet generated a slightly different pkScript for the
// provided address.
btc.log.Warnf("Generated tx does not seem to contain an output to %v!", address)
return newOutput(&txHash, 0, 0 /* ! */), nil
}
// override feeRate to avoid unnecessary conversions and btcjson types.
func (btc *ExchangeWalletElectrum) feeRate(_ RawRequester, confTarget uint64) (uint64, error) {
satPerKB, err := btc.ew.wallet.FeeRate(btc.ew.ctx, int64(confTarget))
if err != nil {
return 0, err
}
return uint64(dex.IntDivUp(satPerKB, 1000)), nil
}
// FeeRate gets a fee rate estimate. Satisfies asset.FeeRater.
func (btc *ExchangeWalletElectrum) FeeRate() uint64 {
feeRate, err := btc.feeRate(nil, 1)
if err != nil {
btc.log.Errorf("Failed to retrieve fee rate: %v", err)
return 0
}
return feeRate
}
// findRedemption will search for the spending transaction of specified
// outpoint. If found, the secret key will be extracted from the input scripts.
// If not found, but otherwise without an error, a nil Hash will be returned
// along with a nil error. Thus, both the error and the Hash should be checked.
// This convention is only used since this is not part of the public API.
func (btc *ExchangeWalletElectrum) findRedemption(ctx context.Context, op outPoint, contractHash []byte) (*chainhash.Hash, uint32, []byte, error) {
msgTx, vin, err := btc.ew.findOutputSpender(ctx, &op.txHash, op.vout)
if err != nil {
return nil, 0, nil, err
}
if msgTx == nil {
return nil, 0, nil, nil
}
txHash := msgTx.TxHash()
txIn := msgTx.TxIn[vin]
secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript,
contractHash, btc.segwit, btc.chainParams)
if err != nil {
return nil, 0, nil, fmt.Errorf("failed to extract secret key from tx %v input %d: %w",
txHash, vin, err) // name the located tx in the error since we found it
}
return &txHash, vin, secret, nil
}
func (btc *ExchangeWalletElectrum) tryRedemptionRequests(ctx context.Context) {
btc.findRedemptionMtx.RLock()
reqs := make([]*findRedemptionReq, 0, len(btc.findRedemptionQueue))
for _, req := range btc.findRedemptionQueue {
reqs = append(reqs, req)
}
btc.findRedemptionMtx.RUnlock()
for _, req := range reqs {
txHash, vin, secret, err := btc.findRedemption(ctx, req.outPt, req.contractHash)
if err != nil {
req.fail("findRedemption: %w", err)
continue
}
if txHash == nil {
continue // maybe next time
}
req.success(&findRedemptionResult{
redemptionCoinID: toCoinID(txHash, vin),
secret: secret,
})
}
}
// FindRedemption locates a swap contract output's redemption transaction input
// and the secret key used to spend the output.
func (btc *ExchangeWalletElectrum) FindRedemption(ctx context.Context, coinID, contract dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) {
txHash, vout, err := decodeCoinID(coinID)
if err != nil {
return nil, nil, err
}
contractHash := btc.hashContract(contract)
// We can verify the contract hash via:
// txRes, _ := btc.ewc.getWalletTransaction(txHash)
// msgTx, _ := msgTxFromBytes(txRes.Hex)
// contractHash := dexbtc.ExtractScriptHash(msgTx.TxOut[vout].PkScript)
// OR
// txOut, _, _ := btc.ew.getTxOutput(txHash, vout)
// contractHash := dexbtc.ExtractScriptHash(txOut.PkScript)
// Check once before putting this in the queue.
outPt := newOutPoint(txHash, vout)
spendTxID, vin, secret, err := btc.findRedemption(ctx, outPt, contractHash)
if err != nil {
return nil, nil, err
}
if spendTxID != nil {
return toCoinID(spendTxID, vin), secret, nil
}
req := &findRedemptionReq{
outPt: outPt,
resultChan: make(chan *findRedemptionResult, 1),
contractHash: contractHash,
// blockHash, blockHeight, and pkScript not used by this impl.
blockHash: &chainhash.Hash{},
}
if err := btc.queueFindRedemptionRequest(req); err != nil {
return nil, nil, err
}
var result *findRedemptionResult
select {
case result = <-req.resultChan:
if result == nil {
err = fmt.Errorf("unexpected nil result for redemption search for %s", outPt)
}
case <-ctx.Done():
err = fmt.Errorf("context cancelled during search for redemption for %s", outPt)
}
// If this contract is still in the findRedemptionQueue, remove from the
// queue to prevent further redemption search attempts for this contract.
btc.findRedemptionMtx.Lock()
delete(btc.findRedemptionQueue, outPt)
btc.findRedemptionMtx.Unlock()
// result would be nil if ctx is canceled or the result channel is closed
// without data, which would happen if the redemption search is aborted when
// this ExchangeWallet is shut down.
if result != nil {
return result.redemptionCoinID, result.secret, result.err
}
return nil, nil, err
}
// watchBlocks pings for new blocks and runs the tipChange callback function
// when the block changes.
func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) {
const electrumBlockTick = 5 * time.Second
ticker := time.NewTicker(electrumBlockTick)
defer ticker.Stop()
bestBlock := func() (*block, error) {
hdr, err := btc.node.getBestBlockHeader()
if err != nil {
return nil, fmt.Errorf("getBestBlockHeader: %v", err)
}
hash, err := chainhash.NewHashFromStr(hdr.Hash)
if err != nil {
return nil, fmt.Errorf("invalid best block hash %s: %v", hdr.Hash, err)
}
return &block{hdr.Height, *hash}, nil
}
currentTip, err := bestBlock()
if err != nil {
btc.log.Errorf("Failed to get best block: %v", err)
currentTip = new(block) // zero height and hash
}
for {
select {
case <-ticker.C:
// Don't make server requests on every tick. Wallet has a headers
// subscription, so we can just ask wallet the height. That means
// only comparing heights instead of hashes, which means we might
// not notice a reorg to a block at the same height, which is
// unimportant because of how electrum searches for transactions.
stat, err := btc.node.syncStatus()
if err != nil {
go btc.tipChange(fmt.Errorf("failed to get sync status: %w", err))
continue
}
sameTip := currentTip.height == int64(stat.Height)
if sameTip {
// Could have actually been a reorg to different block at same
// height. We'll report a new tip block on the next block.
continue
}
newTip, err := bestBlock()
if err != nil {
// NOTE: often says "height X out of range", then succeeds on next tick
if !strings.Contains(err.Error(), "out of range") {
go btc.tipChange(fmt.Errorf("failed to get best block from %s electrum server: %w",
btc.symbol, err))
}
continue
}
btc.log.Debugf("tip change: %d (%s) => %d (%s)", currentTip.height, currentTip.hash,
newTip.height, newTip.hash)
currentTip = newTip
go btc.tipChange(nil)
go btc.tryRedemptionRequests(ctx)
case <-ctx.Done():
return
}
}
}