Files
ark/server/internal/infrastructure/wallet/btc-embedded/wallet.go
Louis Singer 10ef0dbffa Add reversible policy to pending vtxos (#311)
* [server] descriptor-based vtxo script

* [server] fix unit tests

* [sdk] descriptor based vtxo

* empty config check & version flag support

* fix: empty config check & version flag support (#309)

* fix

* [sdk] several fixes

* [sdk][server] several fixes

* [common][sdk] add reversible VtxoScript type, use it in async payment

* [common] improve parser

* [common] fix reversible vtxo parser

* [sdk] remove logs

* fix forfeit map

* remove debug log

* [sdk] do not allow reversible vtxo script in case of self-transfer

* remove signing pubkey

* remove signer public key, craft forfeit txs client side

* go work sync

* fix linter errors

* rename MakeForfeitTxs to BuildForfeitTxs

* fix conflicts

* fix tests

* comment VtxoScript type

* revert ROUND_INTERVAL value

---------

Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com>
Co-authored-by: sekulicd <sekula87@gmail.com>
2024-09-19 10:01:33 +02:00

1256 lines
31 KiB
Go

package btcwallet
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"strings"
"sync"
"time"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/server/internal/core/domain"
"github.com/ark-network/ark/server/internal/core/ports"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/rpcclient"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog"
"github.com/btcsuite/btcwallet/chain"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/btcsuite/btcwallet/wallet"
"github.com/btcsuite/btcwallet/walletdb"
_ "github.com/btcsuite/btcwallet/walletdb/bdb"
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/lightninglabs/neutrino"
"github.com/lightningnetwork/lnd/blockcache"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/btcwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
log "github.com/sirupsen/logrus"
"github.com/vulpemventures/go-bip39"
)
type WalletOption func(*service) error
type WalletConfig struct {
Datadir string
Network common.Network
}
func (c WalletConfig) chainParams() *chaincfg.Params {
mutinyNetSigNetParams := chaincfg.CustomSignetParams(common.MutinyNetChallenge, nil)
mutinyNetSigNetParams.TargetTimePerBlock = common.MutinyNetBlockTime
switch c.Network.Name {
case common.Bitcoin.Name:
return &chaincfg.MainNetParams
case common.BitcoinTestNet.Name:
return &chaincfg.TestNet3Params
case common.BitcoinRegTest.Name:
return &chaincfg.RegressionNetParams
case common.BitcoinSigNet.Name:
return &mutinyNetSigNetParams
default:
return &chaincfg.MainNetParams
}
}
type accountName string
const (
mainAccount accountName = "main"
connectorAccount accountName = "connector"
aspKeyAccount accountName = "aspkey"
// https://github.com/bitcoin/bitcoin/blob/439e58c4d8194ca37f70346727d31f52e69592ec/src/policy/policy.cpp#L23C8-L23C11
// biggest input size to compute the maximum dust amount
biggestInputSize = 148 + 182 // = 330 vbytes
)
var (
ErrWalletNotLoaded = fmt.Errorf("wallet not loaded, create or unlock it first")
p2wpkhKeyScope = waddrmgr.KeyScopeBIP0084
p2trKeyScope = waddrmgr.KeyScopeBIP0086
outputLockDuration = time.Minute
)
// add additional chain API not supported by the chain.Interface type
type extraChainAPI interface {
getTx(txid string) (*wire.MsgTx, error)
getTxStatus(txid string) (isConfirmed bool, blocktime int64, err error)
broadcast(txHex string) error
}
type service struct {
wallet *btcwallet.BtcWallet
cfg WalletConfig
chainSource chain.Interface
scanner chain.Interface
extraAPI extraChainAPI
feeEstimator chainfee.Estimator
watchedScriptsLock sync.RWMutex
watchedScripts map[string]struct{}
aspTaprootAddr waddrmgr.ManagedPubKeyAddress
}
// WithNeutrino creates a start a neutrino node using the provided service datadir
func WithNeutrino(initialPeer string, esploraURL string) WalletOption {
return func(s *service) error {
if s.cfg.Network.Name == common.BitcoinRegTest.Name && len(initialPeer) == 0 {
return fmt.Errorf("initial neutrino peer required for regtest network, set NEUTRINO_PEER env var")
}
db, err := createOrOpenWalletDB(s.cfg.Datadir + "/neutrino.db")
if err != nil {
return err
}
netParams := s.cfg.chainParams()
config := neutrino.Config{
DataDir: s.cfg.Datadir,
ChainParams: *netParams,
Database: db,
}
if len(initialPeer) > 0 {
config.AddPeers = []string{initialPeer}
}
neutrino.UseLogger(logger("neutrino"))
btcwallet.UseLogger(logger("btcwallet"))
neutrinoSvc, err := neutrino.NewChainService(config)
if err != nil {
return err
}
if err := neutrinoSvc.Start(); err != nil {
return err
}
// wait for neutrino to sync
for !neutrinoSvc.IsCurrent() {
time.Sleep(1 * time.Second)
}
chainSrc := chain.NewNeutrinoClient(netParams, neutrinoSvc)
scanner := chain.NewNeutrinoClient(netParams, neutrinoSvc)
esploraClient := &esploraClient{url: esploraURL}
estimator, err := chainfee.NewWebAPIEstimator(esploraClient, true, 5*time.Minute, 20*time.Minute)
if err != nil {
return err
}
if err := withExtraAPI(esploraClient)(s); err != nil {
return err
}
if err := withFeeEstimator(estimator)(s); err != nil {
return err
}
if err := withChainSource(chainSrc)(s); err != nil {
return err
}
return withScanner(scanner)(s)
}
}
func WithPollingBitcoind(host, user, pass string) WalletOption {
return func(s *service) error {
netParams := s.cfg.chainParams()
// Create a new bitcoind configuration
bitcoindConfig := &chain.BitcoindConfig{
ChainParams: netParams,
Host: host,
User: user,
Pass: pass,
PollingConfig: &chain.PollingConfig{
BlockPollingInterval: 10 * time.Second,
TxPollingInterval: 5 * time.Second,
TxPollingIntervalJitter: 0.1,
RPCBatchSize: 20,
RPCBatchInterval: 1 * time.Second,
},
}
chain.UseLogger(logger("chain"))
// Create the BitcoindConn first
bitcoindConn, err := chain.NewBitcoindConn(bitcoindConfig)
if err != nil {
return fmt.Errorf("failed to create bitcoind connection: %w", err)
}
// Start the bitcoind connection
if err := bitcoindConn.Start(); err != nil {
return fmt.Errorf("failed to start bitcoind connection: %w", err)
}
// Now create the BitcoindClient using the connection
chainClient := bitcoindConn.NewBitcoindClient()
// Start the chain client
if err := chainClient.Start(); err != nil {
bitcoindConn.Stop()
return fmt.Errorf("failed to start bitcoind client: %w", err)
}
// wait for bitcoind to sync
for !chainClient.IsCurrent() {
time.Sleep(1 * time.Second)
}
estimator, err := chainfee.NewBitcoindEstimator(
rpcclient.ConnConfig{
Host: bitcoindConfig.Host,
User: bitcoindConfig.User,
Pass: bitcoindConfig.Pass,
},
"CONSERVATIVE",
chainfee.AbsoluteFeePerKwFloor,
)
if err != nil {
return fmt.Errorf("failed to create bitcoind fee estimator: %w", err)
}
if err := withExtraAPI(&bitcoindRPCClient{chainClient})(s); err != nil {
return err
}
if err := withFeeEstimator(estimator)(s); err != nil {
return err
}
// Set up the wallet as chain source and scanner
if err := withChainSource(chainClient)(s); err != nil {
chainClient.Stop()
bitcoindConn.Stop()
return fmt.Errorf("failed to set chain source: %w", err)
}
if err := withScanner(chainClient)(s); err != nil {
chainClient.Stop()
bitcoindConn.Stop()
return fmt.Errorf("failed to set scanner: %w", err)
}
return nil
}
}
// NewService creates the wallet service, an option must be set to configure the chain source.
func NewService(cfg WalletConfig, options ...WalletOption) (ports.WalletService, error) {
wallet.UseLogger(logger("wallet"))
svc := &service{
cfg: cfg,
watchedScriptsLock: sync.RWMutex{},
watchedScripts: make(map[string]struct{}),
}
for _, option := range options {
if err := option(svc); err != nil {
return nil, err
}
}
return svc, nil
}
func (s *service) Close() {
if s.walletLoaded() {
if err := s.wallet.Stop(); err != nil {
log.WithError(err).Warn("failed to gracefully stop the wallet, forcing shutdown")
}
}
}
func (s *service) GenSeed(_ context.Context) (string, error) {
entropy, err := bip39.NewEntropy(256)
if err != nil {
return "", err
}
return bip39.NewMnemonic(entropy)
}
func (s *service) Create(_ context.Context, seed, password string) error {
return s.create(seed, password, 0)
}
func (s *service) Restore(_ context.Context, seed, password string) error {
return s.create(seed, password, 100)
}
func (s *service) Unlock(_ context.Context, password string) error {
if !s.walletLoaded() {
pwd := []byte(password)
opt := btcwallet.LoaderWithLocalWalletDB(s.cfg.Datadir, false, time.Minute)
config := btcwallet.Config{
LogDir: s.cfg.Datadir,
PrivatePass: pwd,
PublicPass: pwd,
Birthday: time.Now(),
RecoveryWindow: 0,
NetParams: s.cfg.chainParams(),
LoaderOptions: []btcwallet.LoaderOption{opt},
CoinSelectionStrategy: wallet.CoinSelectionLargest,
ChainSource: s.chainSource,
}
blockCache := blockcache.NewBlockCache(2 * 1024 * 1024 * 1024)
wallet, err := btcwallet.New(config, blockCache)
if err != nil {
return fmt.Errorf("failed to setup wallet loader: %s", err)
}
if err := wallet.Start(); err != nil {
return fmt.Errorf("failed to start wallet: %s", err)
}
for {
if !wallet.InternalWallet().ChainSynced() {
log.Debugf("waiting sync: current height %d", wallet.InternalWallet().Manager.SyncedTo().Height)
time.Sleep(3 * time.Second)
continue
}
break
}
log.Debugf("chain synced")
addrs, err := wallet.ListAddresses(string(aspKeyAccount), false)
if err != nil {
return err
}
for info, addrs := range addrs {
if info.AccountName != string(aspKeyAccount) {
continue
}
for _, addr := range addrs {
if addr.Internal {
continue
}
splittedPath := strings.Split(addr.DerivationPath, "/")
last := splittedPath[len(splittedPath)-1]
if last == "0" {
decoded, err := btcutil.DecodeAddress(addr.Address, s.cfg.chainParams())
if err != nil {
return err
}
infos, err := wallet.AddressInfo(decoded)
if err != nil {
return err
}
managedPubkeyAddr, ok := infos.(waddrmgr.ManagedPubKeyAddress)
if !ok {
return fmt.Errorf("failed to cast address to managed pubkey address")
}
s.aspTaprootAddr = managedPubkeyAddr
break
}
}
}
s.wallet = wallet
return nil
}
return s.wallet.InternalWallet().Unlock([]byte(password), nil)
}
func (s *service) Lock(_ context.Context, _ string) error {
if !s.walletLoaded() {
return ErrWalletNotLoaded
}
s.wallet.InternalWallet().Lock()
return nil
}
func (s *service) BroadcastTransaction(ctx context.Context, txHex string) (string, error) {
if err := s.extraAPI.broadcast(txHex); err != nil {
return "", err
}
var tx wire.MsgTx
if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txHex))); err != nil {
return "", err
}
if err := s.wallet.PublishTransaction(&tx, ""); err != nil {
return "", err
}
return tx.TxHash().String(), nil
}
func (s *service) ConnectorsAccountBalance(ctx context.Context) (uint64, uint64, error) {
amount, err := s.getBalance(connectorAccount)
if err != nil {
return 0, 0, err
}
return amount, 0, nil
}
func (s *service) MainAccountBalance(ctx context.Context) (uint64, uint64, error) {
amount, err := s.getBalance(mainAccount)
if err != nil {
return 0, 0, err
}
return amount, 0, nil
}
func (s *service) DeriveAddresses(ctx context.Context, num int) ([]string, error) {
addresses := make([]string, 0, num)
for i := 0; i < num; i++ {
addr, err := s.deriveNextAddress(mainAccount)
if err != nil {
return nil, err
}
addresses = append(addresses, addr.EncodeAddress())
}
if len(addresses) == 0 {
return nil, fmt.Errorf("no addresses derived")
}
return addresses, nil
}
func (s *service) DeriveConnectorAddress(ctx context.Context) (string, error) {
addr, err := s.deriveNextAddress(connectorAccount)
if err != nil {
return "", err
}
return addr.EncodeAddress(), nil
}
func (s *service) GetPubkey(ctx context.Context) (*secp256k1.PublicKey, error) {
return s.aspTaprootAddr.PubKey(), nil
}
func (s *service) ListConnectorUtxos(ctx context.Context, connectorAddress string) ([]ports.TxInput, error) {
w := s.wallet.InternalWallet()
addr, err := btcutil.DecodeAddress(connectorAddress, w.ChainParams())
if err != nil {
return nil, err
}
script, err := txscript.PayToAddrScript(addr)
if err != nil {
return nil, err
}
connectorAccountNumber, err := w.AccountNumber(p2wpkhKeyScope, string(connectorAccount))
if err != nil {
return nil, err
}
utxos, err := w.UnspentOutputs(wallet.OutputSelectionPolicy{
Account: connectorAccountNumber,
RequiredConfirmations: 0,
})
if err != nil {
return nil, err
}
txInputs := make([]ports.TxInput, 0, len(utxos))
for _, utxo := range utxos {
if !bytes.Equal(utxo.Output.PkScript, script) {
continue
}
txInputs = append(txInputs, transactionOutputTxInput{utxo})
}
return txInputs, nil
}
func (s *service) LockConnectorUtxos(ctx context.Context, utxos []ports.TxOutpoint) error {
w := s.wallet.InternalWallet()
for _, utxo := range utxos {
id, _ := chainhash.NewHashFromStr(utxo.GetTxid())
if _, err := w.LeaseOutput(
wtxmgr.LockID(id[:]),
wire.OutPoint{
Hash: *id,
Index: utxo.GetIndex(),
},
outputLockDuration,
); err != nil {
return err
}
}
return nil
}
func (s *service) SelectUtxos(ctx context.Context, _ string, amount uint64) ([]ports.TxInput, uint64, error) {
w := s.wallet.InternalWallet()
mainAccountNumber, err := w.AccountNumber(p2wpkhKeyScope, string(mainAccount))
if err != nil {
return nil, 0, err
}
utxos, err := w.UnspentOutputs(wallet.OutputSelectionPolicy{
Account: mainAccountNumber,
RequiredConfirmations: 0, // allow uncomfirmed utxos
})
if err != nil {
return nil, 0, err
}
coins := make([]wallet.Coin, 0, len(utxos))
for _, utxo := range utxos {
coins = append(coins, wallet.Coin{
OutPoint: *wire.NewOutPoint(&utxo.OutPoint.Hash, utxo.OutPoint.Index),
TxOut: utxo.Output,
})
}
arranged, err := wallet.CoinSelectionLargest.ArrangeCoins(
coins,
btcutil.Amount(0), // unused by CoinSelectionLargest strategy
)
if err != nil {
return nil, 0, err
}
selectedAmount := uint64(0)
selectedUtxos := make([]ports.TxInput, 0, len(arranged))
for _, coin := range arranged {
if selectedAmount >= amount {
break
}
selectedAmount += uint64(coin.Value)
selectedUtxos = append(selectedUtxos, coinTxInput{coin})
}
if selectedAmount < amount {
return nil, 0, fmt.Errorf("insufficient funds to select %d, only %d available", amount, selectedAmount)
}
return selectedUtxos, selectedAmount - amount, nil
}
func (s *service) SignTransaction(ctx context.Context, partialTx string, extractRawTx bool) (string, error) {
ptx, err := psbt.NewFromRawBytes(
strings.NewReader(partialTx),
true,
)
if err != nil {
return "", err
}
signedInputs, err := s.signPsbt(ptx, nil)
if err != nil {
return "", err
}
if extractRawTx {
// verify that all inputs are signed
if len(signedInputs) != len(ptx.Inputs) {
return "", fmt.Errorf("not all inputs are signed, unable to finalize the psbt")
}
for i, in := range ptx.Inputs {
isTaproot := txscript.IsPayToTaproot(in.WitnessUtxo.PkScript)
if isTaproot && len(in.TaprootLeafScript) > 0 {
closure, err := bitcointree.DecodeClosure(in.TaprootLeafScript[0].Script)
if err != nil {
return "", err
}
witness := make(wire.TxWitness, 4)
castClosure, isTaprootMultisig := closure.(*bitcointree.MultisigClosure)
if isTaprootMultisig {
ownerPubkey := schnorr.SerializePubKey(castClosure.Pubkey)
aspKey := schnorr.SerializePubKey(castClosure.AspPubkey)
for _, sig := range in.TaprootScriptSpendSig {
if bytes.Equal(sig.XOnlyPubKey, ownerPubkey) {
witness[0] = sig.Signature
}
if bytes.Equal(sig.XOnlyPubKey, aspKey) {
witness[1] = sig.Signature
}
}
witness[2] = in.TaprootLeafScript[0].Script
witness[3] = in.TaprootLeafScript[0].ControlBlock
for idw, w := range witness {
if w == nil {
return "", fmt.Errorf("missing witness element %d, cannot finalize taproot mutlisig input %d", idw, i)
}
}
var witnessBuf bytes.Buffer
if err := psbt.WriteTxWitness(&witnessBuf, witness); err != nil {
return "", err
}
ptx.Inputs[i].FinalScriptWitness = witnessBuf.Bytes()
continue
}
}
if err := psbt.Finalize(ptx, i); err != nil {
return "", fmt.Errorf("failed to finalize input %d: %w", i, err)
}
}
extracted, err := psbt.Extract(ptx)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := extracted.Serialize(&buf); err != nil {
return "", err
}
return hex.EncodeToString(buf.Bytes()), nil
}
return ptx.B64Encode()
}
func (s *service) SignTransactionTapscript(ctx context.Context, partialTx string, inputIndexes []int) (string, error) {
partial, err := psbt.NewFromRawBytes(
strings.NewReader(partialTx),
true,
)
if err != nil {
return "", err
}
if len(inputIndexes) == 0 {
inputIndexes = make([]int, len(partial.Inputs))
for i := range partial.Inputs {
inputIndexes[i] = i
}
}
signedInputs, err := s.signPsbt(partial, inputIndexes)
if err != nil {
return "", err
}
for _, index := range inputIndexes {
hasBeenSigned := false
for _, signedIndex := range signedInputs {
if signedIndex == uint32(index) {
hasBeenSigned = true
break
}
}
if !hasBeenSigned {
return "", fmt.Errorf("input %d has not been signed", index)
}
}
return partial.B64Encode()
}
func (s *service) Status(ctx context.Context) (ports.WalletStatus, error) {
if !s.walletLoaded() {
return status{
initialized: s.walletInitialized(),
}, nil
}
w := s.wallet.InternalWallet()
return status{
true,
!w.Manager.IsLocked(),
w.ChainSynced(),
}, nil
}
func (s *service) WaitForSync(ctx context.Context, txid string) error {
w := s.wallet.InternalWallet()
txhash, err := chainhash.NewHashFromStr(txid)
if err != nil {
return err
}
ticker := time.NewTicker(5 * time.Second)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
_, err := w.GetTransaction(*txhash)
if err != nil {
if strings.Contains(err.Error(), wallet.ErrNoTx.Error()) {
continue
}
return err
} else {
ticker.Stop()
return nil
}
}
}
}
func (s *service) MinRelayFeeRate(ctx context.Context) chainfee.SatPerKVByte {
return s.feeEstimator.RelayFeePerKW().FeePerKVByte()
}
func (s *service) MinRelayFee(ctx context.Context, vbytes uint64) (uint64, error) {
fee := s.feeEstimator.RelayFeePerKW().FeeForVByte(lntypes.VByte(vbytes))
return uint64(fee.ToUnit(btcutil.AmountSatoshi)), nil
}
func (s *service) EstimateFees(ctx context.Context, partialTx string) (uint64, error) {
feeRate, err := s.feeEstimator.EstimateFeePerKW(1)
if err != nil {
return 0, err
}
partial, err := psbt.NewFromRawBytes(
strings.NewReader(partialTx),
true,
)
if err != nil {
return 0, err
}
weightEstimator := &input.TxWeightEstimator{}
for _, input := range partial.Inputs {
if input.WitnessUtxo == nil {
return 0, fmt.Errorf("missing witness utxo for input")
}
script, err := txscript.ParsePkScript(input.WitnessUtxo.PkScript)
if err != nil {
return 0, err
}
switch script.Class() {
case txscript.PubKeyHashTy:
weightEstimator.AddP2PKHInput()
case txscript.WitnessV0PubKeyHashTy:
weightEstimator.AddP2WKHInput()
case txscript.WitnessV1TaprootTy:
if len(input.TaprootLeafScript) > 0 {
leaf := input.TaprootLeafScript[0]
ctrlBlock, err := txscript.ParseControlBlock(leaf.ControlBlock)
if err != nil {
return 0, err
}
weightEstimator.AddTapscriptInput(64*2, &waddrmgr.Tapscript{
RevealedScript: leaf.Script,
ControlBlock: ctrlBlock,
})
} else {
weightEstimator.AddTaprootKeySpendInput(txscript.SigHashDefault)
}
default:
return 0, fmt.Errorf("unsupported script type: %v", script.Class())
}
}
for _, output := range partial.UnsignedTx.TxOut {
script, err := txscript.ParsePkScript(output.PkScript)
if err != nil {
return 0, err
}
switch script.Class() {
case txscript.PubKeyHashTy:
weightEstimator.AddP2PKHOutput()
case txscript.WitnessV0PubKeyHashTy:
weightEstimator.AddP2WKHOutput()
case txscript.ScriptHashTy:
weightEstimator.AddP2SHOutput()
case txscript.WitnessV0ScriptHashTy:
weightEstimator.AddP2WSHOutput()
case txscript.WitnessV1TaprootTy:
weightEstimator.AddP2TROutput()
default:
return 0, fmt.Errorf("unsupported script type: %v", script.Class())
}
}
fee := feeRate.FeeForVByte(lntypes.VByte(weightEstimator.VSize()))
return uint64(fee.ToUnit(btcutil.AmountSatoshi)), nil
}
func (s *service) WatchScripts(ctx context.Context, scripts []string) error {
addresses := make([]btcutil.Address, 0, len(scripts))
for _, script := range scripts {
scriptBytes, err := hex.DecodeString(script)
if err != nil {
return err
}
addr, err := fromOutputScript(scriptBytes, s.cfg.chainParams())
if err != nil {
return err
}
addresses = append(addresses, addr)
}
if err := s.scanner.NotifyReceived(addresses); err != nil {
if err := s.UnwatchScripts(ctx, scripts); err != nil {
return fmt.Errorf("error while unwatching scripts: %w", err)
}
return err
}
s.watchedScriptsLock.Lock()
defer s.watchedScriptsLock.Unlock()
for _, script := range scripts {
s.watchedScripts[script] = struct{}{}
}
return nil
}
func (s *service) UnwatchScripts(ctx context.Context, scripts []string) error {
s.watchedScriptsLock.Lock()
defer s.watchedScriptsLock.Unlock()
for _, script := range scripts {
delete(s.watchedScripts, script)
}
return nil
}
func (s *service) GetNotificationChannel(
ctx context.Context,
) <-chan map[string][]ports.VtxoWithValue {
ch := make(chan map[string][]ports.VtxoWithValue)
go func() {
const maxCacheSize = 100
sentTxs := make(map[chainhash.Hash]struct{})
cache := func(hash chainhash.Hash) {
if len(sentTxs) > maxCacheSize {
sentTxs = make(map[chainhash.Hash]struct{})
}
sentTxs[hash] = struct{}{}
}
for n := range s.scanner.Notifications() {
switch m := n.(type) {
case chain.RelevantTx:
if _, sent := sentTxs[m.TxRecord.Hash]; sent {
continue
}
notification := s.castNotification(m.TxRecord)
cache(m.TxRecord.Hash)
ch <- notification
case chain.FilteredBlockConnected:
for _, tx := range m.RelevantTxs {
if _, sent := sentTxs[tx.Hash]; sent {
continue
}
notification := s.castNotification(tx)
cache(tx.Hash)
ch <- notification
}
}
}
}()
return ch
}
func (s *service) IsTransactionConfirmed(
ctx context.Context, txid string,
) (isConfirmed bool, blocktime int64, err error) {
return s.extraAPI.getTxStatus(txid)
}
func (s *service) GetDustAmount(
ctx context.Context,
) (uint64, error) {
return s.MinRelayFee(ctx, biggestInputSize)
}
func (s *service) GetTransaction(ctx context.Context, txid string) (string, error) {
tx, err := s.extraAPI.getTx(txid)
if err != nil {
return "", err
}
if tx == nil {
return "", fmt.Errorf("transaction not found")
}
var buf bytes.Buffer
if err := tx.Serialize(&buf); err != nil {
return "", err
}
return hex.EncodeToString(buf.Bytes()), nil
}
func (s *service) castNotification(tx *wtxmgr.TxRecord) map[string][]ports.VtxoWithValue {
vtxos := make(map[string][]ports.VtxoWithValue)
s.watchedScriptsLock.RLock()
defer s.watchedScriptsLock.RUnlock()
for outputIndex, txout := range tx.MsgTx.TxOut {
script := hex.EncodeToString(txout.PkScript)
if _, ok := s.watchedScripts[script]; !ok {
continue
}
if len(vtxos[script]) <= 0 {
vtxos[script] = make([]ports.VtxoWithValue, 0)
}
vtxos[script] = append(vtxos[script], ports.VtxoWithValue{
VtxoKey: domain.VtxoKey{
Txid: tx.Hash.String(),
VOut: uint32(outputIndex),
},
Value: uint64(txout.Value),
})
}
return vtxos
}
func (s *service) create(mnemonic, password string, addrGap uint32) error {
if len(mnemonic) <= 0 {
return fmt.Errorf("missing hd seed")
}
if len(password) <= 0 {
return fmt.Errorf("missing password")
}
pwd := []byte(password)
seed := bip39.NewSeed(mnemonic, password)
opt := btcwallet.LoaderWithLocalWalletDB(s.cfg.Datadir, false, time.Minute)
config := btcwallet.Config{
LogDir: s.cfg.Datadir,
PrivatePass: pwd,
PublicPass: pwd,
Birthday: time.Now(),
RecoveryWindow: addrGap,
HdSeed: seed,
NetParams: s.cfg.chainParams(),
LoaderOptions: []btcwallet.LoaderOption{opt},
CoinSelectionStrategy: wallet.CoinSelectionLargest,
ChainSource: s.chainSource,
}
blockCache := blockcache.NewBlockCache(2 * 1024 * 1024 * 1024)
wallet, err := btcwallet.New(config, blockCache)
if err != nil {
return fmt.Errorf("failed to setup wallet loader: %s", err)
}
if err := wallet.Start(); err != nil {
return fmt.Errorf("failed to start wallet: %s", err)
}
if err := s.initWallet(wallet); err != nil {
return err
}
for {
if !wallet.InternalWallet().ChainSynced() {
log.Debugf("waiting sync: current height %d", wallet.InternalWallet().Manager.SyncedTo().Height)
time.Sleep(3 * time.Second)
continue
}
break
}
log.Debugf("chain synced")
if addrGap > 0 {
// TODO: fix rescan
if err := wallet.InternalWallet().Rescan(nil, nil); err != nil {
return err
}
}
wallet.InternalWallet().Lock()
s.wallet = wallet
return nil
}
func (s *service) initWallet(wallet *btcwallet.BtcWallet) error {
w := wallet.InternalWallet()
walletAccounts, err := w.Accounts(p2wpkhKeyScope)
if err != nil {
return fmt.Errorf("failed to list wallet accounts: %s", err)
}
var mainAccountNumber, connectorAccountNumber, aspKeyAccountNumber uint32
if walletAccounts != nil {
for _, account := range walletAccounts.Accounts {
switch account.AccountName {
case string(mainAccount):
mainAccountNumber = account.AccountNumber
case string(connectorAccount):
connectorAccountNumber = account.AccountNumber
case string(aspKeyAccount):
aspKeyAccountNumber = account.AccountNumber
default:
continue
}
}
}
if mainAccountNumber == 0 && connectorAccountNumber == 0 && aspKeyAccountNumber == 0 {
log.Debug("creating default accounts for ark wallet...")
mainAccountNumber, err = w.NextAccount(p2wpkhKeyScope, string(mainAccount))
if err != nil {
return fmt.Errorf("failed to create %s: %s", mainAccount, err)
}
connectorAccountNumber, err = w.NextAccount(p2wpkhKeyScope, string(connectorAccount))
if err != nil {
return fmt.Errorf("failed to create %s: %s", connectorAccount, err)
}
aspKeyAccountNumber, err = w.NextAccount(p2trKeyScope, string(aspKeyAccount))
if err != nil {
return fmt.Errorf("failed to create %s: %s", aspKeyAccount, err)
}
}
log.Debugf("main account number: %d", mainAccountNumber)
log.Debugf("connector account number: %d", connectorAccountNumber)
log.Debugf("asp key account number: %d", aspKeyAccountNumber)
addrs, err := wallet.ListAddresses(string(aspKeyAccount), false)
if err != nil {
return err
}
if len(addrs) == 0 {
aspKeyAddr, err := wallet.NewAddress(lnwallet.TaprootPubkey, false, string(aspKeyAccount))
if err != nil {
return err
}
addrInfos, err := wallet.AddressInfo(aspKeyAddr)
if err != nil {
return err
}
managedAddr, ok := addrInfos.(waddrmgr.ManagedPubKeyAddress)
if !ok {
return fmt.Errorf("failed to cast address to managed pubkey address")
}
s.aspTaprootAddr = managedAddr
} else {
for info, addrs := range addrs {
if info.AccountName != string(aspKeyAccount) {
continue
}
for _, addr := range addrs {
if addr.Internal {
continue
}
splittedPath := strings.Split(addr.DerivationPath, "/")
last := splittedPath[len(splittedPath)-1]
if last == "0" {
decoded, err := btcutil.DecodeAddress(addr.Address, s.cfg.chainParams())
if err != nil {
return err
}
infos, err := wallet.AddressInfo(decoded)
if err != nil {
return err
}
managedPubkeyAddr, ok := infos.(waddrmgr.ManagedPubKeyAddress)
if !ok {
return fmt.Errorf("failed to cast address to managed pubkey address")
}
s.aspTaprootAddr = managedPubkeyAddr
break
}
}
}
}
return nil
}
func (s *service) getBalance(account accountName) (uint64, error) {
if !s.walletLoaded() {
return 0, ErrWalletNotLoaded
}
balance, err := s.wallet.ConfirmedBalance(0, string(account))
if err != nil {
return 0, err
}
return uint64(balance), nil
}
// this only supports deriving segwit v0 accounts
func (s *service) deriveNextAddress(account accountName) (btcutil.Address, error) {
if !s.walletLoaded() {
return nil, ErrWalletNotLoaded
}
return s.wallet.NewAddress(lnwallet.WitnessPubKey, false, string(account))
}
func (s *service) walletLoaded() bool {
return s.wallet != nil
}
func (s *service) walletInitialized() bool {
opts := []btcwallet.LoaderOption{btcwallet.LoaderWithLocalWalletDB(s.cfg.Datadir, false, time.Minute)}
loader, err := btcwallet.NewWalletLoader(
s.cfg.chainParams(), 0, opts...,
)
if err != nil {
return false
}
exist, _ := loader.WalletExists()
return exist
}
func withChainSource(chainSource chain.Interface) WalletOption {
return func(s *service) error {
if s.chainSource != nil {
return fmt.Errorf("chain source already set")
}
if err := chainSource.Start(); err != nil {
return fmt.Errorf("failed to start chain source: %s", err)
}
s.chainSource = chainSource
return nil
}
}
func withScanner(chainSource chain.Interface) WalletOption {
return func(s *service) error {
if s.scanner != nil {
return fmt.Errorf("scanner already set")
}
if err := chainSource.Start(); err != nil {
return fmt.Errorf("failed to start scanner: %s", err)
}
s.scanner = chainSource
return nil
}
}
func withExtraAPI(api extraChainAPI) WalletOption {
return func(s *service) error {
if s.extraAPI != nil {
return fmt.Errorf("extra chain API already set")
}
s.extraAPI = api
return nil
}
}
func withFeeEstimator(estimator chainfee.Estimator) WalletOption {
return func(s *service) error {
if s.feeEstimator != nil {
return fmt.Errorf("fee estimator already set")
}
if err := estimator.Start(); err != nil {
return fmt.Errorf("failed to start fee estimator: %s", err)
}
s.feeEstimator = estimator
return nil
}
}
func createOrOpenWalletDB(path string) (walletdb.DB, error) {
db, err := walletdb.Open("bdb", path, true, 60*time.Second)
if err == nil {
return db, nil
}
if err != walletdb.ErrDbDoesNotExist {
return nil, err
}
return walletdb.Create("bdb", path, true, 60*time.Second)
}
// status implements ports.WalletStatus interface
type status struct {
initialized bool
unlocked bool
synced bool
}
func (s status) IsInitialized() bool {
return s.initialized
}
func (s status) IsUnlocked() bool {
return s.unlocked
}
func (s status) IsSynced() bool {
return s.synced
}
func fromOutputScript(script []byte, netParams *chaincfg.Params) (btcutil.Address, error) {
return btcutil.NewAddressTaproot(script[2:], netParams)
}
func logger(subsystem string) btclog.Logger {
return btclog.NewBackend(log.StandardLogger().Writer()).Logger(subsystem)
}