diff --git a/src/sv2/CMakeLists.txt b/src/sv2/CMakeLists.txt
index d6e44842e8c877..e61f2f3560834c 100644
--- a/src/sv2/CMakeLists.txt
+++ b/src/sv2/CMakeLists.txt
@@ -4,6 +4,7 @@
 
 add_library(bitcoin_sv2 STATIC EXCLUDE_FROM_ALL
   noise.cpp
+  transport.cpp
 )
 
 target_link_libraries(bitcoin_sv2
diff --git a/src/sv2/transport.cpp b/src/sv2/transport.cpp
new file mode 100644
index 00000000000000..37a6e36ba19e0a
--- /dev/null
+++ b/src/sv2/transport.cpp
@@ -0,0 +1,494 @@
+// Copyright (c) 2023-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <sv2/transport.h>
+
+#include <logging.h>
+#include <memusage.h>
+#include <sv2/messages.h>
+#include <sv2/noise.h>
+#include <random.h>
+#include <util/check.h>
+#include <util/strencodings.h>
+#include <util/vector.h>
+
+Sv2Transport::Sv2Transport(CKey static_key, Sv2SignatureNoiseMessage certificate) noexcept
+    : m_cipher{Sv2Cipher(std::move(static_key), std::move(certificate))}, m_initiating{false},
+      m_recv_state{RecvState::HANDSHAKE_STEP_1},
+      m_send_state{SendState::HANDSHAKE_STEP_2},
+      m_message{Sv2NetMsg(Sv2NetHeader{})}
+{
+    LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Noise session receive state -> %s\n",
+                    RecvStateAsString(m_recv_state));
+}
+
+Sv2Transport::Sv2Transport(CKey static_key, XOnlyPubKey responder_authority_key) noexcept
+    : m_cipher{Sv2Cipher(std::move(static_key), responder_authority_key)}, m_initiating{true},
+      m_recv_state{RecvState::HANDSHAKE_STEP_2},
+      m_send_state{SendState::HANDSHAKE_STEP_1},
+      m_message{Sv2NetMsg(Sv2NetHeader{})}
+{
+    /** Start sending immediately since we're the initiator of the connection.
+        This only happens in test code.
+    */
+    LOCK(m_send_mutex);
+    StartSendingHandshake();
+
+}
+
+void Sv2Transport::SetReceiveState(RecvState recv_state) noexcept
+{
+    AssertLockHeld(m_recv_mutex);
+    // Enforce allowed state transitions.
+    switch (m_recv_state) {
+    case RecvState::HANDSHAKE_STEP_1:
+        Assume(recv_state == RecvState::HANDSHAKE_STEP_2);
+        break;
+    case RecvState::HANDSHAKE_STEP_2:
+        Assume(recv_state == RecvState::APP);
+        break;
+    case RecvState::APP:
+        Assume(recv_state == RecvState::APP_READY);
+        break;
+    case RecvState::APP_READY:
+        Assume(recv_state == RecvState::APP);
+        break;
+    }
+    // Change state.
+    m_recv_state = recv_state;
+    LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Noise session receive state -> %s\n",
+                  RecvStateAsString(m_recv_state));
+
+}
+
+void Sv2Transport::SetSendState(SendState send_state) noexcept
+{
+    AssertLockHeld(m_send_mutex);
+    // Enforce allowed state transitions.
+    switch (m_send_state) {
+    case SendState::HANDSHAKE_STEP_1:
+        Assume(send_state == SendState::HANDSHAKE_STEP_2);
+        break;
+    case SendState::HANDSHAKE_STEP_2:
+        Assume(send_state == SendState::READY);
+        break;
+    case SendState::READY:
+        Assume(false); // Final state
+        break;
+    }
+    // Change state.
+    m_send_state = send_state;
+    LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Noise session send state -> %s\n",
+                  SendStateAsString(m_send_state));
+}
+
+void Sv2Transport::StartSendingHandshake() noexcept
+{
+    AssertLockHeld(m_send_mutex);
+    AssertLockNotHeld(m_recv_mutex);
+    Assume(m_send_state == SendState::HANDSHAKE_STEP_1);
+    Assume(m_send_buffer.empty());
+
+    m_send_buffer.resize(Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+    m_cipher.GetHandshakeState().WriteMsgEphemeralPK(MakeWritableByteSpan(m_send_buffer));
+
+    m_send_state = SendState::HANDSHAKE_STEP_2;
+}
+
+void Sv2Transport::SendHandshakeReply() noexcept
+{
+    AssertLockHeld(m_send_mutex);
+    AssertLockHeld(m_recv_mutex);
+    Assume(m_send_state == SendState::HANDSHAKE_STEP_2);
+
+    Assume(m_send_buffer.empty());
+    m_send_buffer.resize(Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+    m_cipher.GetHandshakeState().WriteMsgES(MakeWritableByteSpan(m_send_buffer));
+
+    m_cipher.FinishHandshake();
+
+    // We can send and receive stuff now, unless the other side hangs up
+    SetSendState(SendState::READY);
+    Assume(m_recv_state == RecvState::HANDSHAKE_STEP_2);
+    SetReceiveState(RecvState::APP);
+}
+
+Transport::BytesToSend Sv2Transport::GetBytesToSend(bool have_next_message) const noexcept
+{
+    AssertLockNotHeld(m_send_mutex);
+    LOCK(m_send_mutex);
+
+    const std::string dummy_m_type; // m_type is set to "" when wrapping Sv2NetMsg
+
+    Assume(m_send_pos <= m_send_buffer.size());
+    return {
+        Span{m_send_buffer}.subspan(m_send_pos),
+        // We only have more to send after the current m_send_buffer if there is a (next)
+        // message to be sent, and we're capable of sending packets. */
+        have_next_message && m_send_state == SendState::READY,
+        dummy_m_type
+    };
+}
+
+void Sv2Transport::MarkBytesSent(size_t bytes_sent) noexcept
+{
+    AssertLockNotHeld(m_send_mutex);
+    LOCK(m_send_mutex);
+
+    // if (m_send_state == SendState::AWAITING_KEY && m_send_pos == 0 && bytes_sent > 0) {
+    //     LogPrint(BCLog::NET, "start sending v2 handshake to peer=%d\n", m_nodeid);
+    // }
+
+    m_send_pos += bytes_sent;
+    Assume(m_send_pos <= m_send_buffer.size());
+    // Wipe the buffer when everything is sent.
+    if (m_send_pos == m_send_buffer.size()) {
+        m_send_pos = 0;
+        ClearShrink(m_send_buffer);
+    }
+}
+
+bool Sv2Transport::SetMessageToSend(CSerializedNetMsg& msg) noexcept
+{
+    AssertLockNotHeld(m_send_mutex);
+    LOCK(m_send_mutex);
+
+    // We only allow adding a new message to be sent when in the READY state (so the packet cipher
+    // is available) and the send buffer is empty. This limits the number of messages in the send
+    // buffer to just one, and leaves the responsibility for queueing them up to the caller.
+    if (m_send_state != SendState::READY) {
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "SendState is not READY\n");
+        return false;
+    }
+
+    if (!m_send_buffer.empty()) {
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Send buffer is not empty\n");
+        return false;
+    }
+
+    // The Sv2NetMsg is wrapped inside a dummy CSerializedNetMsg, extract it:
+    Sv2NetMsg sv2_msg(std::move(msg));
+    // Reconstruct the header:
+    Sv2NetHeader hdr(sv2_msg.m_msg_type, sv2_msg.size());
+
+    // Construct ciphertext in send buffer.
+    const size_t encrypted_msg_size = Sv2Cipher::EncryptedMessageSize(sv2_msg.size());
+    m_send_buffer.resize(SV2_HEADER_ENCRYPTED_SIZE + encrypted_msg_size);
+    Span<std::byte> buffer_span{MakeWritableByteSpan(m_send_buffer)};
+
+    // Header
+    DataStream ss_header_plain{};
+    ss_header_plain << hdr;
+    LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Header: %s\n", HexStr(ss_header_plain));
+    Span<std::byte> header_encrypted{buffer_span.subspan(0, SV2_HEADER_ENCRYPTED_SIZE)};
+    if (!m_cipher.EncryptMessage(ss_header_plain, header_encrypted)) {
+        return false;
+    }
+
+    // Payload
+    Span<const std::byte> payload_plain = MakeByteSpan(sv2_msg);
+    // TODO: truncate very long messages, about 100 bytes at the start and end
+    //       is probably enough for most debugging.
+    // LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Payload: %s\n", HexStr(payload_plain));
+    Span<std::byte> payload_encrypted{buffer_span.subspan(SV2_HEADER_ENCRYPTED_SIZE, encrypted_msg_size)};
+    if (!m_cipher.EncryptMessage(payload_plain, payload_encrypted)) {
+        return false;
+    }
+
+    // Release memory (not needed with std::move above)
+    // ClearShrink(msg.data);
+
+    return true;
+}
+
+size_t Sv2Transport::GetSendMemoryUsage() const noexcept
+{
+    AssertLockNotHeld(m_send_mutex);
+    LOCK(m_send_mutex);
+
+    return sizeof(m_send_buffer) + memusage::DynamicUsage(m_send_buffer);
+}
+
+bool Sv2Transport::ReceivedBytes(Span<const uint8_t>& msg_bytes) noexcept
+{
+    AssertLockNotHeld(m_send_mutex);
+    AssertLockNotHeld(m_recv_mutex);
+    /** How many bytes to allocate in the receive buffer at most above what is received so far. */
+    static constexpr size_t MAX_RESERVE_AHEAD = 256 * 1024; // TODO: reduce to NOISE_MAX_CHUNK_SIZE?
+
+    LOCK(m_recv_mutex);
+    // Process the provided bytes in msg_bytes in a loop. In each iteration a nonzero number of
+    // bytes (decided by GetMaxBytesToProcess) are taken from the beginning om msg_bytes, and
+    // appended to m_recv_buffer. Then, depending on the receiver state, one of the
+    // ProcessReceived*Bytes functions is called to process the bytes in that buffer.
+    while (!msg_bytes.empty()) {
+        // Decide how many bytes to copy from msg_bytes to m_recv_buffer.
+        size_t max_read = GetMaxBytesToProcess();
+
+        // Reserve space in the buffer if there is not enough.
+        if (m_recv_buffer.size() + std::min(msg_bytes.size(), max_read) > m_recv_buffer.capacity()) {
+            switch (m_recv_state) {
+            case RecvState::HANDSHAKE_STEP_1:
+                m_recv_buffer.reserve(Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+                break;
+            case RecvState::HANDSHAKE_STEP_2:
+                m_recv_buffer.reserve(Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+                break;
+            case RecvState::APP: {
+                // During states where a packet is being received, as much as is expected but never
+                // more than MAX_RESERVE_AHEAD bytes in addition to what is received so far.
+                // This means attackers that want to cause us to waste allocated memory are limited
+                // to MAX_RESERVE_AHEAD above the largest allowed message contents size, and to
+                // MAX_RESERVE_AHEAD more than they've actually sent us.
+                size_t alloc_add = std::min(max_read, msg_bytes.size() + MAX_RESERVE_AHEAD);
+                m_recv_buffer.reserve(m_recv_buffer.size() + alloc_add);
+                break;
+            }
+            case RecvState::APP_READY:
+                // The buffer is empty in this state.
+                Assume(m_recv_buffer.empty());
+                break;
+            }
+        }
+
+        // Can't read more than provided input.
+        max_read = std::min(msg_bytes.size(), max_read);
+        // Copy data to buffer.
+        m_recv_buffer.insert(m_recv_buffer.end(), UCharCast(msg_bytes.data()), UCharCast(msg_bytes.data() + max_read));
+        msg_bytes = msg_bytes.subspan(max_read);
+
+        // Process data in the buffer.
+        switch (m_recv_state) {
+
+        case RecvState::HANDSHAKE_STEP_1:
+            if (!ProcessReceivedEphemeralKeyBytes()) return false;
+            break;
+
+        case RecvState::HANDSHAKE_STEP_2:
+            if (!ProcessReceivedHandshakeReplyBytes()) return false;
+            break;
+
+        case RecvState::APP:
+            if (!ProcessReceivedPacketBytes()) return false;
+            break;
+
+        case RecvState::APP_READY:
+            return true;
+
+        }
+        // Make sure we have made progress before continuing.
+        Assume(max_read > 0);
+    }
+
+    return true;
+}
+
+bool Sv2Transport::ProcessReceivedEphemeralKeyBytes() noexcept
+{
+    AssertLockHeld(m_recv_mutex);
+    AssertLockNotHeld(m_send_mutex);
+    Assume(m_recv_state == RecvState::HANDSHAKE_STEP_1);
+    Assume(m_recv_buffer.size() <= Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+
+    if (m_recv_buffer.size() == Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE) {
+        // Other side's key has been fully received, and can now be Diffie-Hellman
+        // combined with our key. This is act 1 of the Noise Protocol handshake.
+        // TODO handle failure
+        // TODO: MakeByteSpan instead of MakeWritableByteSpan
+        m_cipher.GetHandshakeState().ReadMsgEphemeralPK(MakeWritableByteSpan(m_recv_buffer));
+        m_recv_buffer.clear();
+        SetReceiveState(RecvState::HANDSHAKE_STEP_2);
+
+        LOCK(m_send_mutex);
+        Assume(m_send_buffer.size() == 0);
+
+        // Send our act 2 handshake
+        SendHandshakeReply();
+    } else {
+        // We still have to receive more key bytes.
+    }
+    return true;
+}
+
+bool Sv2Transport::ProcessReceivedHandshakeReplyBytes() noexcept
+{
+    AssertLockHeld(m_recv_mutex);
+    AssertLockNotHeld(m_send_mutex);
+    Assume(m_recv_state == RecvState::HANDSHAKE_STEP_2);
+    Assume(m_recv_buffer.size() <= Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+
+    if (m_recv_buffer.size() == Sv2HandshakeState::HANDSHAKE_STEP2_SIZE) {
+        // TODO handle failure
+        // TODO: MakeByteSpan instead of MakeWritableByteSpan
+        bool res = m_cipher.GetHandshakeState().ReadMsgES(MakeWritableByteSpan(m_recv_buffer));
+        if (!res) return false;
+        m_recv_buffer.clear();
+        m_cipher.FinishHandshake();
+        SetReceiveState(RecvState::APP);
+
+        LOCK(m_send_mutex);
+        Assume(m_send_buffer.size() == 0);
+
+        SetSendState(SendState::READY);
+    } else {
+        // We still have to receive more key bytes.
+    }
+    return true;
+}
+
+size_t Sv2Transport::GetMaxBytesToProcess() noexcept
+{
+    AssertLockHeld(m_recv_mutex);
+    switch (m_recv_state) {
+    case RecvState::HANDSHAKE_STEP_1:
+        // In this state, we only allow the 64-byte key into the receive buffer.
+        Assume(m_recv_buffer.size() <= Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+        return Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE - m_recv_buffer.size();
+    case RecvState::HANDSHAKE_STEP_2:
+        // In this state, we only allow the handshake reply into the receive buffer.
+        Assume(m_recv_buffer.size() <= Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+        return Sv2HandshakeState::HANDSHAKE_STEP2_SIZE - m_recv_buffer.size();
+    case RecvState::APP:
+        // Decode a packet. Process the header first,
+        // so that we know where the current packet ends (and we don't process bytes from the next
+        // packet yet). Then, process the ciphertext bytes of the current packet.
+        if (m_recv_buffer.size() < SV2_HEADER_ENCRYPTED_SIZE) {
+            return SV2_HEADER_ENCRYPTED_SIZE - m_recv_buffer.size();
+        } else {
+            // When transitioning from receiving the packet length to receiving its ciphertext,
+            // the encrypted header is left in the receive buffer.
+            size_t expanded_size_with_header = SV2_HEADER_ENCRYPTED_SIZE + Sv2Cipher::EncryptedMessageSize(m_header.m_msg_len);
+            return expanded_size_with_header - m_recv_buffer.size();
+        }
+    case RecvState::APP_READY:
+        // No bytes can be processed until GetMessage() is called.
+        return 0;
+    }
+    Assume(false); // unreachable
+    return 0;
+}
+
+bool Sv2Transport::ProcessReceivedPacketBytes() noexcept
+{
+    AssertLockHeld(m_recv_mutex);
+    Assume(m_recv_state == RecvState::APP);
+
+    // The maximum permitted decrypted payload size for a packet
+    static constexpr size_t MAX_CONTENTS_LEN = 16777215; // 24 bit unsigned;
+
+    Assume(m_recv_buffer.size() <= SV2_HEADER_ENCRYPTED_SIZE || m_header.m_msg_len > 0);
+
+    if (m_recv_buffer.size() == SV2_HEADER_ENCRYPTED_SIZE) {
+        // Header received, decrypt it.
+        std::array<std::byte, SV2_HEADER_PLAIN_SIZE> header_plain;
+        if  (!m_cipher.DecryptMessage(MakeWritableByteSpan(m_recv_buffer), header_plain)) {
+            LogPrintLevel(BCLog::SV2, BCLog::Level::Debug, "Failed to decrypt header\n");
+            return false;
+        }
+
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Header: %s\n", HexStr(header_plain));
+
+        // Decode header
+        DataStream ss_header{header_plain};
+        node::Sv2NetHeader header;
+        ss_header >> header;
+        m_header = std::move(header);
+
+        // TODO: 16 MB is pretty large, maybe set lower limits for most or all message types?
+        if (m_header.m_msg_len > MAX_CONTENTS_LEN) {
+            LogTrace(BCLog::SV2, "Packet too large (%u bytes)\n", m_header.m_msg_len);
+            return false;
+        }
+
+        // Disconnect for empty messages (TODO: check the spec)
+        if (m_header.m_msg_len == 0) {
+            LogTrace(BCLog::SV2, "Empty message\n");
+            return false;
+        }
+        LogTrace(BCLog::SV2, "Expecting %d bytes payload (plain)\n", m_header.m_msg_len);
+    } else if (m_recv_buffer.size() > SV2_HEADER_ENCRYPTED_SIZE &&
+               m_recv_buffer.size() == SV2_HEADER_ENCRYPTED_SIZE + Sv2Cipher::EncryptedMessageSize(m_header.m_msg_len)) {
+        /** Ciphertext received: decrypt into decode_buffer and deserialize into m_message.
+          *
+          * Note that it is impossible to reach this branch without hitting the
+          * branch above first, as GetMaxBytesToProcess only allows up to
+          * SV2_HEADER_ENCRYPTED_SIZE into the buffer before that point. */
+        std::vector<std::uint8_t> payload;
+        payload.resize(m_header.m_msg_len);
+
+        Span<std::byte> recv_span{MakeWritableByteSpan(m_recv_buffer).subspan(SV2_HEADER_ENCRYPTED_SIZE)};
+        if (!m_cipher.DecryptMessage(recv_span, MakeWritableByteSpan(payload))) {
+            LogPrintLevel(BCLog::SV2, BCLog::Level::Debug, "Failed to decrypt message payload\n");
+            return false;
+        }
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Payload: %s\n", HexStr(payload));
+
+        // Wipe the receive buffer where the next packet will be received into.
+        ClearShrink(m_recv_buffer);
+
+        Sv2NetMsg message{m_header.m_msg_type, std::move(payload)};
+        m_message = std::move(message);
+
+        // At this point we have a valid message decrypted into m_message.
+        SetReceiveState(RecvState::APP_READY);
+    } else {
+        // We either have less than 22 bytes, so we don't know the packet's length yet, or more
+        // than 22 bytes but less than the packet's full ciphertext. Wait until those arrive.
+        LogTrace(BCLog::SV2, "Waiting for more bytes\n");
+    }
+    return true;
+}
+
+bool Sv2Transport::ReceivedMessageComplete() const noexcept
+{
+    AssertLockNotHeld(m_recv_mutex);
+    LOCK(m_recv_mutex);
+
+    return m_recv_state == RecvState::APP_READY;
+}
+
+CNetMessage Sv2Transport::GetReceivedMessage(std::chrono::microseconds time, bool& reject_message) noexcept
+{
+    AssertLockNotHeld(m_recv_mutex);
+    LOCK(m_recv_mutex);
+    Assume(m_recv_state == RecvState::APP_READY);
+
+    SetReceiveState(RecvState::APP);
+    return m_message; // Sv2NetMsg is wrapped in a CNetMessage
+}
+
+Transport::Info Sv2Transport::GetInfo() const noexcept
+{
+    return {.transport_type = TransportProtocolType::V1, .session_id = {}};
+}
+
+std::string RecvStateAsString(Sv2Transport::RecvState state)
+{
+    switch (state) {
+    case Sv2Transport::RecvState::HANDSHAKE_STEP_1:
+        return "HANDSHAKE_STEP_1";
+    case Sv2Transport::RecvState::HANDSHAKE_STEP_2:
+        return "HANDSHAKE_STEP_2";
+    case Sv2Transport::RecvState::APP:
+        return "APP";
+    case Sv2Transport::RecvState::APP_READY:
+        return "APP_READY";
+    } // no default case, so the compiler can warn about missing cases
+
+    assert(false);
+}
+
+std::string SendStateAsString(Sv2Transport::SendState state)
+{
+    switch (state) {
+    case Sv2Transport::SendState::HANDSHAKE_STEP_1:
+        return "HANDSHAKE_STEP_1";
+    case Sv2Transport::SendState::HANDSHAKE_STEP_2:
+        return "HANDSHAKE_STEP_2";
+    case Sv2Transport::SendState::READY:
+        return "READY";
+    } // no default case, so the compiler can warn about missing cases
+
+    assert(false);
+}
diff --git a/src/sv2/transport.h b/src/sv2/transport.h
new file mode 100644
index 00000000000000..b8948e71a9f420
--- /dev/null
+++ b/src/sv2/transport.h
@@ -0,0 +1,194 @@
+// Copyright (c) 2023-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#ifndef BITCOIN_SV2_TRANSPORT_H
+#define BITCOIN_SV2_TRANSPORT_H
+
+#include <common/transport.h>
+#include <sv2/messages.h>
+#include <sv2/noise.h>
+#include <sync.h>
+
+static constexpr size_t SV2_HEADER_PLAIN_SIZE{6};
+static constexpr size_t SV2_HEADER_ENCRYPTED_SIZE{SV2_HEADER_PLAIN_SIZE + Poly1305::TAGLEN};
+
+using node::Sv2NetHeader;
+using node::Sv2NetMsg;
+
+class Sv2Transport final : public Transport
+{
+public:
+
+    // The sender side and receiver side of Sv2Transport are state machines that are transitioned
+    // through, based on what has been received. The receive state corresponds to the contents of,
+    // and bytes received to, the receive buffer. The send state controls what can be appended to
+    // the send buffer and what can be sent from it.
+
+    /** State type that defines the current contents of the receive buffer and/or how the next
+     *  received bytes added to it will be interpreted.
+     *
+     * Diagram:
+     *
+     *   start(responder)
+     *        |              start(initiator)
+     *        |                     |            /---------\
+     *        |                     |            |         |
+     *        v                     v            v         |
+     *  HANDSHAKE_STEP_1 -> HANDSHAKE_STEP_2 -> APP -> APP_READY
+     */
+    enum class RecvState : uint8_t {
+        /** Handshake Act 1: -> E */
+        HANDSHAKE_STEP_1,
+
+        /** Handshake Act 2: <- e, ee, s, es, SIGNATURE_NOISE_MESSAGE */
+        HANDSHAKE_STEP_2,
+
+        /** Application packet.
+         *
+         * A packet is received, and decrypted/verified. If that succeeds, the
+         * state becomes APP_READY and the decrypted message is kept in m_message
+         * until it is retrieved by GetMessage(). */
+        APP,
+
+        /** Nothing (an application packet is available for GetMessage()).
+         *
+         * Nothing can be received in this state. When the message is retrieved
+         * by GetMessage(), the state becomes APP again. */
+        APP_READY,
+    };
+
+    /** State type that controls the sender side.
+     *
+     * Diagram:
+     *
+     *   start(initiator)
+     *        |             start(responder)
+     *        |                  |
+     *        |                  |
+     *        v                  v
+     *  HANDSHAKE_STEP_1 -> HANDSHAKE_STEP_2 -> READY
+     */
+    enum class SendState : uint8_t {
+        /** Handshake Act 1: -> E */
+        HANDSHAKE_STEP_1,
+
+        /** Handshake Act 2: <- e, ee, s, es, SIGNATURE_NOISE_MESSAGE */
+        HANDSHAKE_STEP_2,
+
+        /** Normal sending state.
+         *
+         * In this state, the ciphers are initialized, so packets can be sent.
+         * In this state a message can be provided if the send buffer is empty. */
+        READY,
+    };
+
+private:
+
+    /** Cipher state. */
+    Sv2Cipher m_cipher;
+
+    /** Whether we are the initiator side. */
+    const bool m_initiating;
+
+    /** Lock for receiver-side fields. */
+    mutable Mutex m_recv_mutex ACQUIRED_BEFORE(m_send_mutex);
+    /** Receive buffer; meaning is determined by m_recv_state. */
+    std::vector<uint8_t> m_recv_buffer GUARDED_BY(m_recv_mutex);
+    /** AAD expected in next received packet (currently used only for garbage). */
+    std::vector<uint8_t> m_recv_aad GUARDED_BY(m_recv_mutex);
+    /** Current receiver state. */
+    RecvState m_recv_state GUARDED_BY(m_recv_mutex);
+
+    /** Lock for sending-side fields. If both sending and receiving fields are accessed,
+     *  m_recv_mutex must be acquired before m_send_mutex. */
+    mutable Mutex m_send_mutex ACQUIRED_AFTER(m_recv_mutex);
+    /** The send buffer; meaning is determined by m_send_state. */
+    std::vector<uint8_t> m_send_buffer GUARDED_BY(m_send_mutex);
+    /** How many bytes from the send buffer have been sent so far. */
+    uint32_t m_send_pos GUARDED_BY(m_send_mutex) {0};
+    /** The garbage sent, or to be sent (MAYBE_V1 and AWAITING_KEY state only). */
+    std::vector<uint8_t> m_send_garbage GUARDED_BY(m_send_mutex);
+    /** Type of the message being sent. */
+    std::string m_send_type GUARDED_BY(m_send_mutex);
+    /** Current sender state. */
+    SendState m_send_state GUARDED_BY(m_send_mutex);
+
+    /** Change the receive state. */
+    void SetReceiveState(RecvState recv_state) noexcept EXCLUSIVE_LOCKS_REQUIRED(m_recv_mutex);
+    /** Change the send state. */
+    void SetSendState(SendState send_state) noexcept EXCLUSIVE_LOCKS_REQUIRED(m_send_mutex);
+    /** Given a packet's contents, find the message type (if valid), and strip it from contents. */
+    static std::optional<std::string> GetMessageType(Span<const uint8_t>& contents) noexcept;
+    /** Determine how many received bytes can be processed in one go (not allowed in V1 state). */
+    size_t GetMaxBytesToProcess() noexcept EXCLUSIVE_LOCKS_REQUIRED(m_recv_mutex);
+    /** Put our ephemeral public key in the send buffer. */
+    void StartSendingHandshake() noexcept EXCLUSIVE_LOCKS_REQUIRED(m_send_mutex, !m_recv_mutex);
+    /** Put second part of the handshake in the send buffer. */
+    void SendHandshakeReply() noexcept EXCLUSIVE_LOCKS_REQUIRED(m_send_mutex, m_recv_mutex);
+    /** Process bytes in m_recv_buffer, while in HANDSHAKE_STEP_1 state. */
+    bool ProcessReceivedEphemeralKeyBytes() noexcept EXCLUSIVE_LOCKS_REQUIRED(m_recv_mutex, !m_send_mutex);
+    /** Process bytes in m_recv_buffer, while in HANDSHAKE_STEP_2 state. */
+    bool ProcessReceivedHandshakeReplyBytes() noexcept EXCLUSIVE_LOCKS_REQUIRED(m_recv_mutex, !m_send_mutex);
+
+    /** Process bytes in m_recv_buffer, while in VERSION/APP state. */
+    bool ProcessReceivedPacketBytes() noexcept EXCLUSIVE_LOCKS_REQUIRED(m_recv_mutex);
+
+    /** In APP, the decrypted header, if m_recv_buffer.size() >=
+    *  SV2_HEADER_ENCRYPTED_SIZE. Unspecified otherwise. */
+    Sv2NetHeader m_header GUARDED_BY(m_recv_mutex);
+    /* In APP_READY the last retrieved message. Unspecified otherwise */
+    Sv2NetMsg m_message GUARDED_BY(m_recv_mutex);
+
+public:
+    /** Construct a Stratum v2 transport as the initiator
+      *
+      * @param[in] static_key a securely generated key
+
+      */
+    Sv2Transport(CKey static_key, XOnlyPubKey responder_authority_key) noexcept;
+
+        /** Construct a Stratum v2 transport as the responder
+      *
+      * @param[in] static_key a securely generated key
+
+      */
+    Sv2Transport(CKey static_key, Sv2SignatureNoiseMessage certificate) noexcept;
+
+    // Receive side functions.
+    bool ReceivedMessageComplete() const noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_recv_mutex);
+    bool ReceivedBytes(Span<const uint8_t>& msg_bytes) noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_recv_mutex, !m_send_mutex);
+
+    CNetMessage GetReceivedMessage(std::chrono::microseconds time, bool& reject_message) noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_recv_mutex);
+
+    // Send side functions.
+    bool SetMessageToSend(CSerializedNetMsg& msg) noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex);
+
+    BytesToSend GetBytesToSend(bool have_next_message) const noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex);
+
+    void MarkBytesSent(size_t bytes_sent) noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex);
+    size_t GetSendMemoryUsage() const noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex);
+
+    // Miscellaneous functions.
+    bool ShouldReconnectV1() const noexcept override { return false; };
+    Info GetInfo() const noexcept override EXCLUSIVE_LOCKS_REQUIRED(!m_recv_mutex);
+
+    // Test only
+    uint256 NoiseHash() const { return m_cipher.GetHash(); };
+    RecvState GetRecvState() EXCLUSIVE_LOCKS_REQUIRED(!m_recv_mutex) {
+        AssertLockNotHeld(m_recv_mutex);
+        LOCK(m_recv_mutex);
+        return  m_recv_state;
+    };
+    SendState GetSendState() EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex) {
+        AssertLockNotHeld(m_send_mutex);
+        LOCK(m_send_mutex);
+        return  m_send_state;
+    };
+};
+
+/** Convert TransportProtocolType enum to a string value */
+std::string RecvStateAsString(Sv2Transport::RecvState state);
+std::string SendStateAsString(Sv2Transport::SendState state);
+
+#endif // BITCOIN_SV2_TRANSPORT_H
diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt
index 83ad6b5cbf3aa4..4196a417005b6b 100644
--- a/src/test/CMakeLists.txt
+++ b/src/test/CMakeLists.txt
@@ -179,6 +179,7 @@ if(WITH_SV2)
   target_sources(test_bitcoin
   PRIVATE
     sv2_noise_tests.cpp
+    sv2_transport_tests.cpp
   )
   target_link_libraries(test_bitcoin bitcoin_sv2)
 endif()
diff --git a/src/test/sv2_transport_tests.cpp b/src/test/sv2_transport_tests.cpp
new file mode 100644
index 00000000000000..c4c7da2cfd3580
--- /dev/null
+++ b/src/test/sv2_transport_tests.cpp
@@ -0,0 +1,389 @@
+// Copyright (c) 2023-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <sv2/noise.h>
+#include <sv2/transport.h>
+#include <logging.h>
+#include <serialize.h>
+#include <span.h>
+#include <streams.h>
+#include <test/util/random.h>
+#include <test/util/setup_common.h>
+#include <node/timeoffsets.h>
+#include <util/bitdeque.h>
+#include <util/strencodings.h>
+#include <util/string.h>
+
+#include <boost/test/unit_test.hpp>
+
+#include <algorithm>
+#include <ios>
+#include <memory>
+#include <optional>
+#include <string>
+
+using namespace std::literals;
+using node::Sv2NetMsg;
+using node::Sv2CoinbaseOutputConstraintsMsg;
+using node::Sv2MsgType;
+
+BOOST_FIXTURE_TEST_SUITE(sv2_transport_tests, RegTestingSetup)
+
+namespace {
+
+/** A class for scenario-based tests of Sv2Transport
+ *
+ * Each Sv2TransportTester encapsulates a Sv2Transport (the one being tested),
+ * and can be told to interact with it. To do so, it also encapsulates a Sv2Cipher
+ * to act as the other side. A second Sv2Transport is not used, as doing so would
+ * not permit scenarios that involve sending invalid data.
+ */
+class Sv2TransportTester
+{
+    FastRandomContext& m_rng;
+    std::unique_ptr<Sv2Transport> m_transport; //!< Sv2Transport being tested
+    std::unique_ptr<Sv2Cipher> m_peer_cipher;  //!< Cipher to help with the other side
+    bool m_test_initiator;    //!< Whether m_transport is the initiator (true) or responder (false)
+
+    std::vector<uint8_t> m_to_send;  //!< Bytes we have queued up to send to m_transport->
+    std::vector<uint8_t> m_received; //!< Bytes we have received from m_transport->
+    std::deque<Sv2NetMsg> m_msg_to_send; //!< Messages to be sent *by* m_transport to us.
+
+public:
+    /** Construct a tester object. test_initiator: whether the tested transport is initiator. */
+
+    explicit Sv2TransportTester(FastRandomContext& rng, bool test_initiator) : m_rng{rng}, m_test_initiator(test_initiator)
+    {
+        auto initiator_static_key{GenerateRandomKey()};
+        auto responder_static_key{GenerateRandomKey()};
+        auto responder_authority_key{GenerateRandomKey()};
+
+        // Create certificates
+        auto epoch_now = std::chrono::system_clock::now().time_since_epoch();
+        uint16_t version = 0;
+        uint32_t valid_from = static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::seconds>(epoch_now).count());
+        uint32_t valid_to =  std::numeric_limits<unsigned int>::max();
+
+        auto responder_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                    XOnlyPubKey(responder_static_key.GetPubKey()), responder_authority_key);
+
+        if (test_initiator) {
+            m_transport = std::make_unique<Sv2Transport>(initiator_static_key, XOnlyPubKey(responder_authority_key.GetPubKey()));
+            m_peer_cipher = std::make_unique<Sv2Cipher>(std::move(responder_static_key), std::move(responder_certificate));
+        } else {
+            m_transport = std::make_unique<Sv2Transport>(responder_static_key, responder_certificate);
+            m_peer_cipher = std::make_unique<Sv2Cipher>(std::move(initiator_static_key), XOnlyPubKey(responder_authority_key.GetPubKey()));
+        }
+    }
+
+    /** Data type returned by Interact:
+     *
+     * - std::nullopt: transport error occurred
+     * - otherwise: a vector of
+     *   - std::nullopt: invalid message received
+     *   - otherwise: a Sv2NetMsg retrieved
+     */
+    using InteractResult = std::optional<std::vector<std::optional<Sv2NetMsg>>>;
+
+    void LogProgress(bool should_progress, bool progress, bool pretend_no_progress) {
+        if (!should_progress) {
+            BOOST_TEST_MESSAGE("[Interact] !should_progress");
+        } else if  (!progress) {
+            BOOST_TEST_MESSAGE("[Interact] should_progress && !progress");
+        } else if (pretend_no_progress) {
+            BOOST_TEST_MESSAGE("[Interact] pretend !progress");
+        }
+    }
+
+    /** Send/receive scheduled/available bytes and messages.
+     *
+     * This is the only function that interacts with the transport being tested; everything else is
+     * scheduling things done by Interact(), or processing things learned by it.
+     */
+    InteractResult Interact()
+    {
+        std::vector<std::optional<Sv2NetMsg>> ret;
+        while (true) {
+            bool progress{false};
+            // Send bytes from m_to_send to the transport.
+            if (!m_to_send.empty()) {
+                size_t n_bytes_to_send = 1 + m_rng.randrange(m_to_send.size());
+                BOOST_TEST_MESSAGE(strprintf("[Interact] send %d of %d bytes", n_bytes_to_send, m_to_send.size()));
+                Span<const uint8_t> to_send = Span{m_to_send}.first(n_bytes_to_send);
+                size_t old_len = to_send.size();
+                if (!m_transport->ReceivedBytes(to_send)) {
+                    BOOST_TEST_MESSAGE("[Interact] transport error");
+                    return std::nullopt;
+                }
+                if (old_len != to_send.size()) {
+                    progress = true;
+                    m_to_send.erase(m_to_send.begin(), m_to_send.begin() + (old_len - to_send.size()));
+                }
+            }
+            // Retrieve messages received by the transport.
+            bool should_progress = m_transport->ReceivedMessageComplete();
+            bool pretend_no_progress = m_rng.randbool();
+            LogProgress(should_progress, progress, pretend_no_progress);
+            if (should_progress && (!progress || pretend_no_progress)) {
+                bool dummy_reject_message = false;
+                CNetMessage net_msg = m_transport->GetReceivedMessage(std::chrono::microseconds(0), dummy_reject_message);
+                Sv2NetMsg msg(std::move(net_msg));
+                ret.emplace_back(std::move(msg));
+                progress = true;
+            }
+            // Enqueue a message to be sent by the transport to us.
+            should_progress = !m_msg_to_send.empty();
+            pretend_no_progress = m_rng.randbool();
+            LogProgress(should_progress, progress, pretend_no_progress);
+            if (should_progress && (!progress || pretend_no_progress)) {
+                BOOST_TEST_MESSAGE("Shoehorn into CSerializedNetMsg");
+                CSerializedNetMsg msg{m_msg_to_send.front()};
+                BOOST_TEST_MESSAGE("Call SetMessageToSend");
+                if (m_transport->SetMessageToSend(msg)) {
+                    BOOST_TEST_MESSAGE("Finished SetMessageToSend");
+                    m_msg_to_send.pop_front();
+                    progress = true;
+                }
+            }
+            // Receive bytes from the transport.
+            const auto& [recv_bytes, _more, _m_type] = m_transport->GetBytesToSend(!m_msg_to_send.empty());
+            should_progress = !recv_bytes.empty();
+            pretend_no_progress = m_rng.randbool();
+            LogProgress(should_progress, progress, pretend_no_progress);
+            if (should_progress && (!progress || pretend_no_progress)) {
+                size_t to_receive = 1 + m_rng.randrange(recv_bytes.size());
+                BOOST_TEST_MESSAGE(strprintf("[Interact] receive %d of %d bytes", to_receive, recv_bytes.size()));
+                m_received.insert(m_received.end(), recv_bytes.begin(), recv_bytes.begin() + to_receive);
+                progress = true;
+                m_transport->MarkBytesSent(to_receive);
+            }
+            if (!progress) break;
+        }
+        return ret;
+    }
+
+    /** Schedule bytes to be sent to the transport. */
+    void Send(Span<const uint8_t> data)
+    {
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Send: %s\n", HexStr(data));
+        m_to_send.insert(m_to_send.end(), data.begin(), data.end());
+    }
+
+    /** Schedule bytes to be sent to the transport. */
+    void Send(Span<const std::byte> data) { Send(MakeUCharSpan(data)); }
+
+    /** Schedule a message to be sent to us by the transport. */
+    void AddMessage(Sv2NetMsg msg)
+    {
+        m_msg_to_send.push_back(std::move(msg));
+    }
+
+    /**
+     * If we are the initiator, the send buffer should contain our ephemeral public
+     * key. Pass this to the peer cipher and clear the buffer.
+     *
+     * If we are the responder, put the peer ephemeral public key on our receive buffer.
+     */
+    void ProcessHandshake1() {
+        if (m_test_initiator) {
+            BOOST_REQUIRE(m_received.size() == Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+            m_peer_cipher->GetHandshakeState().ReadMsgEphemeralPK(MakeWritableByteSpan(m_received));
+            m_received.clear();
+        } else {
+            BOOST_REQUIRE(m_to_send.empty());
+            m_to_send.resize(Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+            m_peer_cipher->GetHandshakeState().WriteMsgEphemeralPK(MakeWritableByteSpan(m_to_send));
+        }
+
+    }
+
+    /** Expect key to have been received from transport and process it.
+     *
+     * Many other Sv2TransportTester functions cannot be called until after
+     * ProcessHandshake2() has been called, as no encryption keys are set up before that point.
+     */
+    void ProcessHandshake2()
+    {
+        if (m_test_initiator) {
+            BOOST_REQUIRE(m_to_send.empty());
+
+            // Have the peer cypher write the second part of the handshake into our receive buffer
+            m_to_send.resize(Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+            m_peer_cipher->GetHandshakeState().WriteMsgES(MakeWritableByteSpan(m_to_send));
+
+            // At this point the peer is done with the handshake:
+            m_peer_cipher->FinishHandshake();
+        } else {
+            BOOST_REQUIRE(m_received.size() == Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+            BOOST_REQUIRE(m_peer_cipher->GetHandshakeState().ReadMsgES(MakeWritableByteSpan(m_received)));
+            m_received.clear();
+
+            m_peer_cipher->FinishHandshake();
+        }
+    }
+
+    /** Schedule an encrypted packet with specified content to be sent to transport
+     *  (only after ReceiveKey). */
+    void SendPacket(Sv2NetMsg msg)
+    {
+        // TODO: randomly break stuff
+
+        std::vector<std::byte> ciphertext;
+        const size_t encrypted_payload_size = Sv2Cipher::EncryptedMessageSize(msg.size());
+        ciphertext.resize(SV2_HEADER_ENCRYPTED_SIZE + encrypted_payload_size);
+        Span<std::byte> buffer_span{MakeWritableByteSpan(ciphertext)};
+
+        // Header
+        DataStream ss_header_plain{};
+        ss_header_plain << Sv2NetHeader(msg);
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Header: %s\n", HexStr(ss_header_plain));
+        Span<std::byte> header_encrypted{buffer_span.subspan(0, SV2_HEADER_ENCRYPTED_SIZE)};
+        BOOST_REQUIRE(m_peer_cipher->EncryptMessage(ss_header_plain, header_encrypted));
+
+        // Payload
+        Span<const std::byte> payload_plain = MakeByteSpan(msg);
+        // TODO: truncate very long messages, about 100 bytes at the start and end
+        //       is probably enough for most debugging.
+        LogPrintLevel(BCLog::SV2, BCLog::Level::Trace, "Payload: %s\n", HexStr(payload_plain));
+        Span<std::byte> payload_encrypted{buffer_span.subspan(SV2_HEADER_ENCRYPTED_SIZE, encrypted_payload_size)};
+        BOOST_REQUIRE(m_peer_cipher->EncryptMessage(payload_plain, payload_encrypted));
+
+        // Schedule it for sending.
+        Send(ciphertext);
+    }
+
+    /** Expect application packet to have been received, with specified message type and payload.
+     *  (only after ReceiveKey). */
+    void ReceiveMessage(Sv2NetMsg expected_msg)
+    {
+        // When processing a packet, at least enough bytes for its length descriptor must be received.
+        BOOST_REQUIRE(m_received.size() >= SV2_HEADER_ENCRYPTED_SIZE);
+
+        auto header_encrypted{MakeWritableByteSpan(m_received).subspan(0, SV2_HEADER_ENCRYPTED_SIZE)};
+        std::array<std::byte, SV2_HEADER_PLAIN_SIZE> header_plain;
+        BOOST_REQUIRE(m_peer_cipher->DecryptMessage(header_encrypted, header_plain));
+
+        // Decode header
+        DataStream ss_header{header_plain};
+        node::Sv2NetHeader header;
+        ss_header >> header;
+
+        BOOST_CHECK(header.m_msg_type == expected_msg.m_msg_type);
+
+        size_t expanded_size = Sv2Cipher::EncryptedMessageSize(header.m_msg_len);
+        BOOST_REQUIRE(m_received.size() >= SV2_HEADER_ENCRYPTED_SIZE + expanded_size);
+
+        Span<std::byte> encrypted_payload{MakeWritableByteSpan(m_received).subspan(SV2_HEADER_ENCRYPTED_SIZE, expanded_size)};
+        Span<std::byte> payload = encrypted_payload.subspan(0, header.m_msg_len);
+
+        BOOST_REQUIRE(m_peer_cipher->DecryptMessage(encrypted_payload, payload));
+
+        std::vector<uint8_t> decode_buffer;
+        decode_buffer.resize(header.m_msg_len);
+
+        std::transform(payload.begin(), payload.end(), decode_buffer.begin(),
+                           [](std::byte b) { return static_cast<uint8_t>(b); });
+
+        // TODO: clear the m_received we used
+
+        Sv2NetMsg message{header.m_msg_type, std::move(decode_buffer)};
+
+        // TODO: compare payload
+    }
+
+    /** Test whether the transport's m_hash matches the other side. */
+    void CompareHash() const
+    {
+        BOOST_REQUIRE(m_transport);
+        BOOST_CHECK(m_transport->NoiseHash() == m_peer_cipher->GetHash());
+    }
+
+    void CheckRecvState(Sv2Transport::RecvState state) {
+        BOOST_REQUIRE(m_transport);
+        BOOST_CHECK_EQUAL(RecvStateAsString(m_transport->GetRecvState()), RecvStateAsString(state));
+    }
+
+    void CheckSendState(Sv2Transport::SendState state) {
+        BOOST_REQUIRE(m_transport);
+        BOOST_CHECK_EQUAL(SendStateAsString(m_transport->GetSendState()), SendStateAsString(state));
+    }
+
+    /** Introduce a bit error in the data scheduled to be sent. */
+    // void Damage()
+    // {
+    //     BOOST_TEST_MESSAGE("[Interact] introduce a bit error");
+    //     m_to_send[m_rng.randrange(m_to_send.size())] ^= (uint8_t{1} << m_rng.randrange(8));
+    // }
+};
+
+} // namespace
+
+BOOST_AUTO_TEST_CASE(sv2_transport_initiator_test)
+{
+    // A mostly normal scenario, testing a transport in initiator mode.
+    // Interact() introduces randomness, so run multiple times
+    for (int i = 0; i < 10; ++i) {
+        BOOST_TEST_MESSAGE(strprintf("\nIteration %d (initiator)", i));
+        Sv2TransportTester tester(m_rng, true);
+        // As the initiator, our ephemeral public key is immedidately put
+        // onto the buffer.
+        tester.CheckSendState(Sv2Transport::SendState::HANDSHAKE_STEP_2);
+        tester.CheckRecvState(Sv2Transport::RecvState::HANDSHAKE_STEP_2);
+        auto ret = tester.Interact();
+        BOOST_REQUIRE(ret && ret->empty());
+        tester.ProcessHandshake1();
+        ret = tester.Interact();
+        BOOST_REQUIRE(ret && ret->empty());
+        tester.ProcessHandshake2();
+        ret = tester.Interact();
+        BOOST_REQUIRE(ret && ret->empty());
+        tester.CheckSendState(Sv2Transport::SendState::READY);
+        tester.CheckRecvState(Sv2Transport::RecvState::APP);
+        tester.CompareHash();
+    }
+}
+
+BOOST_AUTO_TEST_CASE(sv2_transport_responder_test)
+{
+    // Normal scenario, with a transport in responder node.
+    for (int i = 0; i < 10; ++i) {
+        BOOST_TEST_MESSAGE(strprintf("\nIteration %d (responder)", i));
+        Sv2TransportTester tester(m_rng, false);
+        tester.CheckSendState(Sv2Transport::SendState::HANDSHAKE_STEP_2);
+        tester.CheckRecvState(Sv2Transport::RecvState::HANDSHAKE_STEP_1);
+        tester.ProcessHandshake1();
+        auto ret = tester.Interact();
+        BOOST_REQUIRE(ret && ret->empty());
+        tester.CheckSendState(Sv2Transport::SendState::READY);
+        tester.CheckRecvState(Sv2Transport::RecvState::APP);
+
+        // Have the test cypher process our handshake reply
+        tester.ProcessHandshake2();
+        tester.CompareHash();
+
+        // Handshake complete, have the initiator send us a message:
+        Sv2CoinbaseOutputConstraintsMsg body{4000, 400};
+        Sv2NetMsg msg{body};
+        BOOST_REQUIRE(msg.m_msg_type == Sv2MsgType::COINBASE_OUTPUT_CONSTRAINTS);
+
+        tester.SendPacket(msg);
+        ret = tester.Interact();
+        BOOST_REQUIRE(ret && ret->size() == 1);
+        BOOST_CHECK((*ret)[0] &&
+                    (*ret)[0]->m_msg_type == Sv2MsgType::COINBASE_OUTPUT_CONSTRAINTS);
+
+        tester.CompareHash();
+
+        // Send a message back to the initiator
+        tester.AddMessage(msg);
+        ret = tester.Interact();
+        BOOST_REQUIRE(ret && ret->size() == 0);
+        tester.ReceiveMessage(msg);
+
+        // TODO: send / receive message larger than the chunk size
+    }
+}
+
+
+BOOST_AUTO_TEST_SUITE_END()