// Copyright 2023 The AthanorLabs/atomic-swap Authors // SPDX-License-Identifier: LGPL-3.0-only // Package monero provides client libraries for working with wallet files and interacting // with a monero node. Management of monero-wallet-rpc daemon instances is fully // encapsulated by these libraries. package monero import ( "bufio" "context" "errors" "fmt" "io/fs" "os" "os/exec" "path" "strings" "syscall" "time" "github.com/MarinX/monerorpc" monerodaemon "github.com/MarinX/monerorpc/daemon" "github.com/MarinX/monerorpc/wallet" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero" ) const ( moneroWalletRPCLogPrefix = "[monero-wallet-rpc]: " // MinSpendConfirmations is the number of confirmations required on transaction // outputs before they can be spent again. MinSpendConfirmations = 10 // SweepToSelfConfirmations is the number of confirmations that we wait for when // sweeping funds from an A+B wallet to our primary wallet. SweepToSelfConfirmations = 2 ) // WalletClient represents a monero-wallet-rpc client. type WalletClient interface { GetAccounts() (*wallet.GetAccountsResponse, error) GetAddress(idx uint32) (*wallet.GetAddressResponse, error) PrimaryAddress() *mcrypto.Address GetBalance(idx uint32) (*wallet.GetBalanceResponse, error) Transfer( ctx context.Context, to *mcrypto.Address, accountIdx uint32, amount *coins.PiconeroAmount, numConfirmations uint64, ) (*wallet.Transfer, error) SweepAll( ctx context.Context, to *mcrypto.Address, accountIdx uint32, numConfirmations uint64, ) ([]*wallet.Transfer, error) CreateWalletConf(walletNamePrefix string) *WalletClientConf WalletName() string GetHeight() (uint64, error) Endpoint() string // URL on which the wallet is accepting RPC requests Close() // Close closes the client itself, including any open wallet CloseAndRemoveWallet() } // WalletClientConf wraps the configuration fields needed to call NewWalletClient type WalletClientConf struct { Env common.Environment // Required WalletFilePath string // Required, wallet created if it does not exist WalletPassword string // Optional, password used to open wallet or when creating a new wallet WalletPort uint // Optional, zero means OS picks a random port MonerodNodes []*common.MoneroNode // Optional, defaulted from environment if nil MoneroWalletRPCPath string // optional, path to monero-rpc-binary LogPath string // optional, default is dir(WalletFilePath)/../monero-wallet-rpc.log } // Fill fills in the optional configuration values (Port, MonerodNodes, MoneroWalletRPCPath, // and LogPath) if they are not set. // Note: MonerodNodes is set to the first validated node. func (conf *WalletClientConf) Fill() error { if conf.WalletFilePath == "" { panic("WalletFilePath is a required conf field") // should have been caught before we were invoked } var err error if conf.MoneroWalletRPCPath == "" { conf.MoneroWalletRPCPath, err = getMoneroWalletRPCBin() if err != nil { return err } } if len(conf.MonerodNodes) == 0 { conf.MonerodNodes = common.ConfigDefaultsForEnv(conf.Env).MoneroNodes } validatedNode, err := findWorkingNode(conf.Env, conf.MonerodNodes) if err != nil { return err } conf.MonerodNodes = []*common.MoneroNode{validatedNode} if conf.LogPath == "" { // default to the folder above the wallet conf.LogPath = path.Join(path.Dir(path.Dir(conf.WalletFilePath)), "monero-wallet-rpc.log") } if conf.WalletPort == 0 { conf.WalletPort, err = common.GetFreeTCPPort() if err != nil { return err } } return nil } // waitForReceiptRequest wraps the input parameters for waitForReceipt type waitForReceiptRequest struct { Ctx context.Context TxID string NumConfirmations uint64 AccountIdx uint32 } type walletClient struct { wRPC wallet.Wallet // full monero-wallet-rpc API (larger than the WalletClient interface) dRPC monerodaemon.Daemon // full monerod RPC API endpoint string walletAddr *mcrypto.Address conf *WalletClientConf rpcProcess *os.Process // monero-wallet-rpc process that we create } // NewWalletClient returns a WalletClient for a newly created monero-wallet-rpc process. func NewWalletClient(conf *WalletClientConf) (WalletClient, error) { if path.Dir(conf.WalletFilePath) == "." { return nil, errors.New("wallet file cannot be in the current working directory") } err := conf.Fill() if err != nil { return nil, err } walletExists, err := common.FileExists(conf.WalletFilePath) if err != nil { return nil, err } isNewWallet := !walletExists validatedNode := conf.MonerodNodes[0] proc, err := createWalletRPCService( conf.Env, conf.MoneroWalletRPCPath, conf.WalletPort, path.Dir(conf.WalletFilePath), conf.LogPath, validatedNode) if err != nil { return nil, err } c := NewThinWalletClient(validatedNode.Host, validatedNode.Port, conf.WalletPort).(*walletClient) c.rpcProcess = proc walletName := path.Base(conf.WalletFilePath) if isNewWallet { if err = c.CreateWallet(walletName, conf.WalletPassword); err != nil { c.Close() return nil, err } log.Infof("New Monero wallet %s created", conf.WalletFilePath) } else { err = c.wRPC.OpenWallet(&wallet.OpenWalletRequest{ Filename: walletName, Password: conf.WalletPassword, }) if err != nil { c.Close() return nil, err } } acctResp, err := c.GetAddress(0) if err != nil { c.Close() return nil, err } c.walletAddr, err = mcrypto.NewAddress(acctResp.Address, conf.Env) if err != nil { c.Close() return nil, err } c.conf = conf return c, nil } // NewThinWalletClient returns a WalletClient for an existing monero-wallet-rpc process. func NewThinWalletClient(monerodHost string, monerodPort uint, walletPort uint) WalletClient { monerodEndpoint := fmt.Sprintf("http://%s:%d/json_rpc", monerodHost, monerodPort) walletEndpoint := fmt.Sprintf("http://127.0.0.1:%d/json_rpc", walletPort) return &walletClient{ dRPC: monerorpc.New(monerodEndpoint, nil).Daemon, wRPC: monerorpc.New(walletEndpoint, nil).Wallet, endpoint: walletEndpoint, } } func (c *walletClient) WalletName() string { return path.Base(c.conf.WalletFilePath) } func (c *walletClient) GetAccounts() (*wallet.GetAccountsResponse, error) { return c.wRPC.GetAccounts(&wallet.GetAccountsRequest{}) } func (c *walletClient) GetBalance(idx uint32) (*wallet.GetBalanceResponse, error) { if err := c.refresh(); err != nil { return nil, err } return c.wRPC.GetBalance(&wallet.GetBalanceRequest{ AccountIndex: idx, }) } // waitForReceipt waits for the passed monero transaction ID to receive numConfirmations // and returns the transfer information. While this function will always wait for the // transaction to leave the mem-pool even if zero confirmations are requested, it is the // caller's responsibility to request enough confirmations that the returned transfer // information will not be invalidated by a block reorg. func (c *walletClient) waitForReceipt(req *waitForReceiptRequest) (*wallet.Transfer, error) { height, err := c.GetHeight() if err != nil { return nil, err } var transfer *wallet.Transfer for { // Wallet is already refreshed here, due to GetHeight above and WaitForBlocks below transferResp, err := c.wRPC.GetTransferByTxid(&wallet.GetTransferByTxidRequest{ TxID: req.TxID, AccountIndex: req.AccountIdx, }) if err != nil { return nil, err } transfer = &transferResp.Transfer log.Infof("Received %d of %d confirmations of XMR TXID=%s (height=%d)", transfer.Confirmations, req.NumConfirmations, req.TxID, height) // wait for transaction be mined (height set) even if 0 confirmations requested if transfer.Height > 0 && transfer.Confirmations >= req.NumConfirmations { break } height, err = WaitForBlocks(req.Ctx, c, 1) if err != nil { return nil, err } } return transfer, nil } func (c *walletClient) Transfer( ctx context.Context, to *mcrypto.Address, accountIdx uint32, amount *coins.PiconeroAmount, numConfirmations uint64, ) (*wallet.Transfer, error) { amt, err := amount.Uint64() if err != nil { return nil, err } amountStr := amount.AsMoneroString() log.Infof("Transferring %s XMR to %s", amountStr, to) reqResp, err := c.wRPC.Transfer(&wallet.TransferRequest{ Destinations: []wallet.Destination{{ Amount: amt, Address: to.String(), }}, AccountIndex: accountIdx, }) if err != nil { log.Warnf("Transfer of %s XMR failed: %s", amountStr, err) return nil, fmt.Errorf("transfer failed: %w", err) } log.Infof("Transfer of %s XMR initiated, TXID=%s", amountStr, reqResp.TxHash) transfer, err := c.waitForReceipt(&waitForReceiptRequest{ Ctx: ctx, TxID: reqResp.TxHash, NumConfirmations: numConfirmations, AccountIdx: accountIdx, }) if err != nil { return nil, fmt.Errorf("monero TXID=%s receipt failure: %w", reqResp.TxHash, err) } log.Infof("Transfer TXID=%s succeeded with %d confirmations and fee %s XMR", transfer.TxID, transfer.Confirmations, coins.FmtPiconeroAsXMR(transfer.Fee), ) return transfer, nil } func (c *walletClient) SweepAll( ctx context.Context, to *mcrypto.Address, accountIdx uint32, numConfirmations uint64, ) ([]*wallet.Transfer, error) { addrResp, err := c.GetAddress(accountIdx) if err != nil { return nil, fmt.Errorf("sweep operation failed to get address: %w", err) } from := addrResp.Address balance, err := c.GetBalance(accountIdx) if err != nil { return nil, fmt.Errorf("sweep operation failed to get balance: %w", err) } log.Infof("Starting sweep of %s XMR from %s to %s", coins.FmtPiconeroAsXMR(balance.Balance), from, to) if balance.Balance == 0 { return nil, fmt.Errorf("sweep from %s failed, no balance to sweep", from) } if balance.BlocksToUnlock > 0 { log.Infof("Sweep operation waiting %d blocks for balance to fully unlock", balance.BlocksToUnlock) if _, err = WaitForBlocks(ctx, c, int(balance.BlocksToUnlock)); err != nil { return nil, fmt.Errorf("sweep operation failed waiting to unlock balance: %w", err) } } reqResp, err := c.wRPC.SweepAll(&wallet.SweepAllRequest{ AccountIndex: accountIdx, Address: to.String(), }) if err != nil { return nil, fmt.Errorf("sweep_all from %s failed: %w", from, err) } log.Infof("Sweep transaction started, TX IDs: %s", strings.Join(reqResp.TxHashList, ", ")) var transfers []*wallet.Transfer for _, txID := range reqResp.TxHashList { receipt, err := c.waitForReceipt(&waitForReceiptRequest{ Ctx: ctx, TxID: txID, NumConfirmations: numConfirmations, AccountIdx: accountIdx, }) if err != nil { return nil, fmt.Errorf("sweep of TXID=%s failed waiting for receipt: %w", txID, err) } log.Infof("Sweep transfer ID=%s of %s XMR (%s XMR fees) completed at height %d", txID, coins.FmtPiconeroAsXMR(receipt.Amount), coins.FmtPiconeroAsXMR(receipt.Fee), receipt.Height, ) transfers = append(transfers, receipt) } return transfers, nil } func (c *walletClient) CreateWalletConf(walletNamePrefix string) *WalletClientConf { walletName := fmt.Sprintf("%s-%s", walletNamePrefix, time.Now().Format(common.TimeFmtNSecs)) walletPath := path.Join(path.Dir(c.conf.WalletFilePath), walletName) conf := &WalletClientConf{ Env: c.conf.Env, WalletFilePath: walletPath, WalletPassword: c.conf.WalletPassword, WalletPort: 0, MonerodNodes: c.conf.MonerodNodes, MoneroWalletRPCPath: c.conf.MoneroWalletRPCPath, LogPath: c.conf.LogPath, } return conf } func createWalletFromKeys( conf *WalletClientConf, walletRestoreHeight uint64, privateSpendKey *mcrypto.PrivateSpendKey, // nil for a view-only wallet privateViewKey *mcrypto.PrivateViewKey, address *mcrypto.Address, ) (WalletClient, error) { if conf.WalletPort == 0 { // swap wallets need randomized ports, so we expect this to be zero var err error conf.WalletPort, err = common.GetFreeTCPPort() if err != nil { return nil, err } } // should be a one item list, we use the same node that the primary wallet is using monerodNode := conf.MonerodNodes[0] proc, err := createWalletRPCService( conf.Env, conf.MoneroWalletRPCPath, conf.WalletPort, path.Dir(conf.WalletFilePath), conf.LogPath, monerodNode, ) if err != nil { return nil, err } c := NewThinWalletClient(monerodNode.Host, monerodNode.Port, conf.WalletPort).(*walletClient) c.rpcProcess = proc c.conf = conf err = c.generateFromKeys( privateSpendKey, // nil for a view-only wallet privateViewKey, address, walletRestoreHeight, path.Base(conf.WalletFilePath), conf.WalletPassword, ) if err != nil { c.Close() return nil, err } acctResp, err := c.GetAddress(0) if err != nil { c.Close() return nil, err } c.walletAddr, err = mcrypto.NewAddress(acctResp.Address, conf.Env) if err != nil { c.Close() return nil, err } if !c.walletAddr.Equal(address) { c.Close() return nil, fmt.Errorf("provided address %s does not match monero-wallet-rpc computed address %s", address, c.walletAddr) } bal, err := c.GetBalance(0) if err != nil { c.Close() return nil, err } log.Infof("Created wallet %s, balance is %s XMR (%d blocks to unlock), address is %s", c.WalletName(), coins.FmtPiconeroAsXMR(bal.Balance), bal.BlocksToUnlock, c.PrimaryAddress(), ) return c, nil } // CreateSpendWalletFromKeys creates a new monero-wallet-rpc process, wallet client and // spend wallet for the passed private key pair (view key and spend key). func CreateSpendWalletFromKeys( conf *WalletClientConf, privateKeyPair *mcrypto.PrivateKeyPair, restoreHeight uint64, ) (WalletClient, error) { privateViewKey := privateKeyPair.ViewKey() privateSpendKey := privateKeyPair.SpendKey() address := privateKeyPair.PublicKeyPair().Address(conf.Env) return createWalletFromKeys(conf, restoreHeight, privateSpendKey, privateViewKey, address) } // CreateViewOnlyWalletFromKeys creates a new monero-wallet-rpc process, wallet client and // view-only wallet for the passed private view key and address. func CreateViewOnlyWalletFromKeys( conf *WalletClientConf, privateViewKey *mcrypto.PrivateViewKey, address *mcrypto.Address, restoreHeight uint64, ) (WalletClient, error) { return createWalletFromKeys(conf, restoreHeight, nil, privateViewKey, address) } func (c *walletClient) generateFromKeys( sk *mcrypto.PrivateSpendKey, vk *mcrypto.PrivateViewKey, address *mcrypto.Address, restoreHeight uint64, filename, password string, ) error { const ( successMessage = "Wallet has been generated successfully." viewOnlySuccessMessage = "Watch-only wallet has been generated successfully." ) spendKey := "" // not used when only generating a view key if sk != nil { spendKey = sk.Hex() } res, err := c.wRPC.GenerateFromKeys(&wallet.GenerateFromKeysRequest{ Filename: filename, Address: address.String(), RestoreHeight: restoreHeight, Viewkey: vk.Hex(), Spendkey: spendKey, Password: password, }) if err != nil { return err } expectedMessage := successMessage if spendKey == "" { expectedMessage = viewOnlySuccessMessage } if res.Info != expectedMessage { return fmt.Errorf("got unexpected Info string: %s", res.Info) } return nil } func (c *walletClient) GetAddress(idx uint32) (*wallet.GetAddressResponse, error) { return c.wRPC.GetAddress(&wallet.GetAddressRequest{ AccountIndex: idx, }) } func (c *walletClient) refresh() error { _, err := c.wRPC.Refresh(&wallet.RefreshRequest{}) return err } func (c *walletClient) CreateWallet(filename, password string) error { return c.wRPC.CreateWallet(&wallet.CreateWalletRequest{ Filename: filename, Password: password, Language: "English", }) } func (c *walletClient) PrimaryAddress() *mcrypto.Address { if c.walletAddr == nil { // Initialised in constructor function, so this shouldn't ever happen panic("primary wallet address was not initialised") } return c.walletAddr } func (c *walletClient) GetHeight() (uint64, error) { if err := c.refresh(); err != nil { return 0, err } res, err := c.wRPC.GetHeight() if err != nil { return 0, err } return res.Height, nil } // getChainHeight gets the blockchain height directly from the monero daemon instead // of the wallet height. func (c *walletClient) getChainHeight() (uint64, error) { res, err := c.dRPC.GetBlockCount() if err != nil { return 0, err } return res.Count, nil } func (c *walletClient) Endpoint() string { return c.endpoint } // Close kills the monero-wallet-rpc process closing the wallet. It is designed to only be // called a single time from a single go process. func (c *walletClient) Close() { if c.rpcProcess == nil { return // no monero-wallet-rpc instance was created } p := c.rpcProcess err := c.wRPC.StopWallet() if err != nil { log.Warnf("StopWallet errored: %s", err) err = p.Kill() // uses, SIG-TERM, which monero-wallet-rpc has a handler for } // If err is nil at this point, the process existed, and we block until the child // process exits. (Note: kill does not error when signaling an exited, but non-reaped // child.) if err == nil { _, _ = p.Wait() } } // CloseAndRemoveWallet kills the monero-wallet-rpc process and removes the wallet files. This // should never be called on the user's primary wallet. It is for temporary swap wallets only. // Call this function at most once from a single go process. func (c *walletClient) CloseAndRemoveWallet() { c.Close() // Just log any file removal errors, as there is nothing useful the caller can do // with the errors if err := os.Remove(c.conf.WalletFilePath); err != nil { log.Errorf("Failed to remove wallet file %q: %s", c.conf.WalletFilePath, err) } if err := os.Remove(c.conf.WalletFilePath + ".keys"); err != nil { log.Errorf("Failed to remove wallet keys file %q: %s", c.conf.WalletFilePath, err) } if err := os.Remove(c.conf.WalletFilePath + ".address.txt"); err != nil { // .address.txt doesn't always exist, only log if it existed and we failed if !errors.Is(err, fs.ErrNotExist) { log.Errorf("Failed to remove wallet address file %q: %s", c.conf.WalletFilePath, err) } } } func findWorkingNode(env common.Environment, nodes []*common.MoneroNode) (*common.MoneroNode, error) { if len(nodes) == 0 { return nil, errors.New("no monero nodes") } var err error for _, n := range nodes { err = validateMonerodNode(env, n) if err != nil { log.Warnf("Non-working node: %s", err) continue } return n, nil } // err is non-nil if we get here return nil, fmt.Errorf("failed to validate any monerod RPC node, last error: %w", err) } // validateMonerodNode validates the monerod node before we launch monero-wallet-rpc, as // doing the pre-checks creates more obvious error messages and faster failure. func validateMonerodNode(env common.Environment, node *common.MoneroNode) error { endpoint := fmt.Sprintf("http://%s:%d/json_rpc", node.Host, node.Port) daemonCli := monerorpc.New(endpoint, nil).Daemon info, err := daemonCli.GetInfo() if err != nil { return fmt.Errorf("could not validate monerod endpoint %s: %w", endpoint, err) } switch env { case common.Stagenet: if !info.Stagenet { return fmt.Errorf("monerod endpoint %s is not a stagenet node", endpoint) } case common.Mainnet: if !info.Mainnet { return fmt.Errorf("monerod endpoint %s is not a mainnet node", endpoint) } case common.Development: if info.NetType != "fakechain" { return fmt.Errorf("monerod endpoint %s should have a network type of \"fakechain\" in dev mode", endpoint) } default: panic("unhandled environment type") } if env != common.Development && info.Offline { return fmt.Errorf("monerod endpoint %s is offline", endpoint) } if !info.Synchronized { return fmt.Errorf("monerod endpoint %s is not synchronised", endpoint) } return nil } // createWalletRPCService starts a monero-wallet-rpc instance. Default values are assigned // to the MonerodHost, MonerodPort, WalletPort and LogPath fields of the config if they // are not already set. func createWalletRPCService( env common.Environment, walletRPCBinPath string, walletPort uint, walletDir string, logFilePath string, moneroNode *common.MoneroNode, ) (*os.Process, error) { walletRPCBinArgs := getWalletRPCFlags(env, walletPort, walletDir, logFilePath, moneroNode) proc, err := launchMoneroWalletRPCChild(walletRPCBinPath, walletRPCBinArgs...) if err != nil { return nil, fmt.Errorf("%w, see %s for details", err, logFilePath) } return proc, nil } // getMoneroWalletRPCBin returns the monero-wallet-rpc binary. It first looks for // "./monero-bin/monero-wallet-rpc". If not found, it then looks for "monero-wallet-rpc" // in the user's path. func getMoneroWalletRPCBin() (string, error) { execName := "monero-wallet-rpc" priorityPath := path.Join("monero-bin", execName) execPath, err := exec.LookPath(priorityPath) if err == nil { return execPath, nil } if !errors.Is(err, os.ErrNotExist) { return "", err } // search for the executable in the user's PATH return exec.LookPath(execName) } // getSysProcAttr returns SysProcAttr values that will work on all platforms, but this // function is overwritten on Linux and FreeBSD. var getSysProcAttr = func() *syscall.SysProcAttr { return &syscall.SysProcAttr{} } func launchMoneroWalletRPCChild(walletRPCBin string, walletRPCBinArgs ...string) (*os.Process, error) { cmd := exec.Command(walletRPCBin, walletRPCBinArgs...) pRead, pWrite, err := os.Pipe() if err != nil { return nil, err } cmd.Stdout = pWrite cmd.Stderr = pWrite // Last entry wins if an environment variable is in the list multiple times. // We parse some output, so we want to force English. NO_COLOR=1 failed to // remove ansi colour escapes, but setting TERM=dumb succeeded. cmd.Env = append(os.Environ(), "LANG=C", "LC_ALL=C", "TERM=dumb") cmd.SysProcAttr = getSysProcAttr() err = cmd.Start() // The writing side of the pipe will remain open in the child process after we close it // here, and the reading side will get an EOF after the last writing side closes. We // need to close the parent writing side after starting the child, in order for the child // to inherit the pipe's file descriptor for Stdout/Stderr. We can't close it in a defer // statement, because we need the scanner below to get EOF if the child process exits // on error. _ = pWrite.Close() // Handle err from cmd.Start() above if err != nil { return nil, err } log.Debugf("Started monero-wallet-rpc with PID=%d", cmd.Process.Pid) scanner := bufio.NewScanner(pRead) started := false // Loop terminates when the child process exits or when we get the message that the RPC server started for scanner.Scan() { line := scanner.Text() // Skips the first 3 lines of boilerplate output, but logs the version after it if line != "This is the RPC monero wallet. It needs to connect to a monero" && line != "daemon to work correctly." && line != "" { log.Info(moneroWalletRPCLogPrefix, line) } if strings.HasSuffix(line, "Starting wallet RPC server") { started = true break } } if !started { _, _ = cmd.Process.Wait() // shouldn't block, process already exited return nil, errors.New("failed to start monero-wallet-rpc") } time.Sleep(200 * time.Millisecond) // additional start time // Drain additional output. We are not detaching monero-wallet-rpc so it will // die when we exit. This has the downside that logs are sent both to the // monero-wallet-rpc.log file and to standard output. go func() { for scanner.Scan() { // We could log here, but it's noisy and we have a separate log file with // full logs. A future version could parse the log messages and send some // filtered subset to swapd's logs. } log.Warnf("monero-wallet-rpc pid=%d exited", cmd.Process.Pid) }() return cmd.Process, nil } // getWalletRPCFlags returns the flags used when launching monero-wallet-rpc func getWalletRPCFlags( env common.Environment, walletPort uint, walletDir string, logFilePath string, moneroNode *common.MoneroNode, ) []string { args := []string{ "--rpc-bind-ip=127.0.0.1", fmt.Sprintf("--rpc-bind-port=%d", walletPort), "--disable-rpc-login", // TODO: Enable this? fmt.Sprintf("--wallet-dir=%s", walletDir), fmt.Sprintf("--log-file=%s", logFilePath), "--log-level=0", fmt.Sprintf("--daemon-host=%s", moneroNode.Host), fmt.Sprintf("--daemon-port=%d", moneroNode.Port), } switch env { case common.Development: // See https://github.com/monero-project/monero/issues/8600 args = append(args, "--allow-mismatched-daemon-version") case common.Mainnet: // do nothing case common.Stagenet: args = append(args, "--stagenet") default: panic("unhandled monero environment type") } return args }