// Copyright 2023 The AthanorLabs/atomic-swap Authors // SPDX-License-Identifier: LGPL-3.0-only package monero import ( "context" "os" "path" "testing" "time" logging "github.com/ipfs/go-log/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero" ) var moneroWalletRPCPath = path.Join("..", "monero-bin", "monero-wallet-rpc") func init() { _ = logging.SetLogLevel("monero", "debug") } func TestClient_Transfer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() transferAmt := coins.MoneroToPiconero(coins.StrToDecimal("10")) transferAmtPlusFees := coins.MoneroToPiconero(coins.StrToDecimal("10.01")) cXMRMaker := CreateWalletClient(t) MineMinXMRBalance(t, cXMRMaker, transferAmtPlusFees) balanceBob := GetBalance(t, cXMRMaker) t.Logf("Bob's initial balance: bal=%s XMR, unlocked=%s XMR, blocks-to-unlock=%d", coins.FmtPiconeroAsXMR(balanceBob.Balance), coins.FmtPiconeroAsXMR(balanceBob.UnlockedBalance), balanceBob.BlocksToUnlock) transferAmtU64, err := transferAmt.Uint64() require.NoError(t, err) require.Greater(t, balanceBob.UnlockedBalance, transferAmtU64) kpA, err := mcrypto.GenerateKeys() require.NoError(t, err) kpB, err := mcrypto.GenerateKeys() require.NoError(t, err) abAddress := mcrypto.SumSpendAndViewKeys(kpA.PublicKeyPair(), kpB.PublicKeyPair()).Address(common.Development) vkABPriv := mcrypto.SumPrivateViewKeys(kpA.ViewKey(), kpB.ViewKey()) // Transfer from Bob's account to the Alice+Bob swap account transfer, err := cXMRMaker.Transfer(ctx, abAddress, 0, transferAmt, MinSpendConfirmations) require.NoError(t, err) t.Logf("Bob sent %s (+fee %s) XMR to A+B address with TX ID %s", coins.FmtPiconeroAsXMR(transfer.Amount), coins.FmtPiconeroAsXMR(transfer.Fee), transfer.TxID) require.NoError(t, err) require.GreaterOrEqual(t, transfer.Confirmations, uint64(MinSpendConfirmations)) t.Logf("Bob's TX was mined at height %d with %d confirmations", transfer.Height, transfer.Confirmations) cXMRMaker.Close() // Done with bob, make sure no one uses him again cXMRMaker = nil // Establish Alice's primary wallet alicePrimaryWallet := "test-swap-wallet" cXMRTaker, err := NewWalletClient(&WalletClientConf{ Env: common.Development, WalletFilePath: path.Join(t.TempDir(), "wallet", alicePrimaryWallet), MoneroWalletRPCPath: moneroWalletRPCPath, }) require.NoError(t, err) alicePrimaryAddr := cXMRTaker.PrimaryAddress() // Alice generates a view-only wallet for A+B to confirm that Bob sent the funds conf := cXMRTaker.CreateWalletConf("alice-view-wallet-to-verify-funds") abViewCli, err := CreateViewOnlyWalletFromKeys(conf, vkABPriv, abAddress, transfer.Height) require.NoError(t, err) defer abViewCli.CloseAndRemoveWallet() balanceABWal := GetBalance(t, abViewCli) height, err := abViewCli.GetHeight() require.NoError(t, err) t.Logf("A+B View-Only wallet balance: bal=%s unlocked=%s blocks-to-unlock=%d, cur-height=%d", coins.FmtPiconeroAsXMR(balanceABWal.Balance), coins.FmtPiconeroAsXMR(balanceABWal.UnlockedBalance), balanceABWal.BlocksToUnlock, height) require.Zero(t, balanceABWal.BlocksToUnlock) require.Equal(t, balanceABWal.Balance, balanceABWal.UnlockedBalance) require.Equal(t, transferAmtU64, balanceABWal.UnlockedBalance) // At this point Alice has received the key from Bob to create an A+B spend wallet. // She'll now sweep the funds from the A+B spend wallet into her primary wallet. abWalletKeyPair := mcrypto.NewPrivateKeyPair( mcrypto.SumPrivateSpendKeys(kpA.SpendKey(), kpB.SpendKey()), mcrypto.SumPrivateViewKeys(kpA.ViewKey(), kpB.ViewKey()), ) require.NoError(t, err) conf = abViewCli.CreateWalletConf("alice-spend-wallet-to-claim") abSpendCli, err := CreateSpendWalletFromKeys(conf, abWalletKeyPair, transfer.Height) require.NoError(t, err) defer abSpendCli.CloseAndRemoveWallet() require.Equal(t, abSpendCli.PrimaryAddress(), abViewCli.PrimaryAddress()) balanceABWal = GetBalance(t, abSpendCli) // Verify that the spend wallet, like the view-only wallet, has the exact amount expected in it require.Equal(t, transferAmtU64, balanceABWal.UnlockedBalance) // Alice transfers from A+B spend wallet to her primary wallet's address transfers, err := abSpendCli.SweepAll(ctx, alicePrimaryAddr, 0, SweepToSelfConfirmations) require.NoError(t, err) t.Logf("Alice swept AB wallet funds with %d transfers", len(transfers)) require.Len(t, transfers, 1) // In our case, it should always be a single transaction sweepAmount := transfers[0].Amount sweepFee := transfers[0].Fee t.Logf("Sweep of A+B wallet sent %s XMR with fees %s XMR to Alice's primary wallet", coins.FmtPiconeroAsXMR(sweepAmount), coins.FmtPiconeroAsXMR(sweepFee)) require.Equal(t, transferAmtU64, sweepAmount+sweepFee) t.Logf("Alice's sweep transactions was mined at height %d with %d confirmations", transfer.Height, transfer.Confirmations) // Verify zero balance of A+B wallet after sweep balanceABWal = GetBalance(t, abSpendCli) require.Equal(t, balanceABWal.Balance, uint64(0)) balanceAlice := GetBalance(t, cXMRTaker) t.Logf("Alice's primary wallet after sweep: bal=%s XMR, unlocked=%s XMR, blocks-to-unlock=%d", coins.FmtPiconeroAsXMR(balanceAlice.Balance), coins.FmtPiconeroAsXMR(balanceAlice.UnlockedBalance), balanceAlice.BlocksToUnlock) require.Equal(t, balanceAlice.Balance, sweepAmount) } func Test_walletClient_SweepAll_nothingToSweepReturnsError(t *testing.T) { emptyWallet := CreateWalletClient(t) takerWallet := CreateWalletClient(t) addrResp, err := takerWallet.GetAddress(0) require.NoError(t, err) destAddr, err := mcrypto.NewAddress(addrResp.Address, common.Development) require.NoError(t, err) _, err = emptyWallet.SweepAll(context.Background(), destAddr, 0, SweepToSelfConfirmations) require.ErrorContains(t, err, "no balance to sweep") } func TestClient_CloseAndRemoveWallet(t *testing.T) { password := t.Name() walletPath := path.Join(t.TempDir(), "wallet", "test-wallet") c, err := NewWalletClient(&WalletClientConf{ Env: common.Development, WalletFilePath: walletPath, WalletPassword: password, MoneroWalletRPCPath: moneroWalletRPCPath, }) require.NoError(t, err) info, err := os.Stat(walletPath) require.NoError(t, err) assert.Greater(t, info.Size(), int64(0)) c.CloseAndRemoveWallet() _, err = os.Stat(walletPath) require.ErrorIs(t, err, os.ErrNotExist) } func TestClient_GetAccounts(t *testing.T) { c, err := NewWalletClient(&WalletClientConf{ Env: common.Development, WalletFilePath: path.Join(t.TempDir(), "wallet", "test-wallet"), MoneroWalletRPCPath: moneroWalletRPCPath, }) require.NoError(t, err) defer c.Close() resp, err := c.GetAccounts() require.NoError(t, err) require.Equal(t, 1, len(resp.SubaddressAccounts)) } func TestClient_GetHeight(t *testing.T) { c, err := NewWalletClient(&WalletClientConf{ Env: common.Development, WalletFilePath: path.Join(t.TempDir(), "wallet", "test-wallet"), MoneroWalletRPCPath: moneroWalletRPCPath, }) require.NoError(t, err) defer c.Close() walletHeight, err := c.GetHeight() require.NoError(t, err) chainHeight, err := c.(*walletClient).getChainHeight() require.NoError(t, err) require.GreaterOrEqual(t, chainHeight, walletHeight) require.LessOrEqual(t, chainHeight-walletHeight, uint64(2)) } func TestCallGenerateFromKeys(t *testing.T) { kp, err := mcrypto.GenerateKeys() require.NoError(t, err) c, err := NewWalletClient(&WalletClientConf{ Env: common.Development, WalletFilePath: path.Join(t.TempDir(), "wallet", "not-used"), MoneroWalletRPCPath: moneroWalletRPCPath, }) require.NoError(t, err) defer c.Close() height, err := c.GetHeight() require.NoError(t, err) addr, err := c.GetAddress(0) require.NoError(t, err) t.Logf("Address %s", addr.Address) // initial wallet automatically closed when a new wallet is opened err = c.(*walletClient).generateFromKeys( kp.SpendKey(), kp.ViewKey(), kp.PublicKeyPair().Address(common.Mainnet), height, "swap-deposit-wallet", "", ) require.NoError(t, err) addr, err = c.GetAddress(0) require.NoError(t, err) t.Logf("Address %s", addr.Address) } // this tests calling generateFromkeys passing an address derived in // a non-standard manner; ie. the public view key in the address doesn't // match the private view key passed in. func TestCallGenerateFromKeys_UnusualAddress(t *testing.T) { kp, err := mcrypto.GenerateKeys() require.NoError(t, err) kp2, err := mcrypto.GenerateKeys() require.NoError(t, err) // create keypair with priv spend key of kp, but a different priv view key // use the address of this keypair in the call to `generateFromKeys` kp3 := mcrypto.NewPrivateKeyPair(kp.SpendKey(), kp2.ViewKey()) address := kp3.PublicKeyPair().Address(common.Mainnet) t.Log("address", address) conf := &WalletClientConf{ Env: common.Development, WalletFilePath: path.Join(t.TempDir(), "wallet", "not-used"), MoneroWalletRPCPath: moneroWalletRPCPath, } err = conf.Fill() require.NoError(t, err) c, err := createWalletFromKeys( conf, 0, kp.SpendKey(), kp.ViewKey(), kp3.PublicKeyPair().Address(common.Mainnet), ) require.NoError(t, err) res, err := c.GetAddress(0) require.NoError(t, err) require.Equal(t, address.String(), res.Address) } func Test_getMoneroWalletRPCBin(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) defer os.Chdir(wd) os.Chdir("..") walletRPCPath, err := getMoneroWalletRPCBin() require.NoError(t, err) // monero-bin/monero-wallet-rpc should take precedence over any monero-wallet-rpc in // the user's path if the relative path to the binary exists. require.Equal(t, "monero-bin/monero-wallet-rpc", walletRPCPath) } func Test_validateMonerodConfigs_dev(t *testing.T) { env := common.Development node, err := findWorkingNode(env, common.ConfigDefaultsForEnv(env).MoneroNodes) require.NoError(t, err) require.NotNil(t, node) } func Test_validateMonerodConfigs_stagenet(t *testing.T) { env := common.Stagenet node, err := findWorkingNode(env, common.ConfigDefaultsForEnv(env).MoneroNodes) require.NoError(t, err) require.NotNil(t, node) } func Test_validateMonerodConfigs_mainnet(t *testing.T) { env := common.Mainnet node, err := findWorkingNode(env, common.ConfigDefaultsForEnv(env).MoneroNodes) require.NoError(t, err) require.NotNil(t, node) } func Test_validateMonerodConfig_misMatchedEnv(t *testing.T) { node := &common.MoneroNode{ Host: "127.0.0.1", Port: common.DefaultMoneroDaemonDevPort, } err := validateMonerodNode(common.Mainnet, node) require.Error(t, err) require.Contains(t, err.Error(), "is not a mainnet node") } func Test_validateMonerodConfig_invalidPort(t *testing.T) { nonUsedPort, err := common.GetFreeTCPPort() require.NoError(t, err) node := &common.MoneroNode{ Host: "127.0.0.1", Port: nonUsedPort, } err = validateMonerodNode(common.Development, node) require.Error(t, err) require.Contains(t, err.Error(), "connection refused") } func Test_walletClient_waitForConfirmations_contextCancelled(t *testing.T) { const amount = 10 const numConfirmations = 999999999 // won't be achieved before our context is cancelled minBal := coins.MoneroToPiconero(coins.StrToDecimal("10.01")) // add a little extra for fees destAddr, err := mcrypto.NewAddress(blockRewardAddress, common.Development) require.NoError(t, err) c := CreateWalletClient(t) MineMinXMRBalance(t, c, minBal) // Cancel the context after the transfer has started ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err = c.Transfer(ctx, destAddr, 0, coins.NewPiconeroAmount(amount), numConfirmations) require.ErrorIs(t, err, context.DeadlineExceeded) } func TestCreateWalletFromKeys(t *testing.T) { // First wallet is just to get the height and generate the config for the 2nd primaryCli, err := NewWalletClient(&WalletClientConf{ Env: common.Development, WalletFilePath: path.Join(t.TempDir(), "wallet", "not-used"), MoneroWalletRPCPath: moneroWalletRPCPath, }) require.NoError(t, err) defer primaryCli.Close() height, err := primaryCli.GetHeight() require.NoError(t, err) kp, err := mcrypto.GenerateKeys() require.NoError(t, err) conf := primaryCli.CreateWalletConf("ab-wallet") abCli, err := CreateSpendWalletFromKeys(conf, kp, height) require.NoError(t, err) defer abCli.CloseAndRemoveWallet() require.Equal(t, kp.PublicKeyPair().Address(common.Development).String(), abCli.PrimaryAddress().String()) }