diff --git a/tests/unit_tests/CMakeLists.txt b/tests/unit_tests/CMakeLists.txt
index 4e5eb8384..93aba5a23 100644
--- a/tests/unit_tests/CMakeLists.txt
+++ b/tests/unit_tests/CMakeLists.txt
@@ -105,6 +105,7 @@ set(unit_tests_sources
   is_hdd.cpp
   aligned.cpp
   rpc_version_str.cpp
+  x25519.cpp
   zmq_rpc.cpp)
 
 set(unit_tests_headers
diff --git a/tests/unit_tests/crypto.cpp b/tests/unit_tests/crypto.cpp
index cb9cf0f39..1c4841bb7 100644
--- a/tests/unit_tests/crypto.cpp
+++ b/tests/unit_tests/crypto.cpp
@@ -39,10 +39,8 @@ extern "C"
 #include "crypto/generators.h"
 #include "cryptonote_basic/cryptonote_basic_impl.h"
 #include "cryptonote_basic/merge_mining.h"
-#include "mx25519.h"
 #include "ringct/rctOps.h"
 #include "ringct/rctTypes.h"
-#include "string_tools.h"
 
 namespace
 {
@@ -58,6 +56,7 @@ namespace
     "6c7251d54154cfa92c173a0dd39c1f948b655970153799af2aeadc9ff1add0ea";
 
   template<typename T> void *addressof(T &t) { return &t; }
+  template<> void *addressof(crypto::secret_key &k) { return addressof(unwrap(unwrap(k))); }
 
   template<typename T>
   bool is_formatted()
@@ -73,62 +72,6 @@ namespace
     out << "BEGIN" << value << "END";  
     return out.str() == "BEGIN<" + std::string{expected, sizeof(T) * 2} + ">END";
   }
-
-  std::vector<const mx25519_impl*> get_available_mx25519_impls()
-  {
-    static constexpr const mx25519_type ALL_IMPL_TYPES[4] = {MX25519_TYPE_PORTABLE,
-    MX25519_TYPE_ARM64,
-    MX25519_TYPE_AMD64,
-    MX25519_TYPE_AMD64X};
-    static constexpr const size_t NUM_IMPLS = sizeof(ALL_IMPL_TYPES) / sizeof(ALL_IMPL_TYPES[0]);
-
-    std::vector<const mx25519_impl*> available_impls;
-    available_impls.reserve(NUM_IMPLS);
-    for (int i = 0; i < NUM_IMPLS; ++i)
-    {
-      const mx25519_type impl_type = ALL_IMPL_TYPES[i];
-      const mx25519_impl * const impl = mx25519_select_impl(impl_type);
-      if (nullptr == impl)
-        continue;
-      available_impls.push_back(impl);
-    }
-
-    return available_impls;
-  }
-
-  std::string get_name_of_mx25519_impl(const mx25519_impl* impl)
-  {
-#   define get_name_of_mx25519_impl_CASE(x) case x: return #x;
-    CHECK_AND_ASSERT_THROW_MES(impl != nullptr, "null impl");
-    const mx25519_type impl_type = mx25519_impl_type(impl);
-    switch (impl_type)
-    {
-    get_name_of_mx25519_impl_CASE(MX25519_TYPE_PORTABLE)
-    get_name_of_mx25519_impl_CASE(MX25519_TYPE_ARM64)
-    get_name_of_mx25519_impl_CASE(MX25519_TYPE_AMD64)
-    get_name_of_mx25519_impl_CASE(MX25519_TYPE_AMD64X)
-    default:
-      throw std::runtime_error("get name of mx25519 impl: unrecognized impl type");
-    }
-#   undef get_name_of_mx25519_impl_CASE
-  }
-
-  void dump_mx25519_impls(const std::vector<const mx25519_impl*> &impls)
-  {
-    std::cout << "Testing " << impls.size() << " mx25519 implementations:" << std::endl;
-    for (const mx25519_impl *impl : impls)
-      std::cout << "    - " << get_name_of_mx25519_impl(impl) << std::endl;
-  }
-}
-
-static inline bool operator==(const mx25519_pubkey &a, const mx25519_pubkey &b)
-{
-  return memcmp(&a, &b, sizeof(mx25519_pubkey)) == 0;
-}
-
-static inline bool operator!=(const mx25519_pubkey &a, const mx25519_pubkey &b)
-{
-  return !(a == b);
 }
 
 TEST(Crypto, Ostream)
@@ -402,225 +345,3 @@ TEST(Crypto, generator_consistency)
   // ringct/rctTypes.h
   ASSERT_TRUE(memcmp(H.data, rct::H.bytes, 32) == 0);
 }
-
-TEST(Crypto, x25519_impl_scmul_key_convergence)
-{
-  std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  if (available_impls.size() < 2)
-    return;
-
-  dump_mx25519_impls(available_impls);
-
-  mx25519_pubkey P_fixed;
-  epee::string_tools::hex_to_pod("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", P_fixed);
-
-  mx25519_pubkey P_random;
-  rct::key tmp = rct::pkGen();
-  edwards_bytes_to_x25519_vartime(P_random.data, tmp.bytes);
-
-  tmp = rct::skGen();
-  mx25519_privkey a;
-  memcpy(&a, &tmp, sizeof(a));
-
-  mx25519_pubkey res_fixed;
-  mx25519_pubkey res_random;
-
-  bool first = true;
-  for (const mx25519_impl *impl : available_impls)
-  {
-    mx25519_pubkey tmp_mx;
-    mx25519_scmul_key(impl, &tmp_mx, &a, &P_fixed);
-    if (!first)
-    {
-      EXPECT_EQ(res_fixed, tmp_mx);
-    }
-    
-    memcpy(&res_fixed, &tmp_mx, 32);
-
-    mx25519_scmul_key(impl, &tmp_mx, &a, &P_random);
-    if (!first)
-    {
-      EXPECT_EQ(res_random, tmp_mx);
-    }
-
-    memcpy(&res_random, &tmp_mx, 32);
-
-    first = false;
-  }
-}
-
-TEST(Crypto, x25519_secret_key_1_scmul_base)
-{
-  const crypto::secret_key one({crypto::ec_scalar{.data = {1}}});
-  const mx25519_pubkey B{.data={9}};
-
-  const std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  for (const mx25519_impl *impl : available_impls)
-  {
-    mx25519_pubkey B1;
-    mx25519_scmul_base(impl, &B1, reinterpret_cast<const mx25519_privkey*>(&one));
-
-    EXPECT_EQ(B, B1);
-    if (B1 != B)
-    {
-      std::cout << "Failure occurred with " << get_name_of_mx25519_impl(impl) << " impl" << std::endl;
-    }
-  }
-}
-
-TEST(Crypto, x25519_secret_key_2_scmul_base)
-{
-  const crypto::secret_key two({crypto::ec_scalar{.data = {2}}});
-  const rct::key G_doubled = rct::addKeys(rct::G, rct::G);
-  mx25519_pubkey B_doubled;
-  edwards_bytes_to_x25519_vartime(B_doubled.data, G_doubled.bytes);
-
-  const std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  for (const mx25519_impl *impl : available_impls)
-  {
-    mx25519_pubkey B2;
-    mx25519_scmul_base(impl, &B2, reinterpret_cast<const mx25519_privkey*>(&two));
-
-    EXPECT_EQ(B_doubled, B2);
-    if (B2 != B_doubled)
-    {
-      std::cout << "Failure occurred with " << get_name_of_mx25519_impl(impl) << " impl" << std::endl;
-    }
-  }
-}
-
-TEST(Crypto, x25519_secret_key_4_scmul_base)
-{
-  const crypto::secret_key four({crypto::ec_scalar{.data = {4}}});
-  const rct::key G_quad = rct::scalarmultBase({.bytes = {4}});
-  mx25519_pubkey B_quad;
-  edwards_bytes_to_x25519_vartime(B_quad.data, G_quad.bytes);
-
-  const std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  for (const mx25519_impl *impl : available_impls)
-  {
-    mx25519_pubkey B4;
-    mx25519_scmul_base(impl, &B4, reinterpret_cast<const mx25519_privkey*>(&four));
-
-    EXPECT_EQ(B_quad, B4);
-    if (B4 != B_quad)
-    {
-      std::cout << "Failure occurred with " << get_name_of_mx25519_impl(impl) << " impl" << std::endl;
-    }
-  }
-}
-
-TEST(Crypto, x25519_secret_key_8_scmul_base)
-{
-  const crypto::secret_key eight({crypto::ec_scalar{.data = {8}}});
-  const rct::key G_oct = rct::scalarmultBase({.bytes = {8}});
-  mx25519_pubkey B_oct;
-  edwards_bytes_to_x25519_vartime(B_oct.data, G_oct.bytes);
-
-  const std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  for (const mx25519_impl *impl : available_impls)
-  {
-    mx25519_pubkey B8;
-    mx25519_scmul_base(impl, &B8, reinterpret_cast<const mx25519_privkey*>(&eight));
-
-    EXPECT_EQ(B_oct, B8);
-    if (B8 != B_oct)
-    {
-      std::cout << "Failure occurred with " << get_name_of_mx25519_impl(impl) << " impl" << std::endl;
-    }
-  }
-}
-
-TEST(Crypto, ConvertPointE_Base)
-{
-  const crypto::public_key G = crypto::get_G();
-  const mx25519_pubkey B_expected = {{9}};
-
-  mx25519_pubkey B_actual;
-  edwards_bytes_to_x25519_vartime(B_actual.data, to_bytes(G));
-
-  EXPECT_EQ(B_expected, B_actual);
-}
-
-TEST(Crypto, ConvertPointE_PreserveScalarMultBase)
-{
-  const std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  for (const mx25519_impl *impl : available_impls)
-  {
-    // *clamped* private key a
-    const crypto::secret_key a = rct::rct2sk(rct::skGen());
-    rct::key a_key;
-    memcpy(&a_key, &a, sizeof(rct::key));
-
-    // P_ed = a G
-    const rct::key P_edward = rct::scalarmultBase(a_key);
-
-    // P_mont = a B
-    mx25519_pubkey P_mont;
-    mx25519_scmul_base(impl, &P_mont, reinterpret_cast<const mx25519_privkey*>(&a));
-
-    // P_mont' = ConvertPointE(P_ed)
-    mx25519_pubkey P_mont_converted;
-    edwards_bytes_to_x25519_vartime(P_mont_converted.data, P_edward.bytes);
-
-    // P_mont' ?= P_mont
-    EXPECT_EQ(P_mont_converted, P_mont);
-  }
-}
-
-TEST(Crypto, ConvertPointE_PreserveScalarMultBase_gep3)
-{
-  // compared to ConvertPointE_PreserveScalarMultBase, this test will use Z != 1 (probably)
-
-  const std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
-
-  for (const mx25519_impl *impl : available_impls)
-  {
-    const crypto::secret_key a = rct::rct2sk(rct::skGen());
-    rct::key a_key;
-    memcpy(&a_key, &a, sizeof(rct::key));
-
-    // P_ed = a G
-    ge_p3 P_p3;
-    ge_scalarmult_base(&P_p3, to_bytes(a));
-
-    // check that Z != 1, otherwise this test is a dup of ConvertPointE_PreserveScalarMultBase
-    rct::key Z_bytes;
-    fe_tobytes(Z_bytes.bytes, P_p3.Z);
-    ASSERT_NE(Z_bytes, rct::I); // check Z != 1
-
-    // P_mont = a B
-    mx25519_pubkey P_mont;
-    mx25519_scmul_base(impl, &P_mont, reinterpret_cast<const mx25519_privkey*>(&a));
-
-    // P_mont' = ConvertPointE(P_ed)
-    mx25519_pubkey P_mont_converted;
-    ge_p3_to_x25519(P_mont_converted.data, &P_p3);
-
-    // P_mont' ?= P_mont
-    EXPECT_EQ(P_mont_converted, P_mont);
-  }
-}
-
-TEST(Crypto, ConvertPointE_EraseSign)
-{
-  // generate a random point P and test that ConvertPointE(P) == ConvertPointE(-P)
-
-  const rct::key P = rct::pkGen();
-  rct::key negP;
-  rct::subKeys(negP, rct::I, P);
-
-  mx25519_pubkey P_mont;
-  edwards_bytes_to_x25519_vartime(P_mont.data, P.bytes);
-
-  mx25519_pubkey negP_mont;
-  edwards_bytes_to_x25519_vartime(negP_mont.data, negP.bytes);
-
-  EXPECT_EQ(P_mont, negP_mont);
-}
diff --git a/tests/unit_tests/x25519.cpp b/tests/unit_tests/x25519.cpp
new file mode 100644
index 000000000..30b640eec
--- /dev/null
+++ b/tests/unit_tests/x25519.cpp
@@ -0,0 +1,205 @@
+// Copyright (c) 2017-2024, The Monero Project
+//
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification, are
+// permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this list of
+//    conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice, this list
+//    of conditions and the following disclaimer in the documentation and/or other
+//    materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its contributors may be
+//    used to endorse or promote products derived from this software without specific
+//    prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include <cstring>
+#include <gtest/gtest.h>
+#include <vector>
+
+#include "common/container_helpers.h"
+extern "C"
+{
+#include "crypto/crypto-ops.h"
+}
+#include "crypto/generators.h"
+#include "misc_log_ex.h"
+#include "mx25519.h"
+#include "ringct/rctOps.h"
+#include "string_tools.h"
+
+namespace
+{
+  static std::vector<const mx25519_impl*> get_available_mx25519_impls()
+  {
+    static constexpr const mx25519_type ALL_IMPL_TYPES[4] = {MX25519_TYPE_PORTABLE,
+    MX25519_TYPE_ARM64,
+    MX25519_TYPE_AMD64,
+    MX25519_TYPE_AMD64X};
+    static constexpr const size_t NUM_IMPLS = sizeof(ALL_IMPL_TYPES) / sizeof(ALL_IMPL_TYPES[0]);
+
+    std::vector<const mx25519_impl*> available_impls;
+    available_impls.reserve(NUM_IMPLS);
+    for (int i = 0; i < NUM_IMPLS; ++i)
+    {
+      const mx25519_type impl_type = ALL_IMPL_TYPES[i];
+      const mx25519_impl * const impl = mx25519_select_impl(impl_type);
+      if (nullptr == impl)
+        continue;
+      available_impls.push_back(impl);
+    }
+
+    return available_impls;
+  }
+
+  static std::string get_name_of_mx25519_impl(const mx25519_impl* impl)
+  {
+#   define get_name_of_mx25519_impl_CASE(x) case x: return #x;
+    CHECK_AND_ASSERT_THROW_MES(impl != nullptr, "null impl");
+    const mx25519_type impl_type = mx25519_impl_type(impl);
+    switch (impl_type)
+    {
+    get_name_of_mx25519_impl_CASE(MX25519_TYPE_PORTABLE)
+    get_name_of_mx25519_impl_CASE(MX25519_TYPE_ARM64)
+    get_name_of_mx25519_impl_CASE(MX25519_TYPE_AMD64)
+    get_name_of_mx25519_impl_CASE(MX25519_TYPE_AMD64X)
+    default:
+      throw std::runtime_error("get name of mx25519 impl: unrecognized impl type");
+    }
+#   undef get_name_of_mx25519_impl_CASE
+  }
+
+  void dump_mx25519_impls(const std::vector<const mx25519_impl*> &impls)
+  {
+    std::cout << "Testing " << impls.size() << " mx25519 implementations:" << std::endl;
+    for (const mx25519_impl *impl : impls)
+      std::cout << "    - " << get_name_of_mx25519_impl(impl) << std::endl;
+  }
+
+  template <typename T>
+  static T hex2pod(boost::string_ref s)
+  {
+    T v;
+    if (!epee::string_tools::hex_to_pod(s, v))
+      throw std::runtime_error("hex2pod conversion failed");
+    return v;
+  }
+} // namespace
+
+static inline bool operator==(const mx25519_pubkey &a, const mx25519_pubkey &b)
+{
+  return memcmp(&a, &b, sizeof(mx25519_pubkey)) == 0;
+}
+
+static inline bool operator!=(const mx25519_pubkey &a, const mx25519_pubkey &b)
+{
+  return !(a == b);
+}
+
+TEST(x25519, scmul_key_convergence)
+{
+  std::vector<const mx25519_impl*> available_impls = get_available_mx25519_impls();
+
+  ASSERT_GT(available_impls.size(), 0);
+
+  dump_mx25519_impls(available_impls);
+
+  std::vector<mx25519_privkey> scalars;
+  for (int i = 0; i <= 254; ++i)
+  {
+    for (unsigned char j = 0; j < 8; ++j)
+    {
+      // add 2^i + j (sometimes with duplicates, which is okay)
+      mx25519_privkey &s = tools::add_element(scalars);
+      memset(s.data, 0, sizeof(mx25519_privkey));
+      const int msb_byte_index = i >> 3;
+      const int msb_bit_index = i & 7;
+      s.data[msb_byte_index] = 1 << msb_bit_index;
+      s.data[0] = j;
+    }
+  }
+  // add -1
+  scalars.push_back(hex2pod<mx25519_privkey>("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"));
+  // add random
+  const rct::key a = rct::skGen();
+  memcpy(tools::add_element(scalars).data, &a, sizeof(mx25519_privkey));
+
+  std::vector<std::pair<rct::key, mx25519_pubkey>> points;
+  // add base point
+  points.push_back({rct::G, mx25519_pubkey{.data={9}}});
+  // add RFC 7784 test point
+  points.push_back({
+    hex2pod<rct::key>("8120f299c37ae1ca64a179f638a6c6fafde968f1c33705e28c413c7579d9884f"),
+    hex2pod<mx25519_pubkey>("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")
+  });
+  // add random point
+  const rct::key P_random = rct::pkGen();
+  mx25519_pubkey P_random_x;
+  edwards_bytes_to_x25519_vartime(P_random_x.data, P_random.bytes);
+  points.push_back({P_random, P_random_x});
+
+  for (const mx25519_privkey &scalar : scalars)
+  {
+    for (const auto &point : points)
+    {
+      // D1 = ConvertPointE(a * P_base)
+      ge_p3 P_ed;
+      ASSERT_EQ(0, ge_frombytes_vartime(&P_ed, point.first.bytes));
+      ge_p3 res_p3;
+      ge_scalarmult_p3(&res_p3, scalar.data, &P_ed);
+      mx25519_pubkey res;
+      ge_p3_to_x25519(res.data, &res_p3);
+
+      for (const mx25519_impl *impl : available_impls)
+      {
+        // D2 = a * D_base
+        mx25519_pubkey res_mx;
+        mx25519_scmul_key(impl, &res_mx, &scalar, &point.second);
+
+        // D1 ?= D2
+        EXPECT_EQ(res, res_mx);
+      }
+    }
+  }
+}
+
+TEST(x25519, ConvertPointE_Base)
+{
+  const crypto::public_key G = crypto::get_G();
+  const mx25519_pubkey B_expected = {{9}};
+
+  mx25519_pubkey B_actual;
+  edwards_bytes_to_x25519_vartime(B_actual.data, to_bytes(G));
+
+  EXPECT_EQ(B_expected, B_actual);
+}
+
+TEST(x25519, ConvertPointE_EraseSign)
+{
+  // generate a random point P and test that ConvertPointE(P) == ConvertPointE(-P)
+
+  const rct::key P = rct::pkGen();
+  rct::key negP;
+  rct::subKeys(negP, rct::I, P);
+
+  mx25519_pubkey P_mont;
+  edwards_bytes_to_x25519_vartime(P_mont.data, P.bytes);
+
+  mx25519_pubkey negP_mont;
+  edwards_bytes_to_x25519_vartime(negP_mont.data, negP.bytes);
+
+  EXPECT_EQ(P_mont, negP_mont);
+}