Repository: btcsuite/btcd

Issue:
[bug]: panic on `ReadTaprootBip32Derivation`
When parsing a PSBT, just got a panic due to `makeslice: len out of range` in `ReadTaprootBip32Derivation`. Perhaps due to `LeafHashes: make([][]byte, int(numHashes))`: https://github.com/btcsuite/btcd/blob/1eb974aab6ef11097571f9517b4b6b0c639ab63b/btcutil/psbt/taproot.go#L118-L133

Test to reproduce:

```go
func TestPSBTCrash(t *testing.T) {
	hexString := "70736274ff01007374ff01030100000000002f0000002e2873007374ff01070100000000000000000000000000000000000000060680050000736274ff01000a0000000060c70006060000736274ff01000a000001000001002407010000000000000000000000000000000000000006060000736274ff01000a0000000000010024c760002a707362c760000b0500000000000000060605000073626274ff01000a00000000000100242121212121212121212121212121212121212121212121212121212121212121212121212107010000000000000000000000000000000000000006060000736274ff01000a000eff000001000a0a040404040404040400"
	// Convert hex string to byte slice
	buffer, err := hex.DecodeString(hexString)
	if err != nil {
		log.Fatalf("Failed to decode hex: %v", err)
	}
	psbt, err := NewFromRawBytes(bytes.NewBuffer(buffer), false)
	t.Logf("got transaction: %v", spew.Sdump(psbt.UnsignedTx))
}
```

Hints from issue comments:

Solution patch:
diff --git a/btcec/ellswift/ellswift.go b/btcec/ellswift/ellswift.go
new file mode 100644
index 00000000..e08e0d59
--- /dev/null
+++ b/btcec/ellswift/ellswift.go
@@ -0,0 +1,404 @@
+package ellswift
+
+import (
+	"crypto/rand"
+	"fmt"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/chaincfg/chainhash"
+)
+
+var (
+	// c is sqrt(-3) (mod p)
+	c btcec.FieldVal
+
+	cBytes = [32]byte{
+		0x0a, 0x2d, 0x2b, 0xa9, 0x35, 0x07, 0xf1, 0xdf,
+		0x23, 0x37, 0x70, 0xc2, 0xa7, 0x97, 0x96, 0x2c,
+		0xc6, 0x1f, 0x6d, 0x15, 0xda, 0x14, 0xec, 0xd4,
+		0x7d, 0x8d, 0x27, 0xae, 0x1c, 0xd5, 0xf8, 0x52,
+	}
+
+	ellswiftTag = []byte("bip324_ellswift_xonly_ecdh")
+
+	// ErrPointNotOnCurve is returned when we're unable to find a point on the
+	// curve.
+	ErrPointNotOnCurve = fmt.Errorf("point does not exist on secp256k1 curve")
+)
+
+func init() {
+	c.SetByteSlice(cBytes[:])
+}
+
+// XSwiftEC() takes two field elements (u, t) and gives us an x-coordinate that
+// is on the secp256k1 curve. This is used to take an ElligatorSwift-encoded
+// public key (u, t) and return the point on the curve it maps to. This
+// function returns an error if there is no valid x-coordinate.
+//
+// TODO: Rewrite these to avoid new(btcec.FieldVal).Add(...) usage?
+// NOTE: u, t MUST be normalized. The result x is normalized.
+func XSwiftEC(u, t *btcec.FieldVal) (*btcec.FieldVal, error) {
+	// 1. Let u' = u if u != 0, else = 1
+	if u.IsZero() {
+		u.SetInt(1)
+	}
+
+	// 2. Let t' = t if t != 0, else 1
+	if t.IsZero() {
+		t.SetInt(1)
+	}
+
+	// 3. Let t'' = t' if g(u') != -(t'^2); t'' = 2t' otherwise
+	// g(x) = x^3 + ax + b, a = 0, b = 7
+
+	// Calculate g(u').
+	gu := new(btcec.FieldVal).SquareVal(u).Mul(u).AddInt(7).Normalize()
+
+	// Calculate the right-hand side of the equation (-t'^2)
+	rhs := new(btcec.FieldVal).SquareVal(t).Negate(1).Normalize()
+
+	if gu.Equals(rhs) {
+		// t'' = 2t'
+		t = t.Add(t)
+	}
+
+	// 4. X = (u'^3 + b - t''^2) / (2t'')
+	tSquared := new(btcec.FieldVal).SquareVal(t).Negate(1)
+	xNum := new(btcec.FieldVal).SquareVal(u).Mul(u).AddInt(7).Add(tSquared)
+	xDenom := new(btcec.FieldVal).Add2(t, t).Inverse()
+	x := xNum.Mul(xDenom)
+
+	// 5. Y = (X+t'') / (u' * c)
+	yNum := new(btcec.FieldVal).Add2(x, t)
+	yDenom := new(btcec.FieldVal).Mul2(u, &c).Inverse()
+	y := yNum.Mul(yDenom)
+
+	// 6. Return the first x in (u'+4Y^2, -X/2Y - u'/2, X/2Y - u'/2) for which
+	//    x^3 + b is square.
+
+	// 6a. Calculate u' +4Y^2 and determine if x^3+7 is square.
+	ySqr := new(btcec.FieldVal).Add(y).Mul(y)
+	quadYSqr := new(btcec.FieldVal).Add(ySqr).MulInt(4)
+	firstX := new(btcec.FieldVal).Add(u).Add(quadYSqr)
+
+	// Determine if firstX is on the curve.
+	if isXOnCurve(firstX) {
+		return firstX.Normalize(), nil
+	}
+
+	// 6b. Calculate -X/2Y - u'/2 and determine if x^3 + 7 is square
+	doubleYInv := new(btcec.FieldVal).Add(y).Add(y).Inverse()
+	xDivDoubleYInv := new(btcec.FieldVal).Add(x).Mul(doubleYInv)
+	negXDivDoubleYInv := new(btcec.FieldVal).Add(xDivDoubleYInv).Negate(1)
+	invTwo := new(btcec.FieldVal).AddInt(2).Inverse()
+	negUDivTwo := new(btcec.FieldVal).Add(u).Mul(invTwo).Negate(1)
+	secondX := new(btcec.FieldVal).Add(negXDivDoubleYInv).Add(negUDivTwo)
+
+	// Determine if secondX is on the curve.
+	if isXOnCurve(secondX) {
+		return secondX.Normalize(), nil
+	}
+
+	// 6c. Calculate X/2Y -u'/2 and determine if x^3 + 7 is square
+	thirdX := new(btcec.FieldVal).Add(xDivDoubleYInv).Add(negUDivTwo)
+
+	// Determine if thirdX is on the curve.
+	if isXOnCurve(thirdX) {
+		return thirdX.Normalize(), nil
+	}
+
+	// Should have found a square above.
+	return nil, fmt.Errorf("no calculated x-values were square")
+}
+
+// isXOnCurve returns true if there is a corresponding y-value for the passed
+// x-coordinate.
+func isXOnCurve(x *btcec.FieldVal) bool {
+	y := new(btcec.FieldVal).Add(x).Square().Mul(x).AddInt(7)
+	return new(btcec.FieldVal).SquareRootVal(y)
+}
+
+// XSwiftECInv takes two field elements (u, x) (where x is on the curve) and
+// returns a field element t. This is used to take a random field element u and
+// a point on the curve and return a field element t where (u, t) forms the
+// ElligatorSwift encoding.
+//
+// TODO: Rewrite these to avoid new(btcec.FieldVal).Add(...) usage?
+// NOTE: u, x MUST be normalized. The result `t` is normalized.
+func XSwiftECInv(u, x *btcec.FieldVal, caseNum int) *btcec.FieldVal {
+	v := new(btcec.FieldVal)
+	s := new(btcec.FieldVal)
+	twoInv := new(btcec.FieldVal).AddInt(2).Inverse()
+
+	if caseNum&2 == 0 {
+		// If lift_x(-x-u) succeeds, return None
+		_, found := liftX(new(btcec.FieldVal).Add(x).Add(u).Negate(2))
+		if found {
+			return nil
+		}
+
+		// Let v = x
+		v.Add(x)
+
+		// Let s = -(u^3+7)/(u^2 + uv + v^2)
+		uSqr := new(btcec.FieldVal).Add(u).Square()
+		vSqr := new(btcec.FieldVal).Add(v).Square()
+		sDenom := new(btcec.FieldVal).Add(u).Mul(v).Add(uSqr).Add(vSqr)
+		sNum := new(btcec.FieldVal).Add(uSqr).Mul(u).AddInt(7)
+
+		s = sDenom.Inverse().Mul(sNum).Negate(1)
+	} else {
+		// Let s = x - u
+		negU := new(btcec.FieldVal).Add(u).Negate(1)
+		s.Add(x).Add(negU).Normalize()
+
+		// If s = 0, return None
+		if s.IsZero() {
+			return nil
+		}
+
+		// Let r be the square root of -s(4(u^3 + 7) + 3u^2s)
+		uSqr := new(btcec.FieldVal).Add(u).Square()
+		lhs := new(btcec.FieldVal).Add(uSqr).Mul(u).AddInt(7).MulInt(4)
+		rhs := new(btcec.FieldVal).Add(uSqr).MulInt(3).Mul(s)
+
+		// Add the two terms together and multiply by -s.
+		lhs.Add(rhs).Normalize().Mul(s).Negate(1)
+
+		r := new(btcec.FieldVal)
+		if !r.SquareRootVal(lhs) {
+			// If no square root was found, return None.
+			return nil
+		}
+
+		if caseNum&1 == 1 && r.Normalize().IsZero() {
+			// If case & 1 = 1 and r = 0, return None.
+			return nil
+		}
+
+		// Let v = (r/s - u)/2
+		sInv := new(btcec.FieldVal).Add(s).Inverse()
+		uNeg := new(btcec.FieldVal).Add(u).Negate(1)
+
+		v.Add(r).Mul(sInv).Add(uNeg).Mul(twoInv)
+	}
+
+	w := new(btcec.FieldVal)
+
+	if !w.SquareRootVal(s) {
+		// If no square root was found, return None.
+		return nil
+	}
+
+	switch caseNum & 5 {
+	case 0:
+		// If case & 5 = 0, return -w(u(1-c)/2 + v)
+		oneMinusC := new(btcec.FieldVal).Add(&c).Negate(1).AddInt(1)
+		t := new(btcec.FieldVal).Add(u).Mul(oneMinusC).Mul(twoInv).Add(v).
+			Mul(w).Negate(1).Normalize()
+
+		return t
+
+	case 1:
+		// If case & 5 = 1, return w(u(1+c)/2 + v)
+		onePlusC := new(btcec.FieldVal).Add(&c).AddInt(1)
+		t := new(btcec.FieldVal).Add(u).Mul(onePlusC).Mul(twoInv).Add(v).
+			Mul(w).Normalize()
+
+		return t
+
+	case 4:
+		// If case & 5 = 4, return w(u(1-c)/2 + v)
+		oneMinusC := new(btcec.FieldVal).Add(&c).Negate(1).AddInt(1)
+		t := new(btcec.FieldVal).Add(u).Mul(oneMinusC).Mul(twoInv).Add(v).
+			Mul(w).Normalize()
+
+		return t
+
+	case 5:
+		// If case & 5 = 5, return -w(u(1+c)/2 + v)
+		onePlusC := new(btcec.FieldVal).Add(&c).AddInt(1)
+		t := new(btcec.FieldVal).Add(u).Mul(onePlusC).Mul(twoInv).Add(v).
+			Mul(w).Negate(1).Normalize()
+
+		return t
+	}
+
+	panic("should not reach here")
+}
+
+// XElligatorSwift takes the x-coordinate of a point on secp256k1 and generates
+// ElligatorSwift encoding of that point composed of two field elements (u, t).
+// NOTE: x MUST be normalized. The return values u, t are normalized.
+func XElligatorSwift(x *btcec.FieldVal) (*btcec.FieldVal, *btcec.FieldVal,
+	error) {
+
+	// We'll choose a random `u` value and a random case so that we can
+	// generate a `t` value.
+	for {
+		// Choose random u value.
+		var randUBytes [32]byte
+		_, err := rand.Read(randUBytes[:])
+		if err != nil {
+			return nil, nil, err
+		}
+
+		u := new(btcec.FieldVal)
+		overflow := u.SetBytes(&randUBytes)
+		if overflow == 1 {
+			u.Normalize()
+		}
+
+		// Choose a random case in the interval [0, 7]
+		var randCaseByte [1]byte
+		_, err = rand.Read(randCaseByte[:])
+		if err != nil {
+			return nil, nil, err
+		}
+
+		caseNum := randCaseByte[0] & 7
+
+		// Find t, if none is found, continue with the loop.
+		t := XSwiftECInv(u, x, int(caseNum))
+		if t != nil {
+			return u, t, nil
+		}
+	}
+}
+
+// EllswiftCreate generates a random private key and returns that along with
+// the ElligatorSwift encoding of its corresponding public key.
+func EllswiftCreate() (*btcec.PrivateKey, [64]byte, error) {
+	var randPrivKeyBytes [32]byte
+
+	// Generate a random private key
+	_, err := rand.Read(randPrivKeyBytes[:])
+	if err != nil {
+		return nil, [64]byte{}, err
+	}
+
+	privKey, _ := btcec.PrivKeyFromBytes(randPrivKeyBytes[:])
+
+	// Fetch the x-coordinate of the public key.
+	x := getXCoord(privKey)
+
+	// Get the ElligatorSwift encoding of the public key.
+	u, t, err := XElligatorSwift(x)
+	if err != nil {
+		return nil, [64]byte{}, err
+	}
+
+	uBytes := u.Bytes()
+	tBytes := t.Bytes()
+
+	// ellswift_pub = bytes(u) || bytes(t), its encoding as 64 bytes
+	var ellswiftPub [64]byte
+	copy(ellswiftPub[0:32], (*uBytes)[:])
+	copy(ellswiftPub[32:64], (*tBytes)[:])
+
+	// Return (priv, ellswift_pub)
+	return privKey, ellswiftPub, nil
+}
+
+// EllswiftECDHXOnly takes the ElligatorSwift-encoded public key of a
+// counter-party and performs ECDH with our private key.
+func EllswiftECDHXOnly(ellswiftTheirs [64]byte, privKey *btcec.PrivateKey) (
+	[32]byte, error) {
+
+	// Let u = int(ellswift_theirs[:32]) mod p.
+	// Let t = int(ellswift_theirs[32:]) mod p.
+	uBytesTheirs := ellswiftTheirs[0:32]
+	tBytesTheirs := ellswiftTheirs[32:64]
+
+	var uTheirs btcec.FieldVal
+	overflow := uTheirs.SetByteSlice(uBytesTheirs[:])
+	if overflow {
+		uTheirs.Normalize()
+	}
+
+	var tTheirs btcec.FieldVal
+	overflow = tTheirs.SetByteSlice(tBytesTheirs[:])
+	if overflow {
+		tTheirs.Normalize()
+	}
+
+	// Calculate bytes(x(priv⋅lift_x(XSwiftEC(u, t))))
+	xTheirs, err := XSwiftEC(&uTheirs, &tTheirs)
+	if err != nil {
+		return [32]byte{}, err
+	}
+
+	pubKey, found := liftX(xTheirs)
+	if !found {
+		return [32]byte{}, ErrPointNotOnCurve
+	}
+
+	var pubJacobian btcec.JacobianPoint
+	pubKey.AsJacobian(&pubJacobian)
+
+	var sharedPoint btcec.JacobianPoint
+	btcec.ScalarMultNonConst(&privKey.Key, &pubJacobian, &sharedPoint)
+	sharedPoint.ToAffine()
+
+	return *sharedPoint.X.Bytes(), nil
+}
+
+// getXCoord fetches the corresponding public key's x-coordinate given a
+// private key.
+func getXCoord(privKey *btcec.PrivateKey) *btcec.FieldVal {
+	var result btcec.JacobianPoint
+	btcec.ScalarBaseMultNonConst(&privKey.Key, &result)
+	result.ToAffine()
+	return &result.X
+}
+
+// liftX returns the point P with x-coordinate `x` and even y-coordinate. If a
+// point exists on the curve, it returns true and false otherwise.
+// TODO: Use quadratic residue formula instead (see: BIP340)?
+func liftX(x *btcec.FieldVal) (*btcec.PublicKey, bool) {
+	ySqr := new(btcec.FieldVal).Add(x).Square().Mul(x).AddInt(7)
+
+	y := new(btcec.FieldVal)
+	if !y.SquareRootVal(ySqr) {
+		// If we've reached here, the point does not exist on the curve.
+		return nil, false
+	}
+
+	if !y.Normalize().IsOdd() {
+		return btcec.NewPublicKey(x, y), true
+	}
+
+	// Negate y if it's odd.
+	if !y.Negate(1).Normalize().IsOdd() {
+		return btcec.NewPublicKey(x, y), true
+	}
+
+	return nil, false
+}
+
+// V2Ecdh performs x-only ecdh and returns a shared secret composed of a tagged
+// hash which itself is composed of two ElligatorSwift-encoded public keys and
+// the x-only ecdh point.
+func V2Ecdh(priv *btcec.PrivateKey, ellswiftTheirs, ellswiftOurs [64]byte,
+	initiating bool) (*chainhash.Hash, error) {
+
+	ecdhPoint, err := EllswiftECDHXOnly(ellswiftTheirs, priv)
+	if err != nil {
+		return nil, err
+	}
+
+	if initiating {
+		// Initiating, place our public key encoding first.
+		var msg []byte
+		msg = append(msg, ellswiftOurs[:]...)
+		msg = append(msg, ellswiftTheirs[:]...)
+		msg = append(msg, ecdhPoint[:]...)
+		return chainhash.TaggedHash(ellswiftTag, msg), nil
+	}
+
+	msg := make([]byte, 0, 64+64+32)
+	msg = append(msg, ellswiftTheirs[:]...)
+	msg = append(msg, ellswiftOurs[:]...)
+	msg = append(msg, ecdhPoint[:]...)
+	return chainhash.TaggedHash(ellswiftTag, msg), nil
+}
diff --git a/btcjson/chainsvrresults.go b/btcjson/chainsvrresults.go
index 433bdda8..b4e4f664 100644
--- a/btcjson/chainsvrresults.go
+++ b/btcjson/chainsvrresults.go
@@ -460,6 +460,7 @@ type GetPeerInfoResult struct {
 	BanScore       int32   `json:"banscore"`
 	FeeFilter      int64   `json:"feefilter"`
 	SyncNode       bool    `json:"syncnode"`
+	V2Connection   bool    `json:"v2_connection"`
 }
 
 // GetRawMempoolVerboseResult models the data returned from the getrawmempool
diff --git a/btcutil/psbt/taproot.go b/btcutil/psbt/taproot.go
index b9df860c..02a0e3a2 100644
--- a/btcutil/psbt/taproot.go
+++ b/btcutil/psbt/taproot.go
@@ -2,6 +2,8 @@ package psbt
 
 import (
 	"bytes"
+	"math"
+	"math/bits"
 
 	"github.com/btcsuite/btcd/btcec/v2/schnorr"
 	"github.com/btcsuite/btcd/txscript"
@@ -93,12 +95,61 @@ func (s *TaprootBip32Derivation) SortBefore(other *TaprootBip32Derivation) bool
 	return bytes.Compare(s.XOnlyPubKey, other.XOnlyPubKey) < 0
 }
 
+// minTaprootBip32DerivationByteSize returns the minimum number of bytes
+// required to encode a Taproot BIP32 derivation field, given the number of
+// leaf hashes.
+//
+// NOTE: This function does not account for the size of the BIP32 child indexes,
+// as we are only computing the minimum size (which occurs when the path is
+// empty). The bits package is used to safely detect and handle overflows.
+func minTaprootBip32DerivationByteSize(numHashes uint64) (uint64, error) {
+	// The Taproot BIP32 derivation field is encoded as:
+	//   [compact size uint: number of leaf hashes]
+	//   [N × 32 bytes: leaf hashes]
+	//   [4 bytes: master key fingerprint]
+	//   [M × 4 bytes: BIP32 child indexes]
+	//
+	// To compute the minimum size given the number of hashes only, we assume:
+	// - N = numHashes (provided)
+	// - M = 0 (no child indexes)
+	//
+	// So the base byte size is:
+	//   1 (leaf hash count) + (N × 32) + 4 (fingerprint)
+	//
+	// First, we calculate the total number of bytes for the leaf hashes.
+	mulCarry, totalHashesBytes := bits.Mul64(numHashes, 32)
+	if mulCarry != 0 {
+		return 0, ErrInvalidPsbtFormat
+	}
+
+	// Since we're computing the minimum possible size, we add a constant that
+	// accounts for the fixed size fields:
+	// * 1 byte for the compact size leaf hash count (assumes numHashes < 0xfd)
+	// * 4 bytes for the master key fingerprint
+	// Total: 5 bytes.
+	// All other fields (e.g., BIP32 path) are assumed absent for minimum size
+	// calculation.
+	result, addCarry := bits.Add64(5, totalHashesBytes, 0)
+	if addCarry != 0 {
+		return 0, ErrInvalidPsbtFormat
+	}
+
+	return result, nil
+}
+
 // ReadTaprootBip32Derivation deserializes a byte slice containing the Taproot
 // BIP32 derivation info that consists of a list of leaf hashes as well as the
 // normal BIP32 derivation info.
 func ReadTaprootBip32Derivation(xOnlyPubKey,
 	value []byte) (*TaprootBip32Derivation, error) {
 
+	// This function allocates additional memory while parsing the serialized
+	// data. To prevent potential out-of-memory (OOM) issues, we must validate
+	// the length of the value slice before proceeding.
+	if len(value) > MaxPsbtValueLength {
+		return nil, ErrInvalidPsbtFormat
+	}
+
 	// The taproot key BIP 32 derivation path is defined as:
 	//   <hashes len> <leaf hash>* <4 byte fingerprint> <32-bit uint>*
 	// So we get at least 5 bytes for the length and the 4 byte fingerprint.
@@ -113,9 +164,22 @@ func ReadTaprootBip32Derivation(xOnlyPubKey,
 		return nil, ErrInvalidPsbtFormat
 	}
 
-	// A hash is 32 bytes in size, so we need at least numHashes*32 + 5
-	// bytes to be present.
-	if len(value) < (int(numHashes)*32)+5 {
+	// As a safety/sanity check, verify that the hash count fits in a `uint32`.
+	// This isn’t mandated by BIP‑371, but it prevents overflow and limits
+	// derivations to about 137 GiB of data.
+	if numHashes > math.MaxUint32 {
+		return nil, ErrInvalidPsbtFormat
+	}
+
+	// Given the number of hashes, we can calculate the minimum byte size
+	// of the taproot BIP32 derivation.
+	minByteSize, err := minTaprootBip32DerivationByteSize(numHashes)
+	if err != nil {
+		return nil, err
+	}
+
+	// Ensure that value is at least the minimum size.
+	if uint64(len(value)) < minByteSize {
 		return nil, ErrInvalidPsbtFormat
 	}
 
diff --git a/config.go b/config.go
index 04f926c2..cb7ce6b2 100644
--- a/config.go
+++ b/config.go
@@ -177,6 +177,7 @@ type config struct {
 	TrickleInterval      time.Duration `long:"trickleinterval" description:"Minimum time between attempts to send new inventory to a connected peer"`
 	UtxoCacheMaxSizeMiB  uint          `long:"utxocachemaxsize" description:"The maximum size in MiB of the UTXO cache"`
 	TxIndex              bool          `long:"txindex" description:"Maintain a full hash-based transaction index which makes all transactions available via the getrawtransaction RPC"`
+	V2Transport          bool          `long:"v2transport" description:"Enable P2P v2 encrypted transport protocol (BIP324) (default: false)"`
 	UserAgentComments    []string      `long:"uacomment" description:"Comment to add to the user agent -- See BIP 14 for more information."`
 	Upnp                 bool          `long:"upnp" description:"Use UPnP to map our listening port outside of NAT"`
 	ShowVersion          bool          `short:"V" long:"version" description:"Display version information and exit"`
@@ -442,6 +443,7 @@ func loadConfig() (*config, []string, error) {
 		Generate:             defaultGenerate,
 		TxIndex:              defaultTxIndex,
 		AddrIndex:            defaultAddrIndex,
+		V2Transport:          false,
 	}
 
 	// Service options which are only added on Windows.
diff --git a/connmgr/connmanager.go b/connmgr/connmanager.go
index e88f8af0..97fcd6c9 100644
--- a/connmgr/connmanager.go
+++ b/connmgr/connmanager.go
@@ -153,6 +153,22 @@ type registerPending struct {
 	done chan struct{}
 }
 
+// ConnOption is a functional option type for various connection operations.
+type ConnOption func(*connOptions)
+
+// connOptions holds the options for a connection operation.
+type connOptions struct {
+	triggerReconnect bool
+}
+
+// WithTriggerReconnect is a functional option that forces a reconnect attempt
+// after disconnection, even for non-permanent peers.
+func WithTriggerReconnect() ConnOption {
+	return func(opts *connOptions) {
+		opts.triggerReconnect = true
+	}
+}
+
 // handleConnected is used to queue a successful connection.
 type handleConnected struct {
 	c    *ConnReq
@@ -161,8 +177,9 @@ type handleConnected struct {
 
 // handleDisconnected is used to remove a connection.
 type handleDisconnected struct {
-	id    uint64
-	retry bool
+	id               uint64
+	retry            bool
+	triggerReconnect bool
 }
 
 // handleFailed is used to remove a pending connection.
@@ -190,11 +207,11 @@ type ConnManager struct {
 // retry duration. Otherwise, if required, it makes a new connection request.
 // After maxFailedConnectionAttempts new connections will be retried after the
 // configured retry duration.
-func (cm *ConnManager) handleFailedConn(c *ConnReq) {
+func (cm *ConnManager) handleFailedConn(c *ConnReq, triggerReconnect bool) {
 	if atomic.LoadInt32(&cm.stop) != 0 {
 		return
 	}
-	if c.Permanent {
+	if c.Permanent || triggerReconnect {
 		c.retryCount++
 		d := time.Duration(c.retryCount) * cm.cfg.RetryDuration
 		if d > maxRetryDuration {
@@ -296,7 +313,6 @@ out:
 					log.Debugf("Canceling: %v", connReq)
 					delete(pending, msg.id)
 					continue
-
 				}
 
 				// An existing connection was located, mark as
@@ -334,7 +350,7 @@ out:
 					log.Debugf("Reconnecting to %v",
 						connReq)
 					pending[msg.id] = connReq
-					cm.handleFailedConn(connReq)
+					cm.handleFailedConn(connReq, msg.triggerReconnect)
 				}
 
 			case handleFailed:
@@ -349,7 +365,7 @@ out:
 				connReq.updateState(ConnFailing)
 				log.Debugf("Failed to connect to %v: %v",
 					connReq, msg.err)
-				cm.handleFailedConn(connReq)
+				cm.handleFailedConn(connReq, false)
 			}
 
 		case <-cm.quit:
@@ -463,14 +479,21 @@ func (cm *ConnManager) Connect(c *ConnReq) {
 
 // Disconnect disconnects the connection corresponding to the given connection
 // id. If permanent, the connection will be retried with an increasing backoff
-// duration.
-func (cm *ConnManager) Disconnect(id uint64) {
+// duration. Functional options can be used to modify behavior, such as forcing
+// a reconnect attempt via WithTriggerReconnect.
+func (cm *ConnManager) Disconnect(id uint64, options ...ConnOption) {
 	if atomic.LoadInt32(&cm.stop) != 0 {
 		return
 	}
+	opts := connOptions{}
+	for _, option := range options {
+		option(&opts)
+	}
 
 	select {
-	case cm.requests <- handleDisconnected{id, true}:
+	case cm.requests <- handleDisconnected{
+		id: id, retry: true, triggerReconnect: opts.triggerReconnect,
+	}:
 	case <-cm.quit:
 	}
 }
@@ -486,7 +509,7 @@ func (cm *ConnManager) Remove(id uint64) {
 	}
 
 	select {
-	case cm.requests <- handleDisconnected{id, false}:
+	case cm.requests <- handleDisconnected{id: id, retry: false}:
 	case <-cm.quit:
 	}
 }
diff --git a/go.mod b/go.mod
index f8b8e95f..d45c6061 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ require (
 	github.com/btcsuite/btcd/btcec/v2 v2.3.4
 	github.com/btcsuite/btcd/btcutil v1.1.5
 	github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
+	github.com/btcsuite/btcd/v2transport v1.0.0
 	github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f
 	github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd
 	github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
@@ -16,8 +17,8 @@ require (
 	github.com/jrick/logrotate v1.0.0
 	github.com/stretchr/testify v1.8.4
 	github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7
-	golang.org/x/crypto v0.22.0
-	golang.org/x/sys v0.19.0
+	golang.org/x/crypto v0.25.0
+	golang.org/x/sys v0.22.0
 	pgregory.net/rapid v1.2.0
 )
 
@@ -32,6 +33,10 @@ require (
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
+replace github.com/btcsuite/btcd/btcec/v2 => ./btcec
+
+replace github.com/btcsuite/btcd/v2transport => ./v2transport
+
 // The retract statements below fixes an accidental push of the tags of a btcd
 // fork.
 retract (
@@ -64,4 +69,4 @@ retract (
 	v0.13.0-beta
 )
 
-go 1.22
+go 1.23.2
diff --git a/go.sum b/go.sum
index 19f62db8..ca66bb9a 100644
--- a/go.sum
+++ b/go.sum
@@ -3,15 +3,10 @@ github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBA
 github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
 github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
 github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
-github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
-github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
-github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
-github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
 github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
 github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
 github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
 github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
-github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
 github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
@@ -91,8 +86,8 @@ github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45
 golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
 golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -110,13 +105,13 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
diff --git a/log.go b/log.go
index 5707d7c2..55be25ca 100644
--- a/log.go
+++ b/log.go
@@ -21,6 +21,7 @@ import (
 	"github.com/btcsuite/btcd/netsync"
 	"github.com/btcsuite/btcd/peer"
 	"github.com/btcsuite/btcd/txscript"
+	"github.com/btcsuite/btcd/v2transport"
 
 	"github.com/btcsuite/btclog"
 	"github.com/jrick/logrotate/rotator"
@@ -69,6 +70,7 @@ var (
 	srvrLog = backendLog.Logger("SRVR")
 	syncLog = backendLog.Logger("SYNC")
 	txmpLog = backendLog.Logger("TXMP")
+	v2trLog = backendLog.Logger(v2transport.Subsystem)
 )
 
 // Initialize package-global logger variables.
@@ -84,25 +86,27 @@ func init() {
 	txscript.UseLogger(scrpLog)
 	netsync.UseLogger(syncLog)
 	mempool.UseLogger(txmpLog)
+	v2transport.UseLogger(v2trLog)
 }
 
 // subsystemLoggers maps each subsystem identifier to its associated logger.
 var subsystemLoggers = map[string]btclog.Logger{
-	"ADXR": adxrLog,
-	"AMGR": amgrLog,
-	"CMGR": cmgrLog,
-	"BCDB": bcdbLog,
-	"BTCD": btcdLog,
-	"CHAN": chanLog,
-	"DISC": discLog,
-	"INDX": indxLog,
-	"MINR": minrLog,
-	"PEER": peerLog,
-	"RPCS": rpcsLog,
-	"SCRP": scrpLog,
-	"SRVR": srvrLog,
-	"SYNC": syncLog,
-	"TXMP": txmpLog,
+	"ADXR":                adxrLog,
+	"AMGR":                amgrLog,
+	"CMGR":                cmgrLog,
+	"BCDB":                bcdbLog,
+	"BTCD":                btcdLog,
+	"CHAN":                chanLog,
+	"DISC":                discLog,
+	"INDX":                indxLog,
+	"MINR":                minrLog,
+	"PEER":                peerLog,
+	"RPCS":                rpcsLog,
+	"SCRP":                scrpLog,
+	"SRVR":                srvrLog,
+	"SYNC":                syncLog,
+	"TXMP":                txmpLog,
+	v2transport.Subsystem: v2trLog,
 }
 
 // initLogRotator initializes the logging rotater to write logs to logFile and
diff --git a/peer/p2pdowngrader.go b/peer/p2pdowngrader.go
new file mode 100644
index 00000000..b0a7ab1d
--- /dev/null
+++ b/peer/p2pdowngrader.go
@@ -0,0 +1,54 @@
+package peer
+
+import (
+	"github.com/decred/dcrd/lru"
+)
+
+const (
+	// defaultDowngradeCacheSize is the default number of addresses to store
+	// in the P2P downgrader cache.
+	defaultDowngradeCacheSize = 100
+)
+
+// P2PDowngrader manages a list of peer addresses that should be attempted
+// with the v1 P2P protocol on their next connection, typically after a v2
+// handshake failure.
+type P2PDowngrader struct {
+	cache lru.Cache
+}
+
+// NewP2PDowngrader returns a new P2PDowngrader instance.
+// cacheSize specifies the maximum number of addresses to remember for downgrade.
+func NewP2PDowngrader(cacheSize uint) *P2PDowngrader {
+	if cacheSize == 0 {
+		cacheSize = defaultDowngradeCacheSize
+	}
+	return &P2PDowngrader{
+		cache: lru.NewCache(cacheSize),
+	}
+}
+
+// MarkForDowngrade flags an address so that the next outbound connection
+// attempt to it will use the v1 P2P protocol.
+func (pd *P2PDowngrader) MarkForDowngrade(addr string) {
+	pd.cache.Add(addr)
+
+	log.Debugf("P2PDowngrader: Marked %s for v1 downgrade", addr)
+}
+
+// ShouldDowngrade checks if an address is marked for a v1 downgrade. If the
+// address is found, it is removed from the list (consumed), and the function
+// returns true. Otherwise, it returns false.
+func (pd *P2PDowngrader) ShouldDowngrade(addr string) bool {
+
+	if exists := pd.cache.Contains(addr); exists {
+		pd.cache.Delete(addr)
+
+		log.Debugf("P2PDowngrader: Consumed v1 downgrade request "+
+			"for %s", addr)
+
+		return true
+	}
+
+	return false
+}
diff --git a/peer/peer.go b/peer/peer.go
index 195fc0b4..2e5d6ff2 100644
--- a/peer/peer.go
+++ b/peer/peer.go
@@ -21,6 +21,7 @@ import (
 	"github.com/btcsuite/btcd/blockchain"
 	"github.com/btcsuite/btcd/chaincfg"
 	"github.com/btcsuite/btcd/chaincfg/chainhash"
+	"github.com/btcsuite/btcd/v2transport"
 	"github.com/btcsuite/btcd/wire"
 	"github.com/btcsuite/go-socks/socks"
 	"github.com/davecgh/go-spew/spew"
@@ -289,6 +290,10 @@ type Config struct {
 	// scenarios where the stall behavior isn't important to the system
 	// under test.
 	DisableStallHandler bool
+
+	// UsingV2Conn is defined if and only if we accept and attempt to make
+	// v2 connections.
+	UsingV2Conn bool
 }
 
 // minUint32 is a helper function to return the minimum of two uint32s.
@@ -393,6 +398,7 @@ type StatsSnap struct {
 	LastPingNonce  uint64
 	LastPingTime   time.Time
 	LastPingMicros int64
+	V2Connection   bool
 }
 
 // HashFunc is a function which returns a block hash, height and error
@@ -463,6 +469,8 @@ type Peer struct {
 	witnessEnabled       bool
 	sendAddrV2           bool
 
+	V2Transport *v2transport.Peer
+
 	wireEncoding wire.MessageEncoding
 
 	knownInventory     lru.Cache
@@ -572,6 +580,7 @@ func (p *Peer) StatsSnapshot() *StatsSnap {
 		LastPingNonce:  p.lastPingNonce,
 		LastPingMicros: p.lastPingMicros,
 		LastPingTime:   p.lastPingTime,
+		V2Connection:   p.cfg.UsingV2Conn,
 	}
 
 	p.statsMtx.RUnlock()
@@ -1065,10 +1074,41 @@ func (p *Peer) handlePongMsg(msg *wire.MsgPong) {
 	}
 }
 
-// readMessage reads the next bitcoin message from the peer with logging.
-func (p *Peer) readMessage(encoding wire.MessageEncoding) (wire.Message, []byte, error) {
-	n, msg, buf, err := wire.ReadMessageWithEncodingN(p.conn,
-		p.ProtocolVersion(), p.cfg.ChainParams.Net, encoding)
+// readMessage reads the next bitcoin message from the peer with logging. The
+// partial bool indicates that we've partially read a message already. In this
+// case, we use the ReadPartialMessageWithEncodingN function.
+func (p *Peer) readMessage(encoding wire.MessageEncoding, partial bool) (
+	wire.Message, []byte, error) {
+
+	var (
+		n         int
+		msg       wire.Message
+		buf       []byte
+		plaintext []byte
+		err       error
+	)
+
+	if p.cfg.UsingV2Conn {
+		plaintext, err = p.V2Transport.V2ReceivePacket(nil)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		msg, buf, err = wire.ReadV2MessageN(
+			plaintext, p.ProtocolVersion(), encoding,
+		)
+		n = len(plaintext)
+	} else if partial {
+		n, msg, buf, err = wire.ReadPartialMessageWithEncodingN(
+			p.conn, p.ProtocolVersion(), p.cfg.ChainParams.Net, encoding,
+			p.V2Transport.ReceivedPrefix(),
+		)
+	} else {
+		n, msg, buf, err = wire.ReadMessageWithEncodingN(
+			p.conn, p.ProtocolVersion(), p.cfg.ChainParams.Net, encoding,
+		)
+	}
+
 	atomic.AddUint64(&p.bytesReceived, uint64(n))
 	if p.cfg.Listeners.OnRead != nil {
 		p.cfg.Listeners.OnRead(p, n, msg, err)
@@ -1105,6 +1145,25 @@ func (p *Peer) writeMessage(msg wire.Message, enc wire.MessageEncoding) error {
 		return nil
 	}
 
+	var (
+		buf bytes.Buffer
+		n   int
+		err error
+	)
+
+	if p.cfg.UsingV2Conn {
+		_, err = wire.WriteV2MessageN(&buf, msg, p.ProtocolVersion(), enc)
+		if err != nil {
+			return err
+		}
+
+		_, n, err = p.V2Transport.V2EncPacket(buf.Bytes(), nil, false)
+	} else {
+		n, err = wire.WriteMessageWithEncodingN(
+			p.conn, msg, p.ProtocolVersion(), p.cfg.ChainParams.Net, enc,
+		)
+	}
+
 	// Use closures to log expensive operations so they are only run when
 	// the logging level requires it.
 	log.Debugf("%v", newLogClosure(func() string {
@@ -1120,18 +1179,9 @@ func (p *Peer) writeMessage(msg wire.Message, enc wire.MessageEncoding) error {
 		return spew.Sdump(msg)
 	}))
 	log.Tracef("%v", newLogClosure(func() string {
-		var buf bytes.Buffer
-		_, err := wire.WriteMessageWithEncodingN(&buf, msg, p.ProtocolVersion(),
-			p.cfg.ChainParams.Net, enc)
-		if err != nil {
-			return err.Error()
-		}
 		return spew.Sdump(buf.Bytes())
 	}))
 
-	// Write the message to the peer.
-	n, err := wire.WriteMessageWithEncodingN(p.conn, msg,
-		p.ProtocolVersion(), p.cfg.ChainParams.Net, enc)
 	atomic.AddUint64(&p.bytesSent, uint64(n))
 	if p.cfg.Listeners.OnWrite != nil {
 		p.cfg.Listeners.OnWrite(p, n, msg, err)
@@ -1400,7 +1450,7 @@ out:
 		// Read a message and stop the idle timer as soon as the read
 		// is done.  The timer is reset below for the next iteration if
 		// needed.
-		rmsg, buf, err := p.readMessage(p.wireEncoding)
+		rmsg, buf, err := p.readMessage(p.wireEncoding, false)
 		idleTimer.Stop()
 		if err != nil {
 			// In order to allow regression tests with malformed messages, don't
@@ -1963,10 +2013,16 @@ func (p *Peer) Disconnect() {
 
 // readRemoteVersionMsg waits for the next message to arrive from the remote
 // peer.  If the next message is not a version message or the version is not
-// acceptable then return an error.
-func (p *Peer) readRemoteVersionMsg() error {
-	// Read their version message.
-	remoteMsg, _, err := p.readMessage(wire.LatestEncoding)
+// acceptable then return an error.  The readPartial bool denotes whether we
+// need to read the rest of a partially-received version message.  This only
+// happens with implicitly downgraded v2->v1 connections.
+func (p *Peer) readRemoteVersionMsg(readPartial bool) error {
+	var (
+		remoteMsg wire.Message
+		err       error
+	)
+
+	remoteMsg, _, err = p.readMessage(wire.LatestEncoding, readPartial)
 	if err != nil {
 		return err
 	}
@@ -2171,7 +2227,7 @@ func (p *Peer) waitToFinishNegotiation(pver uint32) error {
 	// can receive unknown messages before and after sendaddrv2 and still
 	// have to wait for verack.
 	for {
-		remoteMsg, _, err := p.readMessage(wire.LatestEncoding)
+		remoteMsg, _, err := p.readMessage(wire.LatestEncoding, false)
 		if err == wire.ErrUnknownMessage {
 			continue
 		} else if err != nil {
@@ -2215,7 +2271,42 @@ func (p *Peer) waitToFinishNegotiation(pver uint32) error {
 //     that btcd does not implement but bitcoind does.
 //  6. If remote peer sent sendaddrv2 above, wait until receipt of verack.
 func (p *Peer) negotiateInboundProtocol() error {
-	if err := p.readRemoteVersionMsg(); err != nil {
+	// We may be anticipating a v2 connection, but if the initiating peer
+	// sends us a v1 version message, we need to note down that we've
+	// downgraded the connection internally.
+	downgradedConn := false
+
+	if p.cfg.UsingV2Conn {
+		garbageLen := rand.Intn(v2transport.MaxGarbageLen + 1)
+		err := p.V2Transport.RespondV2Handshake(
+			garbageLen, p.cfg.ChainParams.Net,
+		)
+		switch {
+		case errors.Is(err, v2transport.ErrUseV1Protocol):
+			log.Infof("Inbound v2 connection attempt from %s "+
+				"downgraded to v1 (peer sent v1 version "+
+				"message)", p.addr)
+
+			p.cfg.UsingV2Conn = false
+			downgradedConn = true
+
+		case err != nil:
+			return err
+
+		default:
+			err = p.V2Transport.CompleteHandshake(
+				false, nil, p.cfg.ChainParams.Net,
+			)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	// If the connection has been downgraded, then we need to parse the
+	// rest of the v1 version message properly. Otherwise, we read the
+	// entire version message off the wire.
+	if err := p.readRemoteVersionMsg(downgradedConn); err != nil {
 		return err
 	}
 
@@ -2253,11 +2344,35 @@ func (p *Peer) negotiateInboundProtocol() error {
 //     in the inbound case.
 //  6. If sendaddrv2 was received, wait for receipt of verack.
 func (p *Peer) negotiateOutboundProtocol() error {
+	if p.cfg.UsingV2Conn {
+		// Note that it's possible that the v2 handshake fails because
+		// the peer does not support v2 connections. In this case, we
+		// should detect that and reconnect using a v1 connection. This
+		// is the logic that bitcoind uses.
+		garbageLen := rand.Intn(v2transport.MaxGarbageLen + 1)
+		err := p.V2Transport.InitiateV2Handshake(garbageLen)
+		if err != nil {
+			return err
+		}
+
+		err = p.V2Transport.CompleteHandshake(
+			true, nil, p.cfg.ChainParams.Net,
+		)
+		if errors.Is(err, v2transport.ErrShouldDowngradeToV1) {
+			log.Infof("Outbound v2 connection attempt to %s "+
+				"failed, will downgrade to v1 (peer does "+
+				"not support v2)", p.addr)
+			return err
+		} else if err != nil {
+			return err
+		}
+	}
+
 	if err := p.writeLocalVersionMsg(); err != nil {
 		return err
 	}
 
-	if err := p.readRemoteVersionMsg(); err != nil {
+	if err := p.readRemoteVersionMsg(false); err != nil {
 		return err
 	}
 
@@ -2327,6 +2442,10 @@ func (p *Peer) AssociateConnection(conn net.Conn) {
 	p.conn = conn
 	p.timeConnected = time.Now()
 
+	if p.cfg.UsingV2Conn {
+		p.V2Transport.UseReadWriter(conn)
+	}
+
 	if p.inbound {
 		p.addr = p.conn.RemoteAddr().String()
 
@@ -2363,6 +2482,20 @@ func (p *Peer) WaitForDisconnect() {
 	<-p.quit
 }
 
+// ShouldDowngradeToV1 is called when we try to connect to a peer via v2 BIP324
+// transport and they hang up. In this case, we should reconnect with the
+// legacy transport.
+//
+// This function is safe for concurrent access.
+func (p *Peer) ShouldDowngradeToV1() bool {
+	// If we weren't attempting a V2 connection with a V2 transport,
+	// or have no V2 transport instance, then no downgrade is indicated.
+	if !p.cfg.UsingV2Conn || p.V2Transport == nil {
+		return false
+	}
+	return p.V2Transport.ShouldDowngradeToV1()
+}
+
 // newPeerBase returns a new base bitcoin peer based on the inbound flag.  This
 // is used by the NewInboundPeer and NewOutboundPeer functions to perform base
 // setup needed by both types of peers.
@@ -2401,6 +2534,14 @@ func newPeerBase(origCfg *Config, inbound bool) *Peer {
 		services:        cfg.Services,
 		protocolVersion: cfg.ProtocolVersion,
 	}
+
+	if p.cfg.UsingV2Conn && p.Services()&wire.SFNodeP2PV2 == wire.SFNodeP2PV2 {
+		p.V2Transport = v2transport.NewPeer()
+	} else {
+		// TODO: Hack, change.
+		p.cfg.UsingV2Conn = false
+	}
+
 	return &p
 }
 
diff --git a/rpcserver.go b/rpcserver.go
index 736f1459..bec0fad8 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -2588,6 +2588,7 @@ func handleGetPeerInfo(s *rpcServer, cmd interface{}, closeChan <-chan struct{})
 			BanScore:       int32(p.BanScore()),
 			FeeFilter:      p.FeeFilter(),
 			SyncNode:       statsSnap.ID == syncPeerID,
+			V2Connection:   statsSnap.V2Connection,
 		}
 		if p.ToPeer().LastPingNonce() != 0 {
 			wait := float64(time.Since(statsSnap.LastPingTime).Nanoseconds())
diff --git a/rpcserverhelp.go b/rpcserverhelp.go
index 71f96e99..133fb48b 100644
--- a/rpcserverhelp.go
+++ b/rpcserverhelp.go
@@ -500,6 +500,7 @@ var helpDescsEnUS = map[string]string{
 	"getpeerinforesult-banscore":       "The ban score",
 	"getpeerinforesult-feefilter":      "The requested minimum fee a transaction must have to be announced to the peer",
 	"getpeerinforesult-syncnode":       "Whether or not the peer is the sync peer",
+	"getpeerinforesult-v2_connection":  "Whether or not the peer is a v2 connection",
 
 	// GetPeerInfoCmd help.
 	"getpeerinfo--synopsis": "Returns data about each connected network peer as an array of json objects.",
diff --git a/sample-btcd.conf b/sample-btcd.conf
index 0fb688fb..103f75b8 100644
--- a/sample-btcd.conf
+++ b/sample-btcd.conf
@@ -176,6 +176,11 @@
 ; Disable committed peer filtering (CF).
 ; nocfilters=1
 
+; Enable or disable the P2P v2 encrypted transport protocol (BIP324).
+; If disabled (which is the default), btcd will only attempt to use the
+; v1 P2P protocol. (default: 0)
+; v2transport=0
+
 ; ------------------------------------------------------------------------------
 ; RPC server options - The following options control the built-in RPC server
 ; which is used to control and query information from a running btcd process.
diff --git a/server.go b/server.go
index 66794e4b..b45db2bb 100644
--- a/server.go
+++ b/server.go
@@ -45,7 +45,7 @@ const (
 	// defaultServices describes the default services that are supported by
 	// the server.
 	defaultServices = wire.SFNodeNetwork | wire.SFNodeNetworkLimited |
-		wire.SFNodeBloom | wire.SFNodeWitness | wire.SFNodeCF
+		wire.SFNodeBloom | wire.SFNodeWitness | wire.SFNodeCF | wire.SFNodeP2PV2
 
 	// defaultRequiredServices describes the default services that are
 	// required to be supported by outbound peers.
@@ -217,6 +217,7 @@ type server struct {
 	txMemPool            *mempool.TxPool
 	cpuMiner             *cpuminer.CPUMiner
 	modifyRebroadcastInv chan interface{}
+	p2pDowngrader        *peer.P2PDowngrader
 	newPeers             chan *serverPeer
 	donePeers            chan *serverPeer
 	banPeers             chan *serverPeer
@@ -457,6 +458,13 @@ func hasServices(advertised, desired wire.ServiceFlag) bool {
 	return advertised&desired == desired
 }
 
+// ShouldReconnectV1 is invoked when we need to determine if we are going to
+// reconnect to an outbound peer. This will return true if we attempted to
+// connect to the peer using the v2 transport, and need to fall back to v1.
+func (sp *serverPeer) ShouldReconnectV1() bool {
+	return sp.ShouldDowngradeToV1()
+}
+
 // OnVersion is invoked when a peer receives a version bitcoin message
 // and is used to negotiate the protocol version details as well as kick start
 // the communications.
@@ -1833,9 +1841,19 @@ func (s *server) handleDonePeerMsg(state *peerState, sp *serverPeer) {
 	// our connection manager about the disconnection. This can happen if we
 	// process a peer's `done` message before its `add`.
 	if !sp.Inbound() {
-		if sp.persistent {
+		switch {
+		case sp.persistent:
 			s.connManager.Disconnect(sp.connReq.ID())
-		} else {
+
+		// If this isn't a persistent peer, but we failed a v2
+		// handshake, then we'll disconnect, but trigger a reconnect so
+		// we can use v1 instead.
+		case sp.ShouldReconnectV1():
+			s.connManager.Disconnect(
+				sp.connReq.ID(), connmgr.WithTriggerReconnect(),
+			)
+
+		default:
 			s.connManager.Remove(sp.connReq.ID())
 			go s.connManager.NewConnReq()
 		}
@@ -2147,8 +2165,8 @@ func newPeerConfig(sp *serverPeer) *peer.Config {
 			OnNotFound:     sp.OnNotFound,
 
 			// Note: The reference client currently bans peers that send alerts
-			// not signed with its key.  We could verify against their key, but
 			// since the reference client is currently unwilling to support
+			// not signed with its key.  We could verify against their key, but
 			// other implementations' alert messages, we will not relay theirs.
 			OnAlert: nil,
 		},
@@ -2164,6 +2182,7 @@ func newPeerConfig(sp *serverPeer) *peer.Config {
 		ProtocolVersion:     peer.MaxProtocolVersion,
 		TrickleInterval:     cfg.TrickleInterval,
 		DisableStallHandler: cfg.DisableStallHandler,
+		UsingV2Conn:         cfg.V2Transport,
 	}
 }
 
@@ -2185,10 +2204,26 @@ func (s *server) inboundPeerConnected(conn net.Conn) {
 // request instance and the connection itself, and finally notifies the address
 // manager of the attempt.
 func (s *server) outboundPeerConnected(c *connmgr.ConnReq, conn net.Conn) {
+	// Just an alias.
+	peerAddr := c.Addr.String()
 	sp := newServerPeer(s, c.Permanent)
-	p, err := peer.NewOutboundPeer(newPeerConfig(sp), c.Addr.String())
+
+	peerCfg := newPeerConfig(sp)
+
+	// Check with the P2PDowngrader if this connection attempt should be
+	// forced to v1.
+	if s.p2pDowngrader.ShouldDowngrade(peerAddr) {
+		srvrLog.Infof("Forcing V1 connection to %s as requested by "+
+			"P2P downgrader.", peerAddr)
+
+		peerCfg.UsingV2Conn = false
+	}
+
+	p, err := peer.NewOutboundPeer(peerCfg, peerAddr)
 	if err != nil {
-		srvrLog.Debugf("Cannot create outbound peer %s: %v", c.Addr, err)
+		srvrLog.Debugf("Cannot create outbound peer %s: %v",
+			c.Addr, err)
+
 		if c.Permanent {
 			s.connManager.Disconnect(c.ID())
 		} else {
@@ -2197,6 +2232,7 @@ func (s *server) outboundPeerConnected(c *connmgr.ConnReq, conn net.Conn) {
 		}
 		return
 	}
+
 	sp.Peer = p
 	sp.connReq = c
 	sp.isWhitelisted = isWhitelisted(conn.RemoteAddr())
@@ -2208,6 +2244,18 @@ func (s *server) outboundPeerConnected(c *connmgr.ConnReq, conn net.Conn) {
 // done along with other performing other desirable cleanup.
 func (s *server) peerDoneHandler(sp *serverPeer) {
 	sp.WaitForDisconnect()
+
+	// If this is an outbound peer and the shouldDowngradeToV1 bool is set
+	// on the underlying Peer, trigger a reconnect using the OG v1
+	// connection scheme.
+	if !sp.Inbound() && sp.Peer.ShouldDowngradeToV1() {
+		srvrLog.Infof("Peer %s indicated v2->v1 downgrade. "+
+			"Marking for next attempt as v1.", sp.Addr())
+
+		s.p2pDowngrader.MarkForDowngrade(sp.Addr())
+	}
+
+	// This is sent to a buffered channel, so it may not execute immediately.
 	s.donePeers <- sp
 
 	// Only tell sync manager we are gone if we ever told it we existed.
@@ -2734,6 +2782,9 @@ func newServer(listenAddrs, agentBlacklist, agentWhitelist []string,
 	if cfg.Prune != 0 {
 		services &^= wire.SFNodeNetwork
 	}
+	if !cfg.V2Transport {
+		services &^= wire.SFNodeP2PV2
+	}
 
 	amgr := addrmgr.New(cfg.DataDir, btcdLookup)
 
@@ -3010,6 +3061,8 @@ func newServer(listenAddrs, agentBlacklist, agentWhitelist []string,
 	}
 	s.connManager = cmgr
 
+	s.p2pDowngrader = peer.NewP2PDowngrader(uint(targetOutbound) + 1)
+
 	// Start up persistent peers.
 	permanentPeers := cfg.ConnectPeers
 	if len(permanentPeers) == 0 {
diff --git a/v2transport/chacha.go b/v2transport/chacha.go
new file mode 100644
index 00000000..6816634f
--- /dev/null
+++ b/v2transport/chacha.go
@@ -0,0 +1,175 @@
+package v2transport
+
+import (
+	"crypto/cipher"
+	"encoding/binary"
+	"fmt"
+
+	"golang.org/x/crypto/chacha20"
+	"golang.org/x/crypto/chacha20poly1305"
+)
+
+const (
+	// rekeyInterval is the number of messages that can be encrypted or
+	// decrypted with a single key before we rotate keys.
+	rekeyInterval = 224
+
+	// keySize is the size of the keys used.
+	keySize = 32
+)
+
+// FSChaCha20Poly1305 is a wrapper around ChaCha20Poly1305 that changes its
+// nonce after every message and is rekeyed after every rekeying interval.
+type FSChaCha20Poly1305 struct {
+	key       []byte
+	packetCtr uint64
+	cipher    cipher.AEAD
+}
+
+// NewFSChaCha20Poly1305 creates a new instance of FSChaCha20Poly1305.
+func NewFSChaCha20Poly1305(initialKey []byte) (*FSChaCha20Poly1305, error) {
+	cipher, err := chacha20poly1305.New(initialKey)
+	if err != nil {
+		return nil, err
+	}
+
+	f := &FSChaCha20Poly1305{
+		key:       initialKey,
+		packetCtr: 0,
+		cipher:    cipher,
+	}
+
+	return f, nil
+}
+
+// Encrypt encrypts the plaintext using the associated data, returning the
+// ciphertext or an error.
+func (f *FSChaCha20Poly1305) Encrypt(aad, plaintext []byte) ([]byte, error) {
+	return f.crypt(aad, plaintext, false)
+}
+
+// Decrypt decrypts the ciphertext using the assosicated data, returning the
+// plaintext or an error.
+func (f *FSChaCha20Poly1305) Decrypt(aad, ciphertext []byte) ([]byte, error) {
+	return f.crypt(aad, ciphertext, true)
+}
+
+// crypt takes the aad and plaintext/ciphertext and either encrypts or decrypts
+// `text` and returns the result. If a failure was encountered, an error will
+// be returned.
+func (f *FSChaCha20Poly1305) crypt(aad, text []byte,
+	decrypt bool) ([]byte, error) {
+
+	// The nonce is constructed as the 4-byte little-endian encoding of the
+	// number of messages crypted with the current key followed by the 8-byte
+	// little-endian encoding of the number of re-keying performed.
+	var nonce [12]byte
+	numMsgs := uint32(f.packetCtr % rekeyInterval)
+	numRekeys := uint64(f.packetCtr / rekeyInterval)
+	binary.LittleEndian.PutUint32(nonce[0:4], numMsgs)
+	binary.LittleEndian.PutUint64(nonce[4:12], numRekeys)
+
+	var result []byte
+	if decrypt {
+		// Decrypt using the nonce, ciphertext, and aad.
+		var err error
+		result, err = f.cipher.Open(nil, nonce[:], text, aad)
+		if err != nil {
+			// It is ok to error here without incrementing packetCtr because
+			// we will no longer be decrypting any more messages.
+			return nil, err
+		}
+	} else {
+		// Encrypt using the nonce, plaintext, and aad.
+		result = f.cipher.Seal(nil, nonce[:], text, aad)
+	}
+
+	f.packetCtr++
+
+	// Rekey if we are at the rekeying interval.
+	if f.packetCtr%rekeyInterval == 0 {
+		var rekeyNonce [12]byte
+		rekeyNonce[0] = 0xff
+		rekeyNonce[1] = 0xff
+		rekeyNonce[2] = 0xff
+		rekeyNonce[3] = 0xff
+		copy(rekeyNonce[4:], nonce[4:])
+		var dummyPlaintext [32]byte
+		f.key = f.cipher.Seal(nil, rekeyNonce[:], dummyPlaintext[:], nil)[:keySize]
+		cipher, err := chacha20poly1305.New(f.key)
+		if err != nil {
+			fmt.Println(err)
+			return nil, err
+		}
+		f.cipher = cipher
+	}
+
+	return result, nil
+}
+
+// FSChaCha20 is a stream cipher that is used to encrypt the length of the
+// packets. This cipher is rekeyed when chunkCtr reaches a multiple of
+// rekeyInterval.
+type FSChaCha20 struct {
+	key      []byte
+	chunkCtr uint64
+	cipher   *chacha20.Cipher
+}
+
+// NewFSChaCha20 initializes a new FSChaCha20 cipher instance.
+func NewFSChaCha20(initialKey []byte) (*FSChaCha20, error) {
+	var initialNonce [12]byte
+	binary.LittleEndian.PutUint64(initialNonce[4:12], 0)
+
+	cipher, err := chacha20.NewUnauthenticatedCipher(
+		initialKey, initialNonce[:],
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &FSChaCha20{
+		key:      initialKey,
+		chunkCtr: 0,
+		cipher:   cipher,
+	}, nil
+}
+
+// Crypt is used to either encrypt or decrypt text. This function is used for
+// both encryption and decryption as the two operations are identical.
+func (f *FSChaCha20) Crypt(text []byte) ([]byte, error) {
+	// XOR the text with the keystream to get either the cipher or plaintext.
+	textLen := len(text)
+	dst := make([]byte, textLen)
+	f.cipher.XORKeyStream(dst, text)
+
+	// Increment the chunkCtr every time this function is called.
+	f.chunkCtr++
+
+	// Check if we need to rekey.
+	if f.chunkCtr%rekeyInterval == 0 {
+		// Get the new key by getting 32 bytes from the keystream. Use all 0's
+		// so that we can get the actual bytes from XORKeyStream since the
+		// chacha20 library doesn't supply us with the keystream's bytes
+		// directly.
+		var dummyXor [32]byte
+		var newKey [32]byte
+		f.cipher.XORKeyStream(newKey[:], dummyXor[:])
+		f.key = newKey[:]
+
+		var nonce [12]byte
+		numRekeys := f.chunkCtr / rekeyInterval
+		binary.LittleEndian.PutUint64(nonce[4:12], numRekeys)
+
+		cipher, err := chacha20.NewUnauthenticatedCipher(
+			f.key, nonce[:],
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		f.cipher = cipher
+	}
+
+	return dst, nil
+}
diff --git a/v2transport/go.mod b/v2transport/go.mod
new file mode 100644
index 00000000..8e616519
--- /dev/null
+++ b/v2transport/go.mod
@@ -0,0 +1,18 @@
+module v2transport
+
+go 1.23.2
+
+require (
+	github.com/btcsuite/btcd v0.24.2
+	github.com/btcsuite/btcd/btcec/v2 v2.3.4
+	github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f
+	golang.org/x/crypto v0.25.0
+)
+
+require (
+	github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
+	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
+	golang.org/x/sys v0.22.0 // indirect
+)
+
+replace github.com/btcsuite/btcd/btcec/v2 => ./../btcec
diff --git a/v2transport/go.sum b/v2transport/go.sum
new file mode 100644
index 00000000..14b1fbae
--- /dev/null
+++ b/v2transport/go.sum
@@ -0,0 +1,21 @@
+github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
+github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo=
+github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/v2transport/log.go b/v2transport/log.go
new file mode 100644
index 00000000..d151b81e
--- /dev/null
+++ b/v2transport/log.go
@@ -0,0 +1,32 @@
+// Copyright (c) 2025 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package v2transport
+
+import (
+	"github.com/btcsuite/btclog"
+)
+
+const Subsystem = "V2TR"
+
+// log is a logger that is initialized with no output filters.  This
+// means the package will not perform any logging by default until the caller
+// requests it.
+var log btclog.Logger
+
+// The default amount of logging is none.
+func init() {
+	DisableLog()
+}
+
+// DisableLog disables all library log output.  Logging output is disabled
+// by default until UseLogger is called.
+func DisableLog() {
+	log = btclog.Disabled
+}
+
+// UseLogger uses a specified Logger to output package logging info.
+func UseLogger(logger btclog.Logger) {
+	log = logger
+}
diff --git a/v2transport/transport.go b/v2transport/transport.go
new file mode 100644
index 00000000..7a3f9d71
--- /dev/null
+++ b/v2transport/transport.go
@@ -0,0 +1,929 @@
+package v2transport
+
+import (
+	"bytes"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"sync/atomic"
+
+	"golang.org/x/crypto/hkdf"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/btcec/v2/ellswift"
+	"github.com/btcsuite/btcd/wire"
+)
+
+// packetBit is a type used to represent the bits in the packet's header.
+type packetBit uint8
+
+const (
+	// ignoreBitPos is the position of the ignore bit in the packet's
+	// header.
+	ignoreBitPos packetBit = 7
+)
+
+const (
+	// garbageSize is the length in bytes of the garbage terminator that
+	// each party sends.
+	garbageSize = 16
+
+	// MaxGarbageLen is the maximum size of garbage that either peer is
+	// allowed to send.
+	MaxGarbageLen = 4095
+
+	// maxContentLen is the maximum length of content that can be encrypted
+	// or decrypted.
+	//
+	// TODO: This should be revisited. For some reason, the test vectors
+	// want us to encrypt 16777215 bytes even though bitcoind will only
+	// decrypt up to 1 + 12 + 4_000_000 bytes by default.
+	maxContentLen = 1<<24 - 1
+
+	// lengthFieldLen is the length of the length field when encrypting the
+	// content's length.
+	lengthFieldLen = 3
+
+	// headerLen is the length of the header field. It is composed of a
+	// single byte with only the ignoreBitPos having any meaning.
+	headerLen = 1
+
+	// chachapoly1305Expansion is the difference in bytes between the
+	// plaintext and ciphertext when using chachapoly1305. The ciphertext
+	// is larger because of the authentication tag.
+	chachapoly1305Expansion = 16
+)
+
+var (
+	// transportVersion is the transport version we are currently using.
+	transportVersion = []byte{}
+
+	// errInsufficientBytes is returned when we haven't received enough
+	// bytes to populate their ElligatorSwift encoded public key.
+	errInsufficientBytes = fmt.Errorf("insufficient bytes received")
+
+	// ErrUseV1Protocol is returned when the initiating peer is attempting
+	// to use the V1 protocol.
+	ErrUseV1Protocol = fmt.Errorf("use v1 protocol instead")
+
+	// errWrongNetV1Peer is returned when a v1 peer is using the wrong
+	// network.
+	errWrongNetV1Peer = fmt.Errorf("peer is v1 and using the wrong network")
+
+	// errGarbageTermNotRecv is returned when a v2 peer never sends us their
+	// garbage terminator.
+	errGarbageTermNotRecv = fmt.Errorf("no garbage term received")
+
+	// errContentLengthExceeded is returned when trying to encrypt or decrypt
+	// more than the maximum content length.
+	errContentLengthExceeded = fmt.Errorf("maximum content length exceeded")
+
+	// errFailedToRecv is returned when a Read call fails.
+	errFailedToRecv = fmt.Errorf("failed to recv data")
+
+	// errPrefixTooLarge is returned if receivedPrefix is ever too large.
+	// This shouldn't happen unless the API is mis-used.
+	errPrefixTooLarge = fmt.Errorf("prefix too large - internal error")
+
+	// errGarbageTooLarge is returned if a caller attempts to send garbage
+	// larger than normal.
+	errGarbageTooLarge = fmt.Errorf("garbage too large")
+
+	// ErrShouldDowngradeToV1 is returned when we send the peer our
+	// ellswift key and they immediately hang up. This indicates that they
+	// don't understand v2 transport and interpreted the 64-byte key as a
+	// v1 message header + message. This will (always?) decode to an
+	// invalid command and checksum. The caller should try connecting to
+	// the peer with the OG v1 transport.
+	ErrShouldDowngradeToV1 = fmt.Errorf("should downgrade to v1")
+)
+
+// Peer defines the components necessary for sending/receiving data over the v2
+// transport.
+type Peer struct {
+	// privkeyOurs is our private key
+	privkeyOurs *btcec.PrivateKey
+
+	// ellswiftOurs is our ElligatorSwift-encoded public key.
+	ellswiftOurs [64]byte
+
+	// sentGarbage is the garbage sent after the public key. This may be up
+	// to
+	// 4095 bytes.
+	sentGarbage []byte
+
+	// receivedPrefix is used to determine which transport protocol we're
+	// using.
+	receivedPrefix []byte
+
+	// sendL is the cipher used to send encrypted packet lengths.
+	sendL *FSChaCha20
+
+	// sendP is the cipher used to send encrypted packets.
+	sendP *FSChaCha20Poly1305
+
+	// sendGarbageTerm is the garbage terminator that we send.
+	sendGarbageTerm [garbageSize]byte
+
+	// recvL is the cipher used to receive encrypted packet lengths.
+	recvL *FSChaCha20
+
+	// recvP is the cipher used to receive encrypted packets.
+	recvP *FSChaCha20Poly1305
+
+	// recvGarbageTerm is the garbage terminator our peer sends.
+	recvGarbageTerm []byte
+
+	// initiatorL is the key used to seed the sendL cipher.
+	initiatorL []byte
+
+	// initiatorP is the key used to seed the sendP cipher.
+	initiatorP []byte
+
+	// responderL is the key used to seed the recvL cipher.
+	responderL []byte
+
+	// responderP is the key used to seed the recvP cipher.
+	responderP []byte
+
+	// sessionID uniquely identifies this encrypted channel. It is
+	// currently only used in the test vectors.
+	sessionID []byte
+
+	// shouldDowngradeToV1 is true if the handshake failed in a way that
+	// indicates the peer does not support v2, and a v1 attempt should be made.
+	shouldDowngradeToV1 atomic.Bool
+
+	// rw is the underlying object that will be read from / written to in
+	// calls to V2EncPacket and V2ReceivePacket.
+	rw io.ReadWriter
+}
+
+// NewPeer returns a new instance of Peer.
+func NewPeer() *Peer {
+	// The keys (initiatorL, initiatorP, responderL, responderP) as well as
+	// the sessionID must have space for the hkdf Expand-derived Reader to
+	// work.
+	return &Peer{
+		receivedPrefix: make([]byte, 0),
+		initiatorL:     make([]byte, 32),
+		initiatorP:     make([]byte, 32),
+		responderL:     make([]byte, 32),
+		responderP:     make([]byte, 32),
+		sessionID:      make([]byte, 32),
+	}
+}
+
+// createV2Ciphers constructs the packet-length and packet encryption ciphers.
+func (p *Peer) createV2Ciphers(ecdhSecret []byte, initiating bool,
+	net wire.BitcoinNet) error {
+
+	log.Debugf("Creating v2 ciphers (initiating=%v, net=%v)", initiating,
+		net)
+
+	// Define the salt as the string "bitcoin_v2_shared_secret" followed by
+	// the BitcoinNet's "magic" bytes.
+	salt := []byte("bitcoin_v2_shared_secret")
+
+	var magic [4]byte
+	binary.LittleEndian.PutUint32(magic[:], uint32(net))
+	salt = append(salt, magic[:]...)
+
+	// Use the hkdf Extract function to generate a pseudo-random key.
+	prk := hkdf.Extract(sha256.New, ecdhSecret, salt)
+
+	log.Tracef("Using salt=%x for HKDF-Extract", salt)
+
+	// Use the hkdf Expand function with info set to "session_id" to generate a
+	// unique sessionID.
+	sessionInfo := []byte("session_id")
+	sessionReader := hkdf.Expand(sha256.New, prk, sessionInfo)
+	_, err := sessionReader.Read(p.sessionID)
+	if err != nil {
+		log.Errorf("Failed to derive session_id: %v", err)
+		return err
+	}
+
+	log.Tracef("Using prk=%x for HKDF-Expand", prk)
+
+	log.Tracef("Derived session_id=%x", p.sessionID)
+
+	// Use the Expand operation to generate packet and packet-length encryption
+	// ciphers.
+	initiatorLInfo := []byte("initiator_L")
+	initiatorLReader := hkdf.Expand(sha256.New, prk, initiatorLInfo)
+	_, err = initiatorLReader.Read(p.initiatorL)
+	if err != nil {
+		log.Errorf("Failed to derive initiator_L: %v", err)
+		return err
+	}
+
+	log.Tracef("Derived initiator_L=%x", p.initiatorL)
+
+	initiatorPInfo := []byte("initiator_P")
+	initiatorPReader := hkdf.Expand(sha256.New, prk, initiatorPInfo)
+	_, err = initiatorPReader.Read(p.initiatorP)
+	if err != nil {
+		log.Errorf("Failed to derive initiator_P: %v", err)
+		return err
+	}
+
+	log.Tracef("Derived initiator_P=%x", p.initiatorP)
+
+	responderLInfo := []byte("responder_L")
+	responderLReader := hkdf.Expand(sha256.New, prk, responderLInfo)
+	_, err = responderLReader.Read(p.responderL)
+	if err != nil {
+		log.Errorf("Failed to derive responder_L: %v", err)
+		return err
+	}
+
+	log.Tracef("Derived responder_L=%x", p.responderL)
+
+	responderPInfo := []byte("responder_P")
+	responderPReader := hkdf.Expand(sha256.New, prk, responderPInfo)
+	_, err = responderPReader.Read(p.responderP)
+	if err != nil {
+		log.Errorf("Failed to derive responder_P: %v", err)
+		return err
+	}
+
+	log.Tracef("Derived responder_P=%x", p.responderP)
+
+	// Create the garbage terminators that each side will use.
+	garbageInfo := []byte("garbage_terminators")
+	garbageReader := hkdf.Expand(sha256.New, prk, garbageInfo)
+	garbageTerminators := make([]byte, 32)
+	_, err = garbageReader.Read(garbageTerminators)
+	if err != nil {
+		log.Errorf("Failed to derive garbage terminators: %v", err)
+		return err
+	}
+
+	initiatorGarbageTerminator := garbageTerminators[:garbageSize]
+	responderGarbageTerminator := garbageTerminators[garbageSize:]
+
+	log.Tracef("Derived initiator garbage terminator=%x",
+		initiatorGarbageTerminator)
+
+	log.Tracef("Derived responder garbage terminator=%x",
+		responderGarbageTerminator)
+
+	if initiating {
+		p.sendL, err = NewFSChaCha20(p.initiatorL)
+		if err != nil {
+			log.Errorf("Failed to create sendL cipher: %v", err)
+			return err
+		}
+
+		p.sendP, err = NewFSChaCha20Poly1305(p.initiatorP)
+		if err != nil {
+			log.Errorf("Failed to create sendP cipher: %v", err)
+			return err
+		}
+
+		copy(p.sendGarbageTerm[:], initiatorGarbageTerminator)
+
+		p.recvL, err = NewFSChaCha20(p.responderL)
+		if err != nil {
+			log.Errorf("Failed to create recvL cipher: %v", err)
+			return err
+		}
+
+		p.recvP, err = NewFSChaCha20Poly1305(p.responderP)
+		if err != nil {
+			log.Errorf("Failed to create recvP cipher: %v", err)
+			return err
+		}
+
+		p.recvGarbageTerm = responderGarbageTerminator
+
+		log.Debugf("Initiator ciphers created (sendL, sendP, recvL, recvP)")
+	} else {
+		p.sendL, err = NewFSChaCha20(p.responderL)
+		if err != nil {
+			log.Errorf("Failed to create sendL cipher: %v", err)
+			return err
+		}
+
+		p.sendP, err = NewFSChaCha20Poly1305(p.responderP)
+		if err != nil {
+			log.Errorf("Failed to create sendP cipher: %v", err)
+			return err
+		}
+
+		copy(p.sendGarbageTerm[:], responderGarbageTerminator)
+
+		p.recvL, err = NewFSChaCha20(p.initiatorL)
+		if err != nil {
+			log.Errorf("Failed to create recvL cipher: %v", err)
+			return err
+		}
+
+		p.recvP, err = NewFSChaCha20Poly1305(p.initiatorP)
+		if err != nil {
+			log.Errorf("Failed to create recvP cipher: %v", err)
+			return err
+		}
+
+		p.recvGarbageTerm = initiatorGarbageTerminator
+
+		log.Debugf("Responder ciphers created (sendL, sendP, recvL, recvP)")
+	}
+
+	// TODO:
+	//     To achieve forward secrecy we must wipe the key material used to initialize the ciphers:
+	//     memory_cleanse(ecdhSecret, prk, initiator_L, initiator_P, responder_L, responder_K)
+	// - golang analogue?
+
+	return nil
+}
+
+// InitiateV2Handshake generates our private key and sends our public key as
+// well as garbage data to our peer.
+func (p *Peer) InitiateV2Handshake(garbageLen int) error {
+	log.Debugf("Initiating v2 handshake (garbageLen=%d)", garbageLen)
+
+	var err error
+	p.privkeyOurs, p.ellswiftOurs, err = ellswift.EllswiftCreate()
+	if err != nil {
+		log.Errorf("Failed to create ellswift keypair: %v", err)
+		return err
+	}
+
+	log.Tracef("Created ellswift keypair, pubkey=%x", p.ellswiftOurs)
+
+	data, err := p.generateKeyAndGarbage(garbageLen)
+	if err != nil {
+		return err
+	}
+
+	log.Debugf("Sending ellswift pubkey and garbage (total_len=%d)",
+		len(data))
+
+	p.Send(data)
+
+	return nil
+}
+
+// RespondV2Handshake responds to the initiator, determines if the initiator
+// wants to use the v2 protocol and if so returns our ElligatorSwift-encoded
+// public key followed by our garbage data over. If the initiator does not want
+// to use the v2 protocol, we'll instead revert to the v1 protocol.
+func (p *Peer) RespondV2Handshake(garbageLen int, net wire.BitcoinNet) error {
+	v1Prefix := createV1Prefix(net)
+
+	log.Debugf("Responding to v2 handshake (garbageLen=%d, net=%v)",
+		garbageLen, net)
+
+	log.Tracef("Expecting v1 prefix: %x", v1Prefix)
+
+	var err error
+
+	// Check and see if the received bytes match the v1 protocol's message
+	// prefix. If it does, we'll revert to the v1 protocol. If it doesn't,
+	// we'll treat this as a v2 peer.
+	for len(p.receivedPrefix) < len(v1Prefix) {
+		log.Tracef("Received prefix len=%d, need=%d",
+			len(p.receivedPrefix), len(v1Prefix))
+
+		var receiveBytes []byte
+		receiveBytes, _, err = p.Receive(1)
+		if err != nil {
+			log.Errorf("Failed to receive byte for v1 prefix "+
+				"check: %v", err)
+			return err
+		}
+
+		log.Tracef("Current received prefix: %x", p.receivedPrefix)
+
+		p.receivedPrefix = append(p.receivedPrefix, receiveBytes...)
+
+		lastIdx := len(p.receivedPrefix) - 1
+
+		if p.receivedPrefix[lastIdx] != v1Prefix[lastIdx] {
+			log.Debugf("Received byte %x does not match v1 "+
+				"prefix at index %d, assuming v2 peer",
+				p.receivedPrefix[lastIdx], lastIdx)
+
+			p.privkeyOurs, p.ellswiftOurs, err = ellswift.EllswiftCreate()
+			if err != nil {
+				log.Errorf("Failed to create ellswift "+
+					"keypair: %v", err)
+				return err
+			}
+
+			log.Tracef("Created ellswift keypair, pubkey=%x",
+				p.ellswiftOurs)
+
+			data, err := p.generateKeyAndGarbage(garbageLen)
+			if err != nil {
+				return err
+			}
+
+			// Send over our ElligatorSwift-encoded pubkey followed by our
+			// randomly generated garbage.
+			log.Debugf("Sending ellswift pubkey and garbage "+
+				"(total_len=%d)", len(data))
+			p.Send(data)
+
+			return nil
+		}
+	}
+
+	log.Infof("Received full v1 prefix match, reverting to v1 protocol")
+
+	return ErrUseV1Protocol
+}
+
+// generateKeyAndGarbage returns a byte slice containing our ellswift-encoded
+// public key followed by the garbage we'll send over.
+func (p *Peer) generateKeyAndGarbage(garbageLen int) ([]byte, error) {
+	log.Tracef("Generating key and garbage (garbageLen=%d)", garbageLen)
+
+	if garbageLen > MaxGarbageLen {
+		log.Errorf("Requested garbage length %d exceeds max %d",
+			garbageLen, MaxGarbageLen)
+
+		return nil, errGarbageTooLarge
+	}
+
+	p.sentGarbage = make([]byte, garbageLen)
+	_, err := rand.Read(p.sentGarbage)
+	if err != nil {
+		log.Errorf("Failed to read random bytes for garbage: %v", err)
+		return nil, err
+	}
+
+	log.Tracef("Generated %d bytes of garbage", garbageLen)
+
+	data := make([]byte, 0, 64+garbageLen)
+	data = append(data, p.ellswiftOurs[:]...)
+	data = append(data, p.sentGarbage...)
+
+	log.Tracef("Generated key and garbage data (total_len=%d)", len(data))
+
+	return data, nil
+}
+
+// createV1Prefix is a helper function that returns the first 16 bytes of the
+// version message's header.
+func createV1Prefix(net wire.BitcoinNet) []byte {
+	v1Prefix := make([]byte, 0, 4+12)
+
+	// The v1 transport protocol uses the network's 4 magic bytes followed by
+	// "version" followed by 5 bytes of 0.
+	var magic [4]byte
+	binary.LittleEndian.PutUint32(magic[:], uint32(net))
+
+	versionBytes := []byte("version\x00\x00\x00\x00\x00")
+
+	v1Prefix = append(v1Prefix, magic[:]...)
+	v1Prefix = append(v1Prefix, versionBytes...)
+
+	return v1Prefix
+}
+
+// CompleteHandshake finishes the v2 protocol negotiation and optionally sends
+// decoy packets after sending the garbage terminator.
+func (p *Peer) CompleteHandshake(initiating bool, decoyContentLens []int,
+	btcnet wire.BitcoinNet) error {
+
+	log.Debugf("Completing v2 handshake (initiating=%v, "+
+		"num_decoys=%d, net=%v)", initiating, len(decoyContentLens),
+		btcnet)
+
+	var receivedPrefix []byte
+	if initiating {
+		log.Trace("Initiator expecting 64 bytes for peer's " +
+			"ellswift key")
+
+		receivedPrefix = make([]byte, 0, 16)
+	} else {
+		// If we are the responder, we have already received bytes to
+		// compare against the v1 transport protocol's starting bytes.
+		// We have to account for these when reading the rest of the 64
+		// bytes off the wire to properly parse the remote's
+		// ellswift-encoded public key.
+		receivedPrefix = p.receivedPrefix
+
+		log.Tracef("Responder already has prefix_len=%d, expecting %d "+
+			"more bytes for peer's ellswift key",
+			len(receivedPrefix), 64-len(receivedPrefix))
+	}
+
+	recvData, numRead, err := p.Receive(64 - len(receivedPrefix))
+	if err != nil {
+		// If we receive an error when reading off the wire and we read
+		// zero bytes, then we will reconnect to the peer using v1.
+		// There are several different errors that Receive can return
+		// that indicate we should reconnect. Instead of special-casing
+		// them all, just perform these checks if any error was
+		// returned.
+		if numRead == 0 && initiating {
+			// The peer most likely attempted to parse our 64-byte
+			// elligator-swift key as a version message and failed
+			// when trying to parse the message header into
+			// something valid. In this case, return a special
+			// error that signals to the server that we can
+			// reconnect with the OG v1 scheme.
+			log.Debugf("Received transport error during " +
+				"v2 handshake, retying downgraded v1 " +
+				"connection.")
+
+			p.shouldDowngradeToV1.Store(true)
+
+			return ErrShouldDowngradeToV1
+		}
+
+		// If we are the recipient, we can fail.
+		log.Errorf("Failed to receive peer's ellswift key data: %v",
+			err)
+		return err
+	}
+
+	log.Tracef("Received %d bytes for peer's ellswift key", len(recvData))
+
+	var ellswiftTheirs [64]byte
+
+	if initiating {
+		// If we are initiating, read all 64 bytes into ellswiftTheirs.
+		copy(ellswiftTheirs[:], recvData)
+	} else {
+		// If we are the responder, then we need to account for the bytes
+		// already received as part of matching against the starting v1
+		// transport bytes. We sanity check receivedPrefix in case it is too
+		// large for some reason.
+		prefixLen := len(receivedPrefix)
+		if prefixLen > 16 {
+			log.Errorf("Responder's received prefix length %d is "+
+				"too large (> 16)", prefixLen)
+
+			return errPrefixTooLarge
+		}
+
+		copy(ellswiftTheirs[:], receivedPrefix)
+		copy(ellswiftTheirs[prefixLen:], recvData)
+	}
+
+	log.Tracef("Assembled peer's ellswift key: %x", ellswiftTheirs)
+
+	// Calculate the v1 protocol's message prefix and see if the bytes read
+	// read into ellswiftTheirs matches it.
+	v1Prefix := createV1Prefix(btcnet)
+
+	// ellswiftTheirs should be at least 16 bytes if receive succeeded, but
+	// just in case, check the size.
+	if len(ellswiftTheirs) < 16 {
+		log.Errorf("Received insufficient bytes (%d) for "+
+
+			"ellswift key", len(ellswiftTheirs))
+		return errInsufficientBytes
+	}
+
+	if !initiating && bytes.Equal(ellswiftTheirs[4:16], v1Prefix[4:16]) {
+		log.Warnf("Peer sent v1 version message for wrong network "+
+			"(expected %v)", btcnet)
+		return errWrongNetV1Peer
+	}
+
+	log.Debug("Calculating ECDH shared secret")
+
+	// Calculate the shared secret to be used in creating the packet ciphers.
+	ecdhSecret, err := ellswift.V2Ecdh(
+		p.privkeyOurs, ellswiftTheirs, p.ellswiftOurs, initiating,
+	)
+	if err != nil {
+		log.Errorf("Failed to calculate ECDH shared secret: %v", err)
+		return err
+	}
+
+	log.Tracef("Calculated ECDH shared secret: %x", ecdhSecret)
+
+	err = p.createV2Ciphers(ecdhSecret[:], initiating, btcnet)
+	if err != nil {
+		// createV2Ciphers logs its own errors
+		return err
+	}
+
+	// Send garbage terminator.
+	log.Debugf("Sending garbage terminator: %x", p.sendGarbageTerm)
+	p.Send(p.sendGarbageTerm[:])
+
+	// Optionally send decoy packets after garbage terminator.
+	aad := p.sentGarbage
+	for i := 0; i < len(decoyContentLens); i++ {
+		log.Tracef("Sending decoy packet %d (content_len=%d)",
+			i+1, decoyContentLens[i])
+
+		decoyContent := make([]byte, decoyContentLens[i])
+
+		encPacket, _, err := p.V2EncPacket(decoyContent, aad, true)
+		if err != nil {
+			log.Errorf("Failed to encrypt/send decoy "+
+				"packet %d: %v", i+1, err)
+			return err
+		}
+
+		p.Send(encPacket)
+
+		// AAD is only used for the first packet after the handshake.
+		aad = nil
+	}
+
+	// Send version packet.
+	log.Debug("Sending version packet")
+	_, _, err = p.V2EncPacket(transportVersion, aad, false)
+	if err != nil {
+		log.Errorf("Failed to encrypt/send version packet: %v", err)
+		return err
+	}
+
+	log.Debugf("Receiving garbage, looking for terminator: %x",
+		p.recvGarbageTerm)
+
+	// Skip garbage until encountering garbage terminator.
+	recvGarbage, _, err := p.Receive(16)
+	if err != nil {
+		log.Errorf("Failed to receive initial 16 bytes of "+
+			"garbage: %v", err)
+		return err
+	}
+
+	log.Tracef("Received initial garbage chunk: %x", recvGarbage)
+
+	// The BIP text states that we can read up to 4111 bytes. We've already
+	// read 16 bytes for the garbage terminator, so we need to read up to
+	// maxGarbageLen more bytes.
+	for i := 0; i < MaxGarbageLen; i++ {
+		recvGarbageLen := len(recvGarbage)
+
+		if bytes.Equal(recvGarbage[recvGarbageLen-16:],
+			p.recvGarbageTerm) {
+
+			log.Debugf("Found garbage terminator after %d total "+
+				"bytes", recvGarbageLen)
+
+			log.Tracef("Processing %d bytes preceding garbage "+
+				"terminator", recvGarbageLen-16)
+
+			// Process any potential packet data sent before the
+			// terminator.
+			_, err = p.V2ReceivePacket(
+				recvGarbage[:recvGarbageLen-16],
+			)
+			if err != nil {
+				log.Errorf("Error processing packet data "+
+					"before garbage terminator: %v", err)
+			}
+			return err
+		}
+
+		log.Tracef("Garbage terminator not found, receiving 1 more "+
+			"byte (total_received=%d)", recvGarbageLen)
+
+		recvData, _, err := p.Receive(1)
+		if err != nil {
+			log.Errorf("Failed to receive garbage "+
+				"byte %d: %v", recvGarbageLen+1, err)
+			return err
+		}
+
+		recvGarbage = append(recvGarbage, recvData...)
+	}
+
+	log.Warnf("Garbage terminator not received after %d "+
+		"bytes", len(recvGarbage))
+
+	return errGarbageTermNotRecv
+}
+
+// V2EncPacket takes the contents and aad and returns a ciphertext.
+func (p *Peer) V2EncPacket(contents []byte, aad []byte, ignore bool) ([]byte,
+	int, error) {
+
+	log.Tracef("Encrypting packet (content_len=%d, aad_len=%d, ignore=%v)",
+		len(contents), len(aad), ignore)
+
+	contentLen := len(contents)
+
+	if contentLen > maxContentLen {
+		log.Errorf("Content length %d exceeds max %d",
+			contentLen, maxContentLen)
+
+		return nil, 0, errContentLengthExceeded
+	}
+
+	// Construct the packet's header based on whether or not the peer
+	// should ignore this packet (i.e. if this is a decoy packet).
+	ignoreNum := 0
+	if ignore {
+		ignoreNum = 1
+	}
+
+	ignoreNum <<= ignoreBitPos
+
+	header := []byte{byte(ignoreNum)}
+
+	log.Tracef("Packet header: %x", header)
+
+	plaintext := make([]byte, 0, contentLen+1)
+	plaintext = append(plaintext, header...)
+	plaintext = append(plaintext, contents...)
+
+	log.Tracef("Plaintext (header + content): %x", plaintext)
+
+	aeadCiphertext, err := p.sendP.Encrypt(aad, plaintext)
+	if err != nil {
+		log.Errorf("Failed to encrypt packet content: %v", err)
+		return nil, 0, err
+	}
+
+	log.Tracef("AEAD ciphertext: %x", aeadCiphertext)
+
+	// We cut off a byte when feeding to Crypt.
+	contentsLE := make([]byte, 4)
+	binary.LittleEndian.PutUint32(contentsLE, uint32(contentLen))
+
+	log.Tracef("Encrypting content length %d (%x)",
+		contentLen, contentsLE[:lengthFieldLen])
+
+	encContentsLen, err := p.sendL.Crypt(contentsLE[:lengthFieldLen])
+	if err != nil {
+		log.Errorf("Failed to encrypt content length: %v", err)
+		return nil, 0, err
+	}
+
+	log.Tracef("Encrypted content length: %x", encContentsLen)
+
+	encPacket := make([]byte, 0, len(encContentsLen)+len(aeadCiphertext))
+	encPacket = append(encPacket, encContentsLen...)
+	encPacket = append(encPacket, aeadCiphertext...)
+
+	log.Tracef("Full encrypted packet: %x", encPacket)
+
+	totalBytes, err := p.Send(encPacket)
+	if err != nil {
+		log.Errorf("Failed to send encrypted packet: %v", err)
+		// Return the packet anyway, as some might have been sent.
+		return encPacket, totalBytes, err
+	}
+
+	log.Tracef("Sent %d bytes for encrypted packet", totalBytes)
+
+	return encPacket, totalBytes, err
+}
+
+// V2ReceivePacket takes the aad and decrypts a received packet.
+func (p *Peer) V2ReceivePacket(aad []byte) ([]byte, error) {
+	log.Tracef("Attempting to receive packet (aad_len=%d)", len(aad))
+
+	for {
+		log.Tracef("Receiving %d bytes for encrypted length",
+			lengthFieldLen)
+
+		// Decrypt the length field so we know how many more bytes to receive.
+		encContentsLen, _, err := p.Receive(lengthFieldLen)
+		if err != nil {
+			log.Errorf("Failed to receive encrypted length: %v",
+				err)
+			return nil, err
+		}
+
+		log.Tracef("Received encrypted length: %x", encContentsLen)
+		contentsLenBytes, err := p.recvL.Crypt(encContentsLen)
+		if err != nil {
+			log.Errorf("Failed to decrypt content length: %v", err)
+			return nil, err
+		}
+
+		log.Tracef("Decrypted content length bytes: %x",
+			contentsLenBytes)
+
+		var contentsLenLE [4]byte
+		copy(contentsLenLE[:], contentsLenBytes)
+
+		contentsLen := binary.LittleEndian.Uint32(contentsLenLE[:])
+
+		log.Tracef("Decrypted content length=%d", contentsLen)
+
+		if contentsLen > maxContentLen {
+			log.Errorf("Decrypted content length %d exceeds "+
+				"max %d", contentsLen, maxContentLen)
+
+			return nil, errContentLengthExceeded
+		}
+
+		// Decrypt the remainder of the packet.
+		numBytes := headerLen + int(contentsLen) + chachapoly1305Expansion
+
+		log.Tracef("Receiving %d bytes for encrypted packet body",
+			numBytes)
+
+		aeadCiphertext, _, err := p.Receive(numBytes)
+		if err != nil {
+			log.Errorf("Failed to receive encrypted "+
+				"packet body: %v", err)
+			return nil, err
+		}
+
+		log.Tracef("Received encrypted packet body: %x", aeadCiphertext)
+
+		plaintext, err := p.recvP.Decrypt(aad, aeadCiphertext)
+		if err != nil {
+			log.Errorf("Failed to decrypt packet body: %v", err)
+			return nil, err
+		}
+
+		log.Tracef("Decrypted plaintext (header + content): %x",
+			plaintext)
+
+		// Only the first packet is expected to have non-empty AAD. If the
+		// ignore bit is set, ignore the packet.
+		// TODO: will this cause anything to leak?
+		// AAD is only used for the first packet after the handshake.
+		aad = nil
+		header := plaintext[:headerLen]
+		log.Tracef("Packet header: %x", header)
+		if (header[0] & (1 << ignoreBitPos)) == 0 {
+			log.Tracef("Ignore bit not set, returning content "+
+				"(len=%d)", len(plaintext[headerLen:]))
+
+			return plaintext[headerLen:], nil
+		}
+
+		log.Debugf("Ignore bit set, discarding packet "+
+			"(content_len=%d)", contentsLen)
+	}
+}
+
+// ReceivedPrefix returns the partial header bytes we've already received.
+func (p *Peer) ReceivedPrefix() []byte {
+	return p.receivedPrefix
+}
+
+// ShouldDowngradeToV1 returns true if the v2 handshake failed in a way that
+// suggests the peer does not support v2 and a v1 connection should be attempted.
+func (p *Peer) ShouldDowngradeToV1() bool {
+	return p.shouldDowngradeToV1.Load()
+}
+
+// UseWriterReader uses the passed-in ReadWriter to Send/Receive to/from.
+func (p *Peer) UseReadWriter(rw io.ReadWriter) {
+	p.rw = rw
+}
+
+// Send sends data to the underlying connection. It returns the number of bytes
+// sent or an error.
+func (p *Peer) Send(data []byte) (int, error) {
+	log.Tracef("Sending %d bytes", len(data))
+	n, err := p.rw.Write(data)
+	if err != nil {
+		log.Errorf("Send failed after %d bytes: %v", n, err)
+	} else {
+		log.Tracef("Sent %d bytes successfully", n)
+	}
+	return n, err
+}
+
+// Receive receives numBytes bytes from the underlying connection.
+func (p *Peer) Receive(numBytes int) ([]byte, int, error) {
+	b := make([]byte, numBytes)
+	index := 0
+	total := 0
+
+	log.Tracef("Attempting to receive %d bytes", numBytes)
+
+	for {
+		// TODO: Use something that inherently prevents going over?
+		if total > numBytes {
+			// This should be logically impossible with io.ReadFull semantics
+			// used implicitly by the loop structure.
+			log.Criticalf("Receive logic error: total=%d > "+
+				"numBytes=%d", total, numBytes)
+			return nil, total, errFailedToRecv
+		}
+
+		if total == numBytes {
+			log.Tracef("Successfully received %d bytes", total)
+			return b, total, nil
+		}
+
+		log.Tracef("Calling Read (need %d bytes, have "+
+			"%d)", numBytes-total, total)
+
+		n, err := p.rw.Read(b[index:])
+		if err != nil {
+			log.Errorf("Receive failed after reading %d bytes "+
+				"(target %d): %v", total+n, numBytes, err)
+			return nil, total, err
+		}
+
+		log.Tracef("Read returned %d bytes", n)
+
+		total += n
+		index += n
+	}
+}
diff --git a/wire/message.go b/wire/message.go
index 95f7013e..be4089d7 100644
--- a/wire/message.go
+++ b/wire/message.go
@@ -62,6 +62,62 @@ const (
 	CmdWTxIdRelay   = "wtxidrelay"
 )
 
+var (
+	v2MessageIDs = map[uint8]string{
+		1:  CmdAddr,
+		2:  CmdBlock,
+		5:  CmdFeeFilter,
+		6:  CmdFilterAdd,
+		7:  CmdFilterClear,
+		8:  CmdFilterLoad,
+		9:  CmdGetBlocks,
+		11: CmdGetData,
+		12: CmdGetHeaders,
+		13: CmdHeaders,
+		14: CmdInv,
+		15: CmdMemPool,
+		16: CmdMerkleBlock,
+		17: CmdNotFound,
+		18: CmdPing,
+		19: CmdPong,
+		21: CmdTx,
+		22: CmdGetCFilters,
+		23: CmdCFilter,
+		24: CmdGetCFHeaders,
+		25: CmdCFHeaders,
+		26: CmdGetCFCheckpt,
+		27: CmdCFCheckpt,
+		28: CmdAddrV2,
+	}
+
+	v2Messages = map[string]uint8{
+		CmdAddr:         1,
+		CmdBlock:        2,
+		CmdFeeFilter:    5,
+		CmdFilterAdd:    6,
+		CmdFilterClear:  7,
+		CmdFilterLoad:   8,
+		CmdGetBlocks:    9,
+		CmdGetData:      11,
+		CmdGetHeaders:   12,
+		CmdHeaders:      13,
+		CmdInv:          14,
+		CmdMemPool:      15,
+		CmdMerkleBlock:  16,
+		CmdNotFound:     17,
+		CmdPing:         18,
+		CmdPong:         19,
+		CmdTx:           21,
+		CmdGetCFilters:  22,
+		CmdCFilter:      23,
+		CmdGetCFHeaders: 24,
+		CmdCFHeaders:    25,
+		CmdGetCFCheckpt: 26,
+		CmdCFCheckpt:    27,
+		CmdAddrV2:       28,
+	}
+)
+
 // MessageEncoding represents the wire message encoding format to be used.
 type MessageEncoding uint32
 
@@ -234,6 +290,33 @@ func readMessageHeader(r io.Reader) (int, *messageHeader, error) {
 	return n, &hdr, nil
 }
 
+// readPartialHeader reads a partial bitcon message header from r. It takes a
+// prefix that contains the already-parsed bytes. This is needed in the case of
+// a downgraded v2->v1 transport connection since we have already started
+// parsing the header bytes when calling into this function.
+func readPartialHeader(prefix []byte, r io.Reader) (int, *messageHeader,
+	error) {
+
+	// Fill out the messageHeader with the network magic from the prefix.
+	hdr := messageHeader{}
+
+	var command [CommandSize]byte
+	prefixReader := bytes.NewReader(prefix[:])
+	if err := readElements(prefixReader, &hdr.magic, &command); err != nil {
+		return 0, nil, err
+	}
+
+	// Strip trailing zeros from command string.
+	hdr.command = string(bytes.TrimRight(command[:], "\x00"))
+
+	// Read the rest of the message header from the passed-in reader.
+	if err := readElements(r, &hdr.length, &hdr.checksum); err != nil {
+		return 0, nil, err
+	}
+
+	return MessageHeaderSize, &hdr, nil
+}
+
 // discardInput reads n bytes from reader r in chunks and discards the read
 // bytes.  This is used to skip payloads when various errors occur and helps
 // prevent rogue nodes from causing massive memory allocation through forging
@@ -271,6 +354,80 @@ func WriteMessage(w io.Writer, msg Message, pver uint32, btcnet BitcoinNet) erro
 	return err
 }
 
+// WriteV2MessageN writes a Message to the passed Writer using the bip324
+// v2 encoding.
+func WriteV2MessageN(w io.Writer, msg Message, pver uint32,
+	encoding MessageEncoding) (int, error) {
+
+	var totalBytes int
+
+	cmd := msg.Command()
+	if len(cmd) > CommandSize {
+		str := fmt.Sprintf("command [%s] is too long [max %v]",
+			cmd, CommandSize)
+		return totalBytes, messageError("WriteMessage", str)
+	}
+
+	index, exists := v2Messages[cmd]
+	if !exists {
+		var command [CommandSize]byte
+		copy(command[:], cmd)
+		hw := bytes.NewBuffer(make([]byte, 0, CommandSize+1))
+		writeElements(hw, byte(0x00), command)
+
+		n, err := w.Write(hw.Bytes())
+		if err != nil {
+			return 0, err
+		}
+
+		totalBytes += n
+	} else {
+		hw := bytes.NewBuffer(make([]byte, 0, 1))
+		writeElement(hw, index)
+
+		n, err := w.Write(hw.Bytes())
+		if err != nil {
+			return 0, err
+		}
+
+		totalBytes += n
+	}
+
+	var bw bytes.Buffer
+	err := msg.BtcEncode(&bw, pver, encoding)
+	if err != nil {
+		return totalBytes, err
+	}
+
+	payload := bw.Bytes()
+	lenp := len(payload)
+
+	// Enforce maximum overall message payload.
+	if lenp > MaxMessagePayload {
+		str := fmt.Sprintf("message payload is too large - encoded "+
+			"%d bytes, but maximum message payload is %d bytes",
+			lenp, MaxMessagePayload)
+		return totalBytes, messageError("WriteMessage", str)
+	}
+
+	mpl := msg.MaxPayloadLength(pver)
+	if uint32(lenp) > mpl {
+		str := fmt.Sprintf("message payload is too large - encoded "+
+			"%d bytes, but maximum message payload size for "+
+			"messages of type [%s] is %d.", lenp, cmd, mpl)
+		return totalBytes, messageError("WriteMessage", str)
+	}
+
+	if len(payload) > 0 {
+		n, err := w.Write(payload)
+		totalBytes += n
+
+		return totalBytes, err
+	}
+
+	return totalBytes, nil
+}
+
 // WriteMessageWithEncodingN writes a bitcoin Message to w including the
 // necessary header information and returns the number of bytes written.
 // This function is the same as WriteMessageN except it also allows the caller
@@ -347,6 +504,57 @@ func WriteMessageWithEncodingN(w io.Writer, msg Message, pver uint32,
 	return totalBytes, err
 }
 
+// ReadV2MessageN takes the passed plaintext and attempts to construct a
+// Message from the bytes using the bip324 v2 encoding.
+func ReadV2MessageN(plaintext []byte, pver uint32, enc MessageEncoding) (
+	Message, []byte, error) {
+
+	if len(plaintext) == 0 {
+		return nil, nil, fmt.Errorf("invalid plaintext length")
+	}
+
+	var msgCmd string
+
+	// If the first byte is 0x00, read the next 12 bytes to determine what
+	// message this is.
+	if plaintext[0] == 0x00 {
+		if len(plaintext) < CommandSize+1 {
+			return nil, nil, fmt.Errorf("invalid plaintext length")
+		}
+
+		// Slice off the first 0x00 and the trailing 0x00 bytes.
+		var command [CommandSize]byte
+		copy(command[:], plaintext[1:CommandSize+1])
+
+		msgCmd = string(bytes.TrimRight(command[:], "\x00"))
+
+		plaintext = plaintext[CommandSize+1:]
+	} else {
+		// The first byte denotes what message this is.
+		msgCmd = v2MessageIDs[plaintext[0]]
+
+		plaintext = plaintext[1:]
+	}
+
+	msg, err := makeEmptyMessage(msgCmd)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	mpl := msg.MaxPayloadLength(pver)
+	if len(plaintext) > int(mpl) {
+		return nil, nil, fmt.Errorf("payload exceeds max length")
+	}
+
+	buf := bytes.NewBuffer(plaintext)
+	err = msg.BtcDecode(buf, pver, enc)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return msg, plaintext, nil
+}
+
 // ReadMessageWithEncodingN reads, validates, and parses the next bitcoin Message
 // from r for the provided protocol version and bitcoin network.  It returns the
 // number of bytes read in addition to the parsed Message and raw bytes which
@@ -363,6 +571,38 @@ func ReadMessageWithEncodingN(r io.Reader, pver uint32, btcnet BitcoinNet,
 		return totalBytes, nil, nil, err
 	}
 
+	return readMessageWithEncodingNInternal(
+		r, pver, hdr, btcnet, enc, totalBytes,
+	)
+}
+
+// ReadPartialMessageWithEncodingN is used in the case that we are expecting a
+// v2 connection and then receive a v1 version header. In this case, we
+// downgrade the implicit v2 connection to a v1 connection and must parse the
+// rest of the version bytes and check it properly.
+func ReadPartialMessageWithEncodingN(r io.Reader, pver uint32,
+	btcnet BitcoinNet, enc MessageEncoding, prefix []byte) (int, Message,
+	[]byte, error) {
+
+	totalBytes := 0
+	n, hdr, err := readPartialHeader(prefix, r)
+	totalBytes += n
+	if err != nil {
+		return totalBytes, nil, nil, err
+	}
+
+	return readMessageWithEncodingNInternal(
+		r, pver, hdr, btcnet, enc, totalBytes,
+	)
+}
+
+// readMessageWithEncodingNInternal is used to deduplicate the code because we
+// typically parse messages and headers all at once except in the case of a
+// downgraded v2->v1 conncection.
+func readMessageWithEncodingNInternal(r io.Reader, pver uint32,
+	hdr *messageHeader, btcnet BitcoinNet, enc MessageEncoding,
+	totalBytes int) (int, Message, []byte, error) {
+
 	// Enforce maximum message payload.
 	if hdr.length > MaxMessagePayload {
 		str := fmt.Sprintf("message payload is too large - header "+
@@ -410,7 +650,7 @@ func ReadMessageWithEncodingN(r io.Reader, pver uint32, btcnet BitcoinNet,
 
 	// Read payload.
 	payload := make([]byte, hdr.length)
-	n, err = io.ReadFull(r, payload)
+	n, err := io.ReadFull(r, payload)
 	totalBytes += n
 	if err != nil {
 		return totalBytes, nil, nil, err
diff --git a/wire/protocol.go b/wire/protocol.go
index 65d19f52..dbe66b17 100644
--- a/wire/protocol.go
+++ b/wire/protocol.go
@@ -103,6 +103,10 @@ const (
 	// SFNodeNetWorkLimited is a flag used to indicate a peer supports serving
 	// the last 288 blocks.
 	SFNodeNetworkLimited = 1 << 10
+
+	// SFNodeP2PV2 is a flag used to indicate a peer supports BIP324 v2
+	// connections.
+	SFNodeP2PV2 = 1 << 11
 )
 
 // Map of service flags back to their constant names for pretty printing.
@@ -116,6 +120,7 @@ var sfStrings = map[ServiceFlag]string{
 	SFNodeCF:             "SFNodeCF",
 	SFNode2X:             "SFNode2X",
 	SFNodeNetworkLimited: "SFNodeNetworkLimited",
+	SFNodeP2PV2:          "SFNodeP2PV2",
 }
 
 // orderedSFStrings is an ordered list of service flags from highest to
@@ -130,6 +135,7 @@ var orderedSFStrings = []ServiceFlag{
 	SFNodeCF,
 	SFNode2X,
 	SFNodeNetworkLimited,
+	SFNodeP2PV2,
 }
 
 // HasFlag returns a bool indicating if the service has the given flag.



Test patch:
diff --git a/btcec/ellswift/ellswift_test.go b/btcec/ellswift/ellswift_test.go
new file mode 100644
index 00000000..87433d60
--- /dev/null
+++ b/btcec/ellswift/ellswift_test.go
@@ -0,0 +1,846 @@
+package ellswift
+
+import (
+	"encoding/hex"
+	"testing"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+)
+
+// setHex decodes the passed big-endian hex string into the internal field value
+// representation.  Only the first 32-bytes are used.
+//
+// This is NOT constant time.
+//
+// The field value is returned to support chaining.  This enables syntax like:
+// f := new(FieldVal).SetHex("0abc").Add(1) so that f = 0x0abc + 1
+func setHex(hexString string) *btcec.FieldVal {
+	if len(hexString)%2 != 0 {
+		hexString = "0" + hexString
+	}
+	bytes, _ := hex.DecodeString(hexString)
+
+	var f btcec.FieldVal
+	f.SetByteSlice(bytes)
+
+	return &f
+}
+
+// TestXSwiftECVectors checks the BIP324 test vectors for the XSwiftEC function.
+func TestXSwiftECVectors(t *testing.T) {
+	tests := []struct {
+		ellswift  string
+		expectedX string
+	}{
+		{
+			ellswift:  "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "edd1fd3e327ce90cc7a3542614289aee9682003e9cf7dcc9cf2ca9743be5aa0c",
+		},
+		{
+			ellswift:  "000000000000000000000000000000000000000000000000000000000000000001d3475bf7655b0fb2d852921035b2ef607f49069b97454e6795251062741771",
+			expectedX: "b5da00b73cd6560520e7c364086e7cd23a34bf60d0e707be9fc34d4cd5fdfa2c",
+		},
+		{
+			ellswift:  "000000000000000000000000000000000000000000000000000000000000000082277c4a71f9d22e66ece523f8fa08741a7c0912c66a69ce68514bfd3515b49f",
+			expectedX: "f482f2e241753ad0fb89150d8491dc1e34ff0b8acfbb442cfe999e2e5e6fd1d2",
+		},
+		{
+			ellswift:  "00000000000000000000000000000000000000000000000000000000000000008421cc930e77c9f514b6915c3dbe2a94c6d8f690b5b739864ba6789fb8a55dd0",
+			expectedX: "9f59c40275f5085a006f05dae77eb98c6fd0db1ab4a72ac47eae90a4fc9e57e0",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000bde70df51939b94c9c24979fa7dd04ebd9b3572da7802290438af2a681895441",
+			expectedX: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa9fffffd6b",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000d19c182d2759cd99824228d94799f8c6557c38a1c0d6779b9d4b729c6f1ccc42",
+			expectedX: "70720db7e238d04121f5b1afd8cc5ad9d18944c6bdc94881f502b7a3af3aecff",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "edd1fd3e327ce90cc7a3542614289aee9682003e9cf7dcc9cf2ca9743be5aa0c",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff2664bbd5",
+			expectedX: "50873db31badcc71890e4f67753a65757f97aaa7dd5f1e82b753ace32219064b",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff7028de7d",
+			expectedX: "1eea9cc59cfcf2fa151ac6c274eea4110feb4f7b68c5965732e9992e976ef68e",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffcbcfb7e7",
+			expectedX: "12303941aedc208880735b1f1795c8e55be520ea93e103357b5d2adb7ed59b8e",
+		},
+		{
+			ellswift:  "0000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffffffffffffffff3113ad9",
+			expectedX: "7eed6b70e7b0767c7d7feac04e57aa2a12fef5e0f48f878fcbb88b3b6b5e0783",
+		},
+		{
+			ellswift:  "0a2d2ba93507f1df233770c2a797962cc61f6d15da14ecd47d8d27ae1cd5f8530000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "532167c11200b08c0e84a354e74dcc40f8b25f4fe686e30869526366278a0688",
+		},
+		{
+			ellswift:  "0a2d2ba93507f1df233770c2a797962cc61f6d15da14ecd47d8d27ae1cd5f853fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "532167c11200b08c0e84a354e74dcc40f8b25f4fe686e30869526366278a0688",
+		},
+		{
+			ellswift:  "0ffde9ca81d751e9cdaffc1a50779245320b28996dbaf32f822f20117c22fbd6c74d99efceaa550f1ad1c0f43f46e7ff1ee3bd0162b7bf55f2965da9c3450646",
+			expectedX: "74e880b3ffd18fe3cddf7902522551ddf97fa4a35a3cfda8197f947081a57b8f",
+		},
+		{
+			ellswift:  "0ffde9ca81d751e9cdaffc1a50779245320b28996dbaf32f822f20117c22fbd6ffffffffffffffffffffffffffffffffffffffffffffffffffffffff156ca896",
+			expectedX: "377b643fce2271f64e5c8101566107c1be4980745091783804f654781ac9217c",
+		},
+		{
+			ellswift:  "123658444f32be8f02ea2034afa7ef4bbe8adc918ceb49b12773b625f490b368ffffffffffffffffffffffffffffffffffffffffffffffffffffffff8dc5fe11",
+			expectedX: "ed16d65cf3a9538fcb2c139f1ecbc143ee14827120cbc2659e667256800b8142",
+		},
+		{
+			ellswift:  "146f92464d15d36e35382bd3ca5b0f976c95cb08acdcf2d5b3570617990839d7ffffffffffffffffffffffffffffffffffffffffffffffffffffffff3145e93b",
+			expectedX: "0d5cd840427f941f65193079ab8e2e83024ef2ee7ca558d88879ffd879fb6657",
+		},
+		{
+			ellswift:  "15fdf5cf09c90759add2272d574d2bb5fe1429f9f3c14c65e3194bf61b82aa73ffffffffffffffffffffffffffffffffffffffffffffffffffffffff04cfd906",
+			expectedX: "16d0e43946aec93f62d57eb8cde68951af136cf4b307938dd1447411e07bffe1",
+		},
+		{
+			ellswift:  "1f67edf779a8a649d6def60035f2fa22d022dd359079a1a144073d84f19b92d50000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "025661f9aba9d15c3118456bbe980e3e1b8ba2e047c737a4eb48a040bb566f6c",
+		},
+		{
+			ellswift:  "1f67edf779a8a649d6def60035f2fa22d022dd359079a1a144073d84f19b92d5fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "025661f9aba9d15c3118456bbe980e3e1b8ba2e047c737a4eb48a040bb566f6c",
+		},
+		{
+			ellswift:  "1fe1e5ef3fceb5c135ab7741333ce5a6e80d68167653f6b2b24bcbcfaaaff507fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "98bec3b2a351fa96cfd191c1778351931b9e9ba9ad1149f6d9eadca80981b801",
+		},
+		{
+			ellswift:  "4056a34a210eec7892e8820675c860099f857b26aad85470ee6d3cf1304a9dcf375e70374271f20b13c9986ed7d3c17799698cfc435dbed3a9f34b38c823c2b4",
+			expectedX: "868aac2003b29dbcad1a3e803855e078a89d16543ac64392d122417298cec76e",
+		},
+		{
+			ellswift:  "4197ec3723c654cfdd32ab075506648b2ff5070362d01a4fff14b336b78f963fffffffffffffffffffffffffffffffffffffffffffffffffffffffffb3ab1e95",
+			expectedX: "ba5a6314502a8952b8f456e085928105f665377a8ce27726a5b0eb7ec1ac0286",
+		},
+		{
+			ellswift:  "47eb3e208fedcdf8234c9421e9cd9a7ae873bfbdbc393723d1ba1e1e6a8e6b24ffffffffffffffffffffffffffffffffffffffffffffffffffffffff7cd12cb1",
+			expectedX: "d192d52007e541c9807006ed0468df77fd214af0a795fe119359666fdcf08f7c",
+		},
+		{
+			ellswift:  "5eb9696a2336fe2c3c666b02c755db4c0cfd62825c7b589a7b7bb442e141c1d693413f0052d49e64abec6d5831d66c43612830a17df1fe4383db896468100221",
+			expectedX: "ef6e1da6d6c7627e80f7a7234cb08a022c1ee1cf29e4d0f9642ae924cef9eb38",
+		},
+		{
+			ellswift:  "7bf96b7b6da15d3476a2b195934b690a3a3de3e8ab8474856863b0de3af90b0e0000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "50851dfc9f418c314a437295b24feeea27af3d0cd2308348fda6e21c463e46ff",
+		},
+		{
+			ellswift:  "7bf96b7b6da15d3476a2b195934b690a3a3de3e8ab8474856863b0de3af90b0efffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "50851dfc9f418c314a437295b24feeea27af3d0cd2308348fda6e21c463e46ff",
+		},
+		{
+			ellswift:  "851b1ca94549371c4f1f7187321d39bf51c6b7fb61f7cbf027c9da62021b7a65fc54c96837fb22b362eda63ec52ec83d81bedd160c11b22d965d9f4a6d64d251",
+			expectedX: "3e731051e12d33237eb324f2aa5b16bb868eb49a1aa1fadc19b6e8761b5a5f7b",
+		},
+		{
+			ellswift:  "943c2f775108b737fe65a9531e19f2fc2a197f5603e3a2881d1d83e4008f91250000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "311c61f0ab2f32b7b1f0223fa72f0a78752b8146e46107f8876dd9c4f92b2942",
+		},
+		{
+			ellswift:  "943c2f775108b737fe65a9531e19f2fc2a197f5603e3a2881d1d83e4008f9125fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "311c61f0ab2f32b7b1f0223fa72f0a78752b8146e46107f8876dd9c4f92b2942",
+		},
+		{
+			ellswift:  "a0f18492183e61e8063e573606591421b06bc3513631578a73a39c1c3306239f2f32904f0d2a33ecca8a5451705bb537d3bf44e071226025cdbfd249fe0f7ad6",
+			expectedX: "97a09cf1a2eae7c494df3c6f8a9445bfb8c09d60832f9b0b9d5eabe25fbd14b9",
+		},
+		{
+			ellswift:  "a1ed0a0bd79d8a23cfe4ec5fef5ba5cccfd844e4ff5cb4b0f2e71627341f1c5b17c499249e0ac08d5d11ea1c2c8ca7001616559a7994eadec9ca10fb4b8516dc",
+			expectedX: "65a89640744192cdac64b2d21ddf989cdac7500725b645bef8e2200ae39691f2",
+		},
+		{
+			ellswift:  "ba94594a432721aa3580b84c161d0d134bc354b690404d7cd4ec57c16d3fbe98ffffffffffffffffffffffffffffffffffffffffffffffffffffffffea507dd7",
+			expectedX: "5e0d76564aae92cb347e01a62afd389a9aa401c76c8dd227543dc9cd0efe685a",
+		},
+		{
+			ellswift:  "bcaf7219f2f6fbf55fe5e062dce0e48c18f68103f10b8198e974c184750e1be3932016cbf69c4471bd1f656c6a107f1973de4af7086db897277060e25677f19a",
+			expectedX: "2d97f96cac882dfe73dc44db6ce0f1d31d6241358dd5d74eb3d3b50003d24c2b",
+		},
+		{
+			ellswift:  "bcaf7219f2f6fbf55fe5e062dce0e48c18f68103f10b8198e974c184750e1be3ffffffffffffffffffffffffffffffffffffffffffffffffffffffff6507d09a",
+			expectedX: "e7008afe6e8cbd5055df120bd748757c686dadb41cce75e4addcc5e02ec02b44",
+		},
+		{
+			ellswift:  "c5981bae27fd84401c72a155e5707fbb811b2b620645d1028ea270cbe0ee225d4b62aa4dca6506c1acdbecc0552569b4b21436a5692e25d90d3bc2eb7ce24078",
+			expectedX: "948b40e7181713bc018ec1702d3d054d15746c59a7020730dd13ecf985a010d7",
+		},
+		{
+			ellswift:  "c894ce48bfec433014b931a6ad4226d7dbd8eaa7b6e3faa8d0ef94052bcf8cff336eeb3919e2b4efb746c7f71bbca7e9383230fbbc48ffafe77e8bcc69542471",
+			expectedX: "f1c91acdc2525330f9b53158434a4d43a1c547cff29f15506f5da4eb4fe8fa5a",
+		},
+		{
+			ellswift:  "cbb0deab125754f1fdb2038b0434ed9cb3fb53ab735391129994a535d925f6730000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "872d81ed8831d9998b67cb7105243edbf86c10edfebb786c110b02d07b2e67cd",
+		},
+		{
+			ellswift:  "d917b786dac35670c330c9c5ae5971dfb495c8ae523ed97ee2420117b171f41effffffffffffffffffffffffffffffffffffffffffffffffffffffff2001f6f6",
+			expectedX: "e45b71e110b831f2bdad8651994526e58393fde4328b1ec04d59897142584691",
+		},
+		{
+			ellswift:  "e28bd8f5929b467eb70e04332374ffb7e7180218ad16eaa46b7161aa679eb4260000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "66b8c980a75c72e598d383a35a62879f844242ad1e73ff12edaa59f4e58632b5",
+		},
+		{
+			ellswift:  "e28bd8f5929b467eb70e04332374ffb7e7180218ad16eaa46b7161aa679eb426fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "66b8c980a75c72e598d383a35a62879f844242ad1e73ff12edaa59f4e58632b5",
+		},
+		{
+			ellswift:  "e7ee5814c1706bf8a89396a9b032bc014c2cac9c121127dbf6c99278f8bb53d1dfd04dbcda8e352466b6fcd5f2dea3e17d5e133115886eda20db8a12b54de71b",
+			expectedX: "e842c6e3529b234270a5e97744edc34a04d7ba94e44b6d2523c9cf0195730a50",
+		},
+		{
+			ellswift:  "f292e46825f9225ad23dc057c1d91c4f57fcb1386f29ef10481cb1d22518593fffffffffffffffffffffffffffffffffffffffffffffffffffffffff7011c989",
+			expectedX: "3cea2c53b8b0170166ac7da67194694adacc84d56389225e330134dab85a4d55",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f0000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "edd1fd3e327ce90cc7a3542614289aee9682003e9cf7dcc9cf2ca9743be5aa0c",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f01d3475bf7655b0fb2d852921035b2ef607f49069b97454e6795251062741771",
+			expectedX: "b5da00b73cd6560520e7c364086e7cd23a34bf60d0e707be9fc34d4cd5fdfa2c",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f4218f20ae6c646b363db68605822fb14264ca8d2587fdd6fbc750d587e76a7ee",
+			expectedX: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa9fffffd6b",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f82277c4a71f9d22e66ece523f8fa08741a7c0912c66a69ce68514bfd3515b49f",
+			expectedX: "f482f2e241753ad0fb89150d8491dc1e34ff0b8acfbb442cfe999e2e5e6fd1d2",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f8421cc930e77c9f514b6915c3dbe2a94c6d8f690b5b739864ba6789fb8a55dd0",
+			expectedX: "9f59c40275f5085a006f05dae77eb98c6fd0db1ab4a72ac47eae90a4fc9e57e0",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fd19c182d2759cd99824228d94799f8c6557c38a1c0d6779b9d4b729c6f1ccc42",
+			expectedX: "70720db7e238d04121f5b1afd8cc5ad9d18944c6bdc94881f502b7a3af3aecff",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "edd1fd3e327ce90cc7a3542614289aee9682003e9cf7dcc9cf2ca9743be5aa0c",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fffffffffffffffffffffffffffffffffffffffffffffffffffffffff2664bbd5",
+			expectedX: "50873db31badcc71890e4f67753a65757f97aaa7dd5f1e82b753ace32219064b",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fffffffffffffffffffffffffffffffffffffffffffffffffffffffff7028de7d",
+			expectedX: "1eea9cc59cfcf2fa151ac6c274eea4110feb4f7b68c5965732e9992e976ef68e",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fffffffffffffffffffffffffffffffffffffffffffffffffffffffffcbcfb7e7",
+			expectedX: "12303941aedc208880735b1f1795c8e55be520ea93e103357b5d2adb7ed59b8e",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffff3113ad9",
+			expectedX: "7eed6b70e7b0767c7d7feac04e57aa2a12fef5e0f48f878fcbb88b3b6b5e0783",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff13cea4a70000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "649984435b62b4a25d40c6133e8d9ab8c53d4b059ee8a154a3be0fcf4e892edb",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff13cea4a7fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "649984435b62b4a25d40c6133e8d9ab8c53d4b059ee8a154a3be0fcf4e892edb",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff15028c590063f64d5a7f1c14915cd61eac886ab295bebd91992504cf77edb028bdd6267f",
+			expectedX: "3fde5713f8282eead7d39d4201f44a7c85a5ac8a0681f35e54085c6b69543374",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff2715de860000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "3524f77fa3a6eb4389c3cb5d27f1f91462086429cd6c0cb0df43ea8f1e7b3fb4",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff2715de86fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "3524f77fa3a6eb4389c3cb5d27f1f91462086429cd6c0cb0df43ea8f1e7b3fb4",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff2c2c5709e7156c417717f2feab147141ec3da19fb759575cc6e37b2ea5ac9309f26f0f66",
+			expectedX: "d2469ab3e04acbb21c65a1809f39caafe7a77c13d10f9dd38f391c01dc499c52",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff3a08cc1efffffffffffffffffffffffffffffffffffffffffffffffffffffffff760e9f0",
+			expectedX: "38e2a5ce6a93e795e16d2c398bc99f0369202ce21e8f09d56777b40fc512bccc",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff3e91257d932016cbf69c4471bd1f656c6a107f1973de4af7086db897277060e25677f19a",
+			expectedX: "864b3dc902c376709c10a93ad4bbe29fce0012f3dc8672c6286bba28d7d6d6fc",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff795d6c1c322cadf599dbb86481522b3cc55f15a67932db2afa0111d9ed6981bcd124bf44",
+			expectedX: "766dfe4a700d9bee288b903ad58870e3d4fe2f0ef780bcac5c823f320d9a9bef",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff8e426f0392389078c12b1a89e9542f0593bc96b6bfde8224f8654ef5d5cda935a3582194",
+			expectedX: "faec7bc1987b63233fbc5f956edbf37d54404e7461c58ab8631bc68e451a0478",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff91192139ffffffffffffffffffffffffffffffffffffffffffffffffffffffff45f0f1eb",
+			expectedX: "ec29a50bae138dbf7d8e24825006bb5fc1a2cc1243ba335bc6116fb9e498ec1f",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff98eb9ab76e84499c483b3bf06214abfe065dddf43b8601de596d63b9e45a166a580541fe",
+			expectedX: "1e0ff2dee9b09b136292a9e910f0d6ac3e552a644bba39e64e9dd3e3bbd3d4d4",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff9b77b7f2c74d99efceaa550f1ad1c0f43f46e7ff1ee3bd0162b7bf55f2965da9c3450646",
+			expectedX: "8b7dd5c3edba9ee97b70eff438f22dca9849c8254a2f3345a0a572ffeaae0928",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff9b77b7f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffff156ca896",
+			expectedX: "0881950c8f51d6b9a6387465d5f12609ef1bb25412a08a74cb2dfb200c74bfbf",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffa2f5cd838816c16c4fe8a1661d606fdb13cf9af04b979a2e159a09409ebc8645d58fde02",
+			expectedX: "2f083207b9fd9b550063c31cd62b8746bd543bdc5bbf10e3a35563e927f440c8",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffb13f75c00000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "4f51e0be078e0cddab2742156adba7e7a148e73157072fd618cd60942b146bd0",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffb13f75c0fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "4f51e0be078e0cddab2742156adba7e7a148e73157072fd618cd60942b146bd0",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe7bc1f8d0000000000000000000000000000000000000000000000000000000000000000",
+			expectedX: "16c2ccb54352ff4bd794f6efd613c72197ab7082da5b563bdf9cb3edaafe74c2",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe7bc1f8dfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
+			expectedX: "16c2ccb54352ff4bd794f6efd613c72197ab7082da5b563bdf9cb3edaafe74c2",
+		},
+		{
+			ellswift:  "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffef64d162750546ce42b0431361e52d4f5242d8f24f33e6b1f99b591647cbc808f462af51",
+			expectedX: "d41244d11ca4f65240687759f95ca9efbab767ededb38fd18c36e18cd3b6f6a9",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffff0e5be52372dd6e894b2a326fc3605a6e8f3c69c710bf27d630dfe2004988b78eb6eab36",
+			expectedX: "64bf84dd5e03670fdb24c0f5d3c2c365736f51db6c92d95010716ad2d36134c8",
+		},
+		{
+			ellswift:  "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffefbb982fffffffffffffffffffffffffffffffffffffffffffffffffffffffff6d6db1f",
+			expectedX: "1c92ccdfcf4ac550c28db57cff0c8515cb26936c786584a70114008d6c33a34b",
+		},
+	}
+
+	for _, test := range tests {
+		// The test.ellswift variable is 128-bytes long and is composed of two
+		// 64-byte hexadecimal strings. The first 64-byte hex string is the u
+		// value and the other is the t-value.
+		uVal := setHex(test.ellswift[0:64]).Normalize()
+		tVal := setHex(test.ellswift[64:128]).Normalize()
+
+		xVal := setHex(test.expectedX)
+
+		xCoord, err := XSwiftEC(uVal, tVal)
+		if err != nil {
+			t.Fatalf("received err with XSwiftEC: %v", err)
+		}
+
+		if !xCoord.Equals(xVal) {
+			t.Fatalf("encoding not equal to x")
+		}
+	}
+}
+
+// TestXSwiftECInvVectors tests that the inverse of the ElligatorSwift encoding
+// XSwiftECInv works as expected. In other words, given a u-value and an
+// x-value, that the correct t-value is returned. Each test case has a cases
+// array that determines which t-value should be returned for each
+// corresponding case.
+func TestXSwiftECInvVectors(t *testing.T) {
+	tests := []struct {
+		u     string
+		x     string
+		cases []string
+	}{
+		{
+			u: "05ff6bdad900fc3261bc7fe34e2fb0f569f06e091ae437d3a52e9da0cbfb9590",
+			x: "80cdf63774ec7022c89a5a8558e373a279170285e0ab27412dbce510bdfe23fc",
+			cases: []string{
+				"",
+				"",
+				"45654798ece071ba79286d04f7f3eb1c3f1d17dd883610f2ad2efd82a287466b",
+				"0aeaa886f6b76c7158452418cbf5033adc5747e9e9b5d3b2303db96936528557",
+				"",
+				"",
+				"ba9ab867131f8e4586d792fb080c14e3c0e2e82277c9ef0d52d1027c5d78b5c4",
+				"f51557790948938ea7badbe7340afcc523a8b816164a2c4dcfc24695c9ad76d8",
+			},
+		},
+		{
+			u: "1737a85f4c8d146cec96e3ffdca76d9903dcf3bd53061868d478c78c63c2aa9e",
+			x: "39e48dd150d2f429be088dfd5b61882e7e8407483702ae9a5ab35927b15f85ea",
+			cases: []string{
+				"1be8cc0b04be0c681d0c6a68f733f82c6c896e0c8a262fcd392918e303a7abf4",
+				"605b5814bf9b8cb066667c9e5480d22dc5b6c92f14b4af3ee0a9eb83b03685e3",
+				"",
+				"",
+				"e41733f4fb41f397e2f3959708cc07d3937691f375d9d032c6d6e71bfc58503b",
+				"9fa4a7eb4064734f99998361ab7f2dd23a4936d0eb4b50c11f56147b4fc9764c",
+				"",
+				"",
+			},
+		},
+		{
+			u: "1aaa1ccebf9c724191033df366b36f691c4d902c228033ff4516d122b2564f68",
+			x: "c75541259d3ba98f207eaa30c69634d187d0b6da594e719e420f4898638fc5b0",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "2323a1d079b0fd72fc8bb62ec34230a815cb0596c2bfac998bd6b84260f5dc26",
+			x: "239342dfb675500a34a196310b8d87d54f49dcac9da50c1743ceab41a7b249ff",
+			cases: []string{
+				"f63580b8aa49c4846de56e39e1b3e73f171e881eba8c66f614e67e5c975dfc07",
+				"b6307b332e699f1cf77841d90af25365404deb7fed5edb3090db49e642a156b6",
+				"",
+				"",
+				"09ca7f4755b63b7b921a91c61e4c18c0e8e177e145739909eb1981a268a20028",
+				"49cf84ccd19660e30887be26f50dac9abfb2148012a124cf6f24b618bd5ea579",
+				"",
+				"",
+			},
+		},
+		{
+			u: "2dc90e640cb646ae9164c0b5a9ef0169febe34dc4437d6e46acb0e27e219d1e8",
+			x: "d236f19bf349b9516e9b3f4a5610fe960141cb23bbc8291b9534f1d71de62a47",
+			cases: []string{
+				"e69df7d9c026c36600ebdf588072675847c0c431c8eb730682533e964b6252c9",
+				"4f18bbdf7c2d6c5f818c18802fa35cd069eaa79fff74e4fc837c80d93fece2f8",
+				"",
+				"",
+				"196208263fd93c99ff1420a77f8d98a7b83f3bce37148cf97dacc168b49da966",
+				"b0e7442083d293a07e73e77fd05ca32f96155860008b1b037c837f25c0131937",
+				"",
+				"",
+			},
+		},
+		{
+			u: "3edd7b3980e2f2f34d1409a207069f881fda5f96f08027ac4465b63dc278d672",
+			x: "053a98de4a27b1961155822b3a3121f03b2a14458bd80eb4a560c4c7a85c149c",
+			cases: []string{
+				"",
+				"",
+				"b3dae4b7dcf858e4c6968057cef2b156465431526538199cf52dc1b2d62fda30",
+				"4aa77dd55d6b6d3cfa10cc9d0fe42f79232e4575661049ae36779c1d0c666d88",
+				"",
+				"",
+				"4c251b482307a71b39697fa8310d4ea9b9abcead9ac7e6630ad23e4c29d021ff",
+				"b558822aa29492c305ef3362f01bd086dcd1ba8a99efb651c98863e1f3998ea7",
+			},
+		},
+		{
+			u: "4295737efcb1da6fb1d96b9ca7dcd1e320024b37a736c4948b62598173069f70",
+			x: "fa7ffe4f25f88362831c087afe2e8a9b0713e2cac1ddca6a383205a266f14307",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "587c1a0cee91939e7f784d23b963004a3bf44f5d4e32a0081995ba20b0fca59e",
+			x: "2ea988530715e8d10363907ff25124524d471ba2454d5ce3be3f04194dfd3a3c",
+			cases: []string{
+				"cfd5a094aa0b9b8891b76c6ab9438f66aa1c095a65f9f70135e8171292245e74",
+				"a89057d7c6563f0d6efa19ae84412b8a7b47e791a191ecdfdf2af84fd97bc339",
+				"475d0ae9ef46920df07b34117be5a0817de1023e3cc32689e9be145b406b0aef",
+				"a0759178ad80232454f827ef05ea3e72ad8d75418e6d4cc1cd4f5306c5e7c453",
+				"302a5f6b55f464776e48939546bc709955e3f6a59a0608feca17e8ec6ddb9dbb",
+				"576fa82839a9c0f29105e6517bbed47584b8186e5e6e132020d507af268438f6",
+				"b8a2f51610b96df20f84cbee841a5f7e821efdc1c33cd9761641eba3bf94f140",
+				"5f8a6e87527fdcdbab07d810fa15c18d52728abe7192b33e32b0acf83a1837dc",
+			},
+		},
+		{
+			u: "5fa88b3365a635cbbcee003cce9ef51dd1a310de277e441abccdb7be1e4ba249",
+			x: "79461ff62bfcbcac4249ba84dd040f2cec3c63f725204dc7f464c16bf0ff3170",
+			cases: []string{
+				"",
+				"",
+				"6bb700e1f4d7e236e8d193ff4a76c1b3bcd4e2b25acac3d51c8dac653fe909a0",
+				"f4c73410633da7f63a4f1d55aec6dd32c4c6d89ee74075edb5515ed90da9e683",
+				"",
+				"",
+				"9448ff1e0b281dc9172e6c00b5893e4c432b1d4da5353c2ae3725399c016f28f",
+				"0b38cbef9cc25809c5b0e2aa513922cd3b39276118bf8a124aaea125f25615ac",
+			},
+		},
+		{
+			u: "6fb31c7531f03130b42b155b952779efbb46087dd9807d241a48eac63c3d96d6",
+			x: "56f81be753e8d4ae4940ea6f46f6ec9fda66a6f96cc95f506cb2b57490e94260",
+			cases: []string{
+				"",
+				"",
+				"59059774795bdb7a837fbe1140a5fa59984f48af8df95d57dd6d1c05437dcec1",
+				"22a644db79376ad4e7b3a009e58b3f13137c54fdf911122cc93667c47077d784",
+				"",
+				"",
+				"a6fa688b86a424857c8041eebf5a05a667b0b7507206a2a82292e3f9bc822d6e",
+				"dd59bb2486c8952b184c5ff61a74c0ecec83ab0206eeedd336c9983a8f8824ab",
+			},
+		},
+		{
+			u: "704cd226e71cb6826a590e80dac90f2d2f5830f0fdf135a3eae3965bff25ff12",
+			x: "138e0afa68936ee670bd2b8db53aedbb7bea2a8597388b24d0518edd22ad66ec",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "725e914792cb8c8949e7e1168b7cdd8a8094c91c6ec2202ccd53a6a18771edeb",
+			x: "8da16eb86d347376b6181ee9748322757f6b36e3913ddfd332ac595d788e0e44",
+			cases: []string{
+				"dd357786b9f6873330391aa5625809654e43116e82a5a5d82ffd1d6624101fc4",
+				"a0b7efca01814594c59c9aae8e49700186ca5d95e88bcc80399044d9c2d8613d",
+				"",
+				"",
+				"22ca8879460978cccfc6e55a9da7f69ab1bcee917d5a5a27d002e298dbefdc6b",
+				"5f481035fe7eba6b3a63655171b68ffe7935a26a1774337fc66fbb253d279af2",
+				"",
+				"",
+			},
+		},
+		{
+			u: "78fe6b717f2ea4a32708d79c151bf503a5312a18c0963437e865cc6ed3f6ae97",
+			x: "8701948e80d15b5cd8f72863eae40afc5aced5e73f69cbc8179a33902c094d98",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "7c37bb9c5061dc07413f11acd5a34006e64c5c457fdb9a438f217255a961f50d",
+			x: "5c1a76b44568eb59d6789a7442d9ed7cdc6226b7752b4ff8eaf8e1a95736e507",
+			cases: []string{
+				"",
+				"",
+				"b94d30cd7dbff60b64620c17ca0fafaa40b3d1f52d077a60a2e0cafd145086c2",
+				"",
+				"",
+				"",
+				"46b2cf32824009f49b9df3e835f05055bf4c2e0ad2f8859f5d1f3501ebaf756d",
+				"",
+			},
+		},
+		{
+			u: "82388888967f82a6b444438a7d44838e13c0d478b9ca060da95a41fb94303de6",
+			x: "29e9654170628fec8b4972898b113cf98807f4609274f4f3140d0674157c90a0",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "91298f5770af7a27f0a47188d24c3b7bf98ab2990d84b0b898507e3c561d6472",
+			x: "144f4ccbd9a74698a88cbf6fd00ad886d339d29ea19448f2c572cac0a07d5562",
+			cases: []string{
+				"e6a0ffa3807f09dadbe71e0f4be4725f2832e76cad8dc1d943ce839375eff248",
+				"837b8e68d4917544764ad0903cb11f8615d2823cefbb06d89049dbabc69befda",
+				"",
+				"",
+				"195f005c7f80f6252418e1f0b41b8da0d7cd189352723e26bc317c6b8a1009e7",
+				"7c8471972b6e8abb89b52f6fc34ee079ea2d7dc31044f9276fb6245339640c55",
+				"",
+				"",
+			},
+		},
+		{
+			u: "b682f3d03bbb5dee4f54b5ebfba931b4f52f6a191e5c2f483c73c66e9ace97e1",
+			x: "904717bf0bc0cb7873fcdc38aa97f19e3a62630972acff92b24cc6dda197cb96",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "c17ec69e665f0fb0dbab48d9c2f94d12ec8a9d7eacb58084833091801eb0b80b",
+			x: "147756e66d96e31c426d3cc85ed0c4cfbef6341dd8b285585aa574ea0204b55e",
+			cases: []string{
+				"6f4aea431a0043bdd03134d6d9159119ce034b88c32e50e8e36c4ee45eac7ae9",
+				"fd5be16d4ffa2690126c67c3ef7cb9d29b74d397c78b06b3605fda34dc9696a6",
+				"5e9c60792a2f000e45c6250f296f875e174efc0e9703e628706103a9dd2d82c7",
+				"",
+				"90b515bce5ffbc422fcecb2926ea6ee631fcb4773cd1af171c93b11aa1538146",
+				"02a41e92b005d96fed93983c1083462d648b2c683874f94c9fa025ca23696589",
+				"a1639f86d5d0fff1ba39daf0d69078a1e8b103f168fc19d78f9efc5522d27968",
+				"",
+			},
+		},
+		{
+			u: "c25172fc3f29b6fc4a1155b8575233155486b27464b74b8b260b499a3f53cb14",
+			x: "1ea9cbdb35cf6e0329aa31b0bb0a702a65123ed008655a93b7dcd5280e52e1ab",
+			cases: []string{
+				"",
+				"",
+				"7422edc7843136af0053bb8854448a8299994f9ddcefd3a9a92d45462c59298a",
+				"78c7774a266f8b97ea23d05d064f033c77319f923f6b78bce4e20bf05fa5398d",
+				"",
+				"",
+				"8bdd12387bcec950ffac4477abbb757d6666b06223102c5656d2bab8d3a6d2a5",
+				"873888b5d990746815dc2fa2f9b0fcc388ce606dc09487431b1df40ea05ac2a2",
+			},
+		},
+		{
+			u: "cab6626f832a4b1280ba7add2fc5322ff011caededf7ff4db6735d5026dc0367",
+			x: "2b2bef0852c6f7c95d72ac99a23802b875029cd573b248d1f1b3fc8033788eb6",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "d8621b4ffc85b9ed56e99d8dd1dd24aedcecb14763b861a17112dc771a104fd2",
+			x: "812cabe972a22aa67c7da0c94d8a936296eb9949d70c37cb2b2487574cb3ce58",
+			cases: []string{
+				"fbc5febc6fdbc9ae3eb88a93b982196e8b6275a6d5a73c17387e000c711bd0e3",
+				"8724c96bd4e5527f2dd195a51c468d2d211ba2fac7cbe0b4b3434253409fb42d",
+				"",
+				"",
+				"043a014390243651c147756c467de691749d8a592a58c3e8c781fff28ee42b4c",
+				"78db36942b1aad80d22e6a5ae3b972d2dee45d0538341f4b4cbcbdabbf604802",
+				"",
+				"",
+			},
+		},
+		{
+			u: "da463164c6f4bf7129ee5f0ec00f65a675a8adf1bd931b39b64806afdcda9a22",
+			x: "25b9ce9b390b408ed611a0f13ff09a598a57520e426ce4c649b7f94f2325620d",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "dafc971e4a3a7b6dcfb42a08d9692d82ad9e7838523fcbda1d4827e14481ae2d",
+			x: "250368e1b5c58492304bd5f72696d27d526187c7adc03425e2b7d81dbb7e4e02",
+			cases: []string{
+				"",
+				"",
+				"370c28f1be665efacde6aa436bf86fe21e6e314c1e53dd040e6c73a46b4c8c49",
+				"cd8acee98ffe56531a84d7eb3e48fa4034206ce825ace907d0edf0eaeb5e9ca2",
+				"",
+				"",
+				"c8f3d70e4199a105321955bc9407901de191ceb3e1ac22fbf1938c5a94b36fe6",
+				"327531167001a9ace57b2814c1b705bfcbdf9317da5316f82f120f1414a15f8d",
+			},
+		},
+		{
+			u: "e0294c8bc1a36b4166ee92bfa70a5c34976fa9829405efea8f9cd54dcb29b99e",
+			x: "ae9690d13b8d20a0fbbf37bed8474f67a04e142f56efd78770a76b359165d8a1",
+			cases: []string{
+				"",
+				"",
+				"dcd45d935613916af167b029058ba3a700d37150b9df34728cb05412c16d4182",
+				"",
+				"",
+				"",
+				"232ba26ca9ec6e950e984fd6fa745c58ff2c8eaf4620cb8d734fabec3e92baad",
+				"",
+			},
+		},
+		{
+			u: "e148441cd7b92b8b0e4fa3bd68712cfd0d709ad198cace611493c10e97f5394e",
+			x: "164a639794d74c53afc4d3294e79cdb3cd25f99f6df45c000f758aba54d699c0",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "e4b00ec97aadcca97644d3b0c8a931b14ce7bcf7bc8779546d6e35aa5937381c",
+			x: "94e9588d41647b3fcc772dc8d83c67ce3be003538517c834103d2cd49d62ef4d",
+			cases: []string{
+				"c88d25f41407376bb2c03a7fffeb3ec7811cc43491a0c3aac0378cdc78357bee",
+				"51c02636ce00c2345ecd89adb6089fe4d5e18ac924e3145e6669501cd37a00d4",
+				"205b3512db40521cb200952e67b46f67e09e7839e0de44004138329ebd9138c5",
+				"58aab390ab6fb55c1d1b80897a207ce94a78fa5b4aa61a33398bcae9adb20d3e",
+				"3772da0bebf8c8944d3fc5800014c1387ee33bcb6e5f3c553fc8732287ca8041",
+				"ae3fd9c931ff3dcba132765249f7601b2a1e7536db1ceba19996afe22c85fb5b",
+				"dfa4caed24bfade34dff6ad1984b90981f6187c61f21bbffbec7cd60426ec36a",
+				"a7554c6f54904aa3e2e47f7685df8316b58705a4b559e5ccc6743515524deef1",
+			},
+		},
+		{
+			u: "e5bbb9ef360d0a501618f0067d36dceb75f5be9a620232aa9fd5139d0863fde5",
+			x: "e5bbb9ef360d0a501618f0067d36dceb75f5be9a620232aa9fd5139d0863fde5",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+		{
+			u: "e6bcb5c3d63467d490bfa54fbbc6092a7248c25e11b248dc2964a6e15edb1457",
+			x: "19434a3c29cb982b6f405ab04439f6d58db73da1ee4db723d69b591da124e7d8",
+			cases: []string{
+				"67119877832ab8f459a821656d8261f544a553b89ae4f25c52a97134b70f3426",
+				"ffee02f5e649c07f0560eff1867ec7b32d0e595e9b1c0ea6e2a4fc70c97cd71f",
+				"b5e0c189eb5b4bacd025b7444d74178be8d5246cfa4a9a207964a057ee969992",
+				"5746e4591bf7f4c3044609ea372e908603975d279fdef8349f0b08d32f07619d",
+				"98ee67887cd5470ba657de9a927d9e0abb5aac47651b0da3ad568eca48f0c809",
+				"0011fd0a19b63f80fa9f100e7981384cd2f1a6a164e3f1591d5b038e36832510",
+				"4a1f3e7614a4b4532fda48bbb28be874172adb9305b565df869b5fa71169629d",
+				"a8b91ba6e4080b3cfbb9f615c8d16f79fc68a2d8602107cb60f4f72bd0f89a92",
+			},
+		},
+		{
+			u: "f28fba64af766845eb2f4302456e2b9f8d80affe57e7aae42738d7cddb1c2ce6",
+			x: "f28fba64af766845eb2f4302456e2b9f8d80affe57e7aae42738d7cddb1c2ce6",
+			cases: []string{
+				"4f867ad8bb3d840409d26b67307e62100153273f72fa4b7484becfa14ebe7408",
+				"5bbc4f59e452cc5f22a99144b10ce8989a89a995ec3cea1c91ae10e8f721bb5d",
+				"",
+				"",
+				"b079852744c27bfbf62d9498cf819deffeacd8c08d05b48b7b41305db1418827",
+				"a443b0a61bad33a0dd566ebb4ef317676576566a13c315e36e51ef1608de40d2",
+				"",
+				"",
+			},
+		},
+		{
+			u: "f455605bc85bf48e3a908c31023faf98381504c6c6d3aeb9ede55f8dd528924d",
+			x: "d31fbcd5cdb798f6c00db6692f8fe8967fa9c79dd10958f4a194f01374905e99",
+			cases: []string{
+				"",
+				"",
+				"0c00c5715b56fe632d814ad8a77f8e66628ea47a6116834f8c1218f3a03cbd50",
+				"df88e44fac84fa52df4d59f48819f18f6a8cd4151d162afaf773166f57c7ff46",
+				"",
+				"",
+				"f3ff3a8ea4a9019cd27eb527588071999d715b859ee97cb073ede70b5fc33edf",
+				"20771bb0537b05ad20b2a60b77e60e7095732beae2e9d505088ce98fa837fce9",
+			},
+		},
+		{
+			u: "f58cd4d9830bad322699035e8246007d4be27e19b6f53621317b4f309b3daa9d",
+			x: "78ec2b3dc0948de560148bbc7c6dc9633ad5df70a5a5750cbed721804f082a3b",
+			cases: []string{
+				"6c4c580b76c7594043569f9dae16dc2801c16a1fbe12860881b75f8ef929bce5",
+				"94231355e7385c5f25ca436aa64191471aea4393d6e86ab7a35fe2afacaefd0d",
+				"dff2a1951ada6db574df834048149da3397a75b829abf58c7e69db1b41ac0989",
+				"a52b66d3c907035548028bf804711bf422aba95f1a666fc86f4648e05f29caae",
+				"93b3a7f48938a6bfbca9606251e923d7fe3e95e041ed79f77e48a07006d63f4a",
+				"6bdcecaa18c7a3a0da35bc9559be6eb8e515bc6c291795485ca01d4f5350ff22",
+				"200d5e6ae525924a8b207cbfb7eb625cc6858a47d6540a73819624e3be53f2a6",
+				"5ad4992c36f8fcaab7fd7407fb8ee40bdd5456a0e599903790b9b71ea0d63181",
+			},
+		},
+		{
+			u: "fd7d912a40f182a3588800d69ebfb5048766da206fd7ebc8d2436c81cbef6421",
+			x: "8d37c862054debe731694536ff46b273ec122b35a9bf1445ac3c4ff9f262c952",
+			cases: []string{
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+				"",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		uVal := setHex(test.u).Normalize()
+		xVal := setHex(test.x).Normalize()
+
+		// Loop through each individual case in the list of cases and ensure
+		// that the correct t-value is calculated.
+		for caseNum, expTString := range test.cases {
+			tVal := XSwiftECInv(uVal, xVal, caseNum)
+
+			if tVal == nil {
+				if expTString != "" {
+					t.Fatalf("t value different than expected")
+				}
+
+				continue
+			}
+
+			expectedT := setHex(expTString)
+
+			if !tVal.Equals(expectedT) {
+				t.Fatalf("t value different than expected")
+			}
+		}
+	}
+}
diff --git a/btcutil/psbt/psbt_test.go b/btcutil/psbt/psbt_test.go
index c8d93f3e..505f205d 100644
--- a/btcutil/psbt/psbt_test.go
+++ b/btcutil/psbt/psbt_test.go
@@ -9,6 +9,7 @@ import (
 	"encoding/base64"
 	"encoding/binary"
 	"encoding/hex"
+	"math"
 	"strings"
 	"testing"
 
@@ -1447,6 +1448,89 @@ func TestNonWitnessToWitness(t *testing.T) {
 	}
 }
 
+// TestPSBTNumberOfHashesOverflow tests the case where the number of hashes
+// in the PSBT exceeds the maximum allowed value. This is a regression test
+// for a bug that was fixed in the PSBT library.
+func TestPSBTNumberOfHashesOverflow(t *testing.T) {
+	// This hex string represents a PSBT with an invalid number of hashes. The
+	// PSBT library should return an error when trying to parse this PSBT.
+	//
+	// TODO(ffranr): Is there a more minimal PSBT example?
+	hexString := "70736274ff01007374ff01030100000000002f0000002e2873007374" +
+		"ff01070100000000000000000000000000000000000000060680050000736274f" +
+		"f01000a0000000060c70006060000736274ff01000a0000010000010024070100" +
+		"00000000000000000000000000000000000006060000736274ff01000a0000000" +
+		"000010024c760002a707362c760000b0500000000000000060605000073626274" +
+		"ff01000a000000000001002421212121212121212121212121212121212121212" +
+		"12121212121212121212121212121212107010000000000000000000000000000" +
+		"000000000006060000736274ff01000a000eff000001000a0a040404040404040" +
+		"400"
+
+	// Convert hex string to byte slice
+	buffer, err := hex.DecodeString(hexString)
+	require.NoError(t, err)
+
+	// Attempt to parse the PSBT.
+	psbt, err := NewFromRawBytes(bytes.NewBuffer(buffer), false)
+	require.Nil(t, psbt)
+	require.ErrorIs(t, err, ErrInvalidPsbtFormat)
+}
+
+// TestMinTaprootBip32DerivationByteSize tests the
+// minTaprootBip32DerivationByteSize function to ensure it correctly calculates
+// the minimum byte size of the Taproot BIP32 derivation path.
+func TestMinTaprootBip32DerivationByteSize(t *testing.T) {
+	tests := []struct {
+		label        string
+		numHashes    uint64
+		expectedSize uint64
+		expectErr    bool
+	}{
+		{
+			label:        "only compact size + fingerprint",
+			numHashes:    0,
+			expectedSize: 5,
+			expectErr:    false,
+		},
+		{
+			label:        "numHashes == 1, therefore: 1 * 32 + 5",
+			numHashes:    1,
+			expectedSize: 37,
+			expectErr:    false,
+		},
+		{
+			label:        "numHashes == 2, therefore: 2 * 32 + 5",
+			numHashes:    2,
+			expectedSize: 69,
+			expectErr:    false,
+		},
+		{
+			label:        "overflow expected",
+			numHashes:    math.MaxUint64,
+			expectedSize: 0,
+			expectErr:    true,
+		},
+	}
+
+	for _, tt := range tests {
+		actualSize, err := minTaprootBip32DerivationByteSize(tt.numHashes)
+
+		if (err != nil) != tt.expectErr {
+			t.Errorf(
+				"%s (numHashes=%d, unexpected_error=%v)", tt.label,
+				tt.numHashes, err,
+			)
+			continue
+		}
+
+		if err == nil && actualSize != tt.expectedSize {
+			t.Errorf("%s (numHashes=%d, actualSize=%d, expectedSize=%d)",
+				tt.label, tt.numHashes, actualSize, tt.expectedSize,
+			)
+		}
+	}
+}
+
 // TestEmptyInputSerialization tests the special serialization case for a wire
 // transaction that has no inputs.
 func TestEmptyInputSerialization(t *testing.T) {
diff --git a/peer/p2pdowngrader_test.go b/peer/p2pdowngrader_test.go
new file mode 100644
index 00000000..20c8ea0a
--- /dev/null
+++ b/peer/p2pdowngrader_test.go
@@ -0,0 +1,220 @@
+package peer
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"pgregory.net/rapid"
+)
+
+// TestP2PDowngraderMarkAndShouldDowngrade tests the basic MarkForDowngrade and
+// ShouldDowngrade functionality.
+func TestP2PDowngraderMarkAndShouldDowngrade(t *testing.T) {
+	t.Parallel()
+
+	rapid.Check(t, func(t *rapid.T) {
+		// addrGen creates somewhat realistic-looking address strings.
+		addrGen := rapid.Map(rapid.SliceOfN(
+			rapid.Byte(), 10, 20), func(bs []byte) string {
+			return fmt.Sprintf("%x.testpeer.net", bs)
+		})
+
+		addr1 := addrGen.Draw(t, "addr1")
+		addr2 := addrGen.Draw(t, "addr2")
+
+		// Ensure addr1 and addr2 are different for a clearer test,
+		// though the logic holds even if they are the same.
+		for addr1 == addr2 {
+			// Suffix with retry to ensure rapid sees it as a new
+			// draw attempt for addr2.
+			addr2 = addrGen.Draw(t, "addr2_retry")
+		}
+
+		// Create a P2PDowngrader with a small capacity.
+		downgrader := NewP2PDowngrader(2)
+
+		// Initially, no address should be marked for downgrade.
+		require.False(
+			t, downgrader.ShouldDowngrade(addr1),
+			"addr1 should not be marked initially",
+		)
+		require.False(
+			t, downgrader.ShouldDowngrade(addr2),
+			"addr2 should not be marked initially",
+		)
+
+		// Mark addr1 for downgrade.
+		downgrader.MarkForDowngrade(addr1)
+
+		// Now, ShouldDowngrade for addr1 should return true.
+		require.True(
+			t, downgrader.ShouldDowngrade(addr1),
+			"addr1 should be marked for downgrade after "+
+				"MarkForDowngrade",
+		)
+
+		// A subsequent call for addr1 should return false as it is
+		// consumed.
+		require.False(
+			t, downgrader.ShouldDowngrade(addr1),
+			"addr1 should not be marked after being consumed "+
+				"by ShouldDowngrade",
+		)
+
+		// addr2 should still not be marked.
+		require.False(
+			t, downgrader.ShouldDowngrade(addr2),
+			"addr2 should remain unmarked",
+		)
+
+		downgrader.MarkForDowngrade(addr2)
+
+		require.True(
+			t, downgrader.ShouldDowngrade(addr2),
+			"addr2 should be marked",
+		)
+		require.False(
+			t, downgrader.ShouldDowngrade(addr2),
+			"addr2 should be consumed after ShouldDowngrade",
+		)
+	})
+}
+
+// TestP2PDowngraderLRUEviction tests the LRU eviction behavior of the cache.
+func TestP2PDowngraderLRUEviction(t *testing.T) {
+	t.Parallel()
+
+	rapid.Check(t, func(t *rapid.T) {
+		// Capacity for the LRU cache.
+		capacity := rapid.UintRange(1, 5).Draw(t, "capacity")
+
+		// Generate a list of unique addresses, one more than the capacity
+		// to trigger eviction.
+		numAddresses := int(capacity + 1)
+		addresses := rapid.SliceOfNDistinct(
+			rapid.String(), numAddresses, numAddresses, rapid.ID,
+		).Draw(t, "addresses")
+
+		downgrader := NewP2PDowngrader(capacity)
+
+		// Mark the first 'capacity' addresses. These are addresses[0]
+		// through addresses[capacity-1]. In dcrd/lru, Add puts new
+		// items at the front (most recent). So, after this loop,
+		// addresses[capacity-1] is newest, addresses[0] is oldest.
+		for i := 0; i < int(capacity); i++ {
+			downgrader.MarkForDowngrade(addresses[i])
+		}
+
+		// The address that should be evicted is addresses[0] (the
+
+		addressToBeEvicted := addresses[0]
+
+		// The address that causes the eviction is addresses[capacity].
+		evictingAddress := addresses[capacity]
+		downgrader.MarkForDowngrade(evictingAddress)
+
+		// Check that the address that should have been evicted is no
+		// longer marked.
+		require.False(
+			t, downgrader.ShouldDowngrade(addressToBeEvicted),
+			"address %s should have been evicted by %s",
+			addressToBeEvicted, evictingAddress,
+		)
+
+		// Check that the evicting address is marked and consumable.
+		require.True(
+			t, downgrader.ShouldDowngrade(evictingAddress),
+			"address %s (the evicting one) should "+
+				"be marked", evictingAddress,
+		)
+
+		// Check the remaining (capacity-1) addresses that were not the
+		// first one. These are addresses[1] through
+		// addresses[capacity-1]. They should still be present.
+		for i := 1; i < int(capacity); i++ {
+			require.True(
+				t, downgrader.ShouldDowngrade(addresses[i]),
+				"address %s should still be marked",
+				addresses[i],
+			)
+		}
+	})
+}
+
+// TestP2PDowngraderMarkExistingUpdatesLRU tests that marking an existing
+// address moves it to the front of the LRU list. This prevents it from being
+// evicted prematurely.
+func TestP2PDowngraderMarkExistingUpdatesLRU(t *testing.T) {
+	t.Parallel()
+
+	rapid.Check(t, func(t *rapid.T) {
+		const capacity = 3
+
+		// Generate 4 unique addresses.
+		numAddresses := capacity + 1
+		addresses := rapid.SliceOfNDistinct(
+			rapid.String(), numAddresses, numAddresses, rapid.ID,
+		).Draw(t, "addresses")
+
+		addrA := addresses[0]
+		addrB := addresses[1]
+		addrC := addresses[2]
+
+		// This will be the evicting address.
+		addrD := addresses[3]
+
+		downgrader := NewP2PDowngrader(capacity)
+
+		// Step 1: Mark A, B, C.
+		// Cache state (newest to oldest): [C, B, A].
+		downgrader.MarkForDowngrade(addrA)
+		downgrader.MarkForDowngrade(addrB)
+		downgrader.MarkForDowngrade(addrC)
+
+		// Step 2: Mark A again. This should move A to the front
+		// (newest). Cache state (newest to oldest): [A, C, B].
+		downgrader.MarkForDowngrade(addrA)
+
+		// Step 3: Mark D. This should evict B (which is now the
+		// oldest). Cache state (newest to oldest): [D, A, C].
+		downgrader.MarkForDowngrade(addrD)
+
+		// Assert that B was evicted.
+		require.False(
+			t, downgrader.ShouldDowngrade(addrB),
+			"addrB should have been evicted",
+		)
+
+		// Assert that A, C, and D are still present and consumable.
+		// Order of checking does not strictly matter here, just
+		// presence.
+		require.True(
+			t, downgrader.ShouldDowngrade(addrA),
+			"addrA should be marked (was re-marked "+
+				"and moved to front)",
+		)
+		require.True(
+			t, downgrader.ShouldDowngrade(addrC),
+			"addrC should be marked",
+		)
+		require.True(
+			t, downgrader.ShouldDowngrade(addrD),
+			"addrD should be marked (evicted B)",
+		)
+
+		// Verify they are consumed.
+		require.False(
+			t, downgrader.ShouldDowngrade(addrA),
+			"addrA should be consumed",
+		)
+		require.False(
+			t, downgrader.ShouldDowngrade(addrC),
+			"addrC should be consumed",
+		)
+		require.False(
+			t, downgrader.ShouldDowngrade(addrD),
+			"addrD should be consumed",
+		)
+	})
+}
diff --git a/v2transport/transport_test.go b/v2transport/transport_test.go
new file mode 100644
index 00000000..fec8efbd
--- /dev/null
+++ b/v2transport/transport_test.go
@@ -0,0 +1,479 @@
+package v2transport
+
+import (
+	"bytes"
+	"encoding/hex"
+	"strings"
+	"testing"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/btcec/v2/ellswift"
+	"github.com/btcsuite/btcd/wire"
+)
+
+func setHex(hexString string) *btcec.FieldVal {
+	if len(hexString)%2 != 0 {
+		hexString = "0" + hexString
+	}
+	bytes, _ := hex.DecodeString(hexString)
+
+	var f btcec.FieldVal
+	f.SetByteSlice(bytes)
+
+	return &f
+}
+
+func TestPacketEncodingVectors(t *testing.T) {
+	tests := []struct {
+		inIdx                 int
+		inPrivOurs            string
+		inEllswiftOurs        string
+		inEllswiftTheirs      string
+		inInitiating          bool
+		inContents            string
+		inMultiply            int
+		inAad                 string
+		inIgnore              bool
+		midXOurs              string
+		midXTheirs            string
+		midXShared            string
+		midSharedSecret       string
+		midInitiatorL         string
+		midInitiatorP         string
+		midResponderL         string
+		midResponderP         string
+		midSendGarbageTerm    string
+		midRecvGarbageTerm    string
+		outSessionID          string
+		outCiphertext         string
+		outCiphertextEndsWith string
+	}{
+		{
+			inIdx:                 1,
+			inPrivOurs:            "61062ea5071d800bbfd59e2e8b53d47d194b095ae5a4df04936b49772ef0d4d7",
+			inEllswiftOurs:        "ec0adff257bbfe500c188c80b4fdd640f6b45a482bbc15fc7cef5931deff0aa186f6eb9bba7b85dc4dcc28b28722de1e3d9108b985e2967045668f66098e475b",
+			inEllswiftTheirs:      "a4a94dfce69b4a2a0a099313d10f9f7e7d649d60501c9e1d274c300e0d89aafaffffffffffffffffffffffffffffffffffffffffffffffffffffffff8faf88d5",
+			inInitiating:          true,
+			inContents:            "8e",
+			inMultiply:            1,
+			inAad:                 "",
+			inIgnore:              false,
+			midXOurs:              "19e965bc20fc40614e33f2f82d4eeff81b5e7516b12a5c6c0d6053527eba0923",
+			midXTheirs:            "0c71defa3fafd74cb835102acd81490963f6b72d889495e06561375bd65f6ffc",
+			midXShared:            "4eb2bf85bd00939468ea2abb25b63bc642e3d1eb8b967fb90caa2d89e716050e",
+			midSharedSecret:       "c6992a117f5edbea70c3f511d32d26b9798be4b81a62eaee1a5acaa8459a3592",
+			midInitiatorL:         "9a6478b5fbab1f4dd2f78994b774c03211c78312786e602da75a0d1767fb55cf",
+			midInitiatorP:         "7d0c7820ba6a4d29ce40baf2caa6035e04f1e1cefd59f3e7e59e9e5af84f1f51",
+			midResponderL:         "17bc726421e4054ac6a1d54915085aaa766f4d3cf67bbd168e6080eac289d15e",
+			midResponderP:         "9f0fc1c0e85fd9a8eee07e6fc41dba2ff54c7729068a239ac97c37c524cca1c0",
+			midSendGarbageTerm:    "faef555dfcdb936425d84aba524758f3",
+			midRecvGarbageTerm:    "02cb8ff24307a6e27de3b4e7ea3fa65b",
+			outSessionID:          "ce72dffb015da62b0d0f5474cab8bc72605225b0cee3f62312ec680ec5f41ba5",
+			outCiphertext:         "7530d2a18720162ac09c25329a60d75adf36eda3c3",
+			outCiphertextEndsWith: "",
+		},
+		{
+			inIdx:                 999,
+			inPrivOurs:            "1f9c581b35231838f0f17cf0c979835baccb7f3abbbb96ffcc318ab71e6e126f",
+			inEllswiftOurs:        "a1855e10e94e00baa23041d916e259f7044e491da6171269694763f018c7e63693d29575dcb464ac816baa1be353ba12e3876cba7628bd0bd8e755e721eb0140",
+			inEllswiftTheirs:      "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f0000000000000000000000000000000000000000000000000000000000000000",
+			inInitiating:          false,
+			inContents:            "3eb1d4e98035cfd8eeb29bac969ed3824a",
+			inMultiply:            1,
+			inAad:                 "",
+			inIgnore:              false,
+			midXOurs:              "45b6f1f684fd9f2b16e2651ddc47156c0695c8c5cd2c0c9df6d79a1056c61120",
+			midXTheirs:            "edd1fd3e327ce90cc7a3542614289aee9682003e9cf7dcc9cf2ca9743be5aa0c",
+			midXShared:            "c40eb6190caf399c9007254ad5e5fa20d64af2b41696599c59b2191d16992955",
+			midSharedSecret:       "a0138f564f74d0ad70bc337dacc9d0bf1d2349364caf1188a1e6e8ddb3b7b184",
+			midInitiatorL:         "b82a0a7ce7219777f914d2ab873c5c487c56bd7b68622594d67fe029a8fa7def",
+			midInitiatorP:         "d760ba8f62dd3d29d7d5584e310caf2540285edc6b51c640f9497e99c3536fd2",
+			midResponderL:         "9db0c6f9a903cbab5d7b3c58273a3421eec0001814ec53236bd405131a0d8e90",
+			midResponderP:         "23d2b5e653e6a3a8db160a2ca03d11cb5a79983babba861fcb57c38413323c0c",
+			midSendGarbageTerm:    "efb64fd80acd3825ac9bc2a67216535a",
+			midRecvGarbageTerm:    "b3cb553453bceb002897e751ff7588bf",
+			outSessionID:          "9267c54560607de73f18c563b76a2442718879c52dd39852885d4a3c9912c9ea",
+			outCiphertext:         "1da1bcf589f9b61872f45b7fa5371dd3f8bdf5d515b0c5f9fe9f0044afb8dc0aa1cd39a8c4",
+			outCiphertextEndsWith: "",
+		},
+		{
+			inIdx:                 0,
+			inPrivOurs:            "0286c41cd30913db0fdff7a64ebda5c8e3e7cef10f2aebc00a7650443cf4c60d",
+			inEllswiftOurs:        "d1ee8a93a01130cbf299249a258f94feb5f469e7d0f2f28f69ee5e9aa8f9b54a60f2c3ff2d023634ec7f4127a96cc11662e402894cf1f694fb9a7eaa5f1d9244",
+			inEllswiftTheirs:      "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff22d5e441524d571a52b3def126189d3f416890a99d4da6ede2b0cde1760ce2c3f98457ae",
+			inInitiating:          true,
+			inContents:            "054290a6c6ba8d80478172e89d32bf690913ae9835de6dcf206ff1f4d652286fe0ddf74deba41d55de3edc77c42a32af79bbea2c00bae7492264c60866ae5a",
+			inMultiply:            1,
+			inAad:                 "84932a55aac22b51e7b128d31d9f0550da28e6a3f394224707d878603386b2f9d0c6bcd8046679bfed7b68c517e7431e75d9dd34605727d2ef1c2babbf680ecc8d68d2c4886e9953a4034abde6da4189cd47c6bb3192242cf714d502ca6103ee84e08bc2ca4fd370d5ad4e7d06c7fbf496c6c7cc7eb19c40c61fb33df2a9ba48497a96c98d7b10c1f91098a6b7b16b4bab9687f27585ade1491ae0dba6a79e1e2d85dd9d9d45c5135ca5fca3f0f99a60ea39edbc9efc7923111c937913f225d67788d5f7e8852b697e26b92ec7bfcaa334a1665511c2b4c0a42d06f7ab98a9719516c8fd17f73804555ee84ab3b7d1762f6096b778d3cb9c799cbd49a9e4a325197b4e6cc4a5c4651f8b41ff88a92ec428354531f970263b467c77ed11312e2617d0d53fe9a8707f51f9f57a77bfb49afe3d89d85ec05ee17b9186f360c94ab8bb2926b65ca99dae1d6ee1af96cad09de70b6767e949023e4b380e66669914a741ed0fa420a48dbc7bfae5ef2019af36d1022283dd90655f25eec7151d471265d22a6d3f91dc700ba749bb67c0fe4bc0888593fbaf59d3c6fff1bf756a125910a63b9682b597c20f560ecb99c11a92c8c8c3f7fbfaa103146083a0ccaecf7a5f5e735a784a8820155914a289d57d8141870ffcaf588882332e0bcd8779efa931aa108dab6c3cce76691e345df4a91a03b71074d66333fd3591bff071ea099360f787bbe43b7b3dff2a59c41c7642eb79870222ad1c6f2e5a191ed5acea51134679587c9cf71c7d8ee290be6bf465c4ee47897a125708704ad610d8d00252d01959209d7cd04d5ecbbb1419a7e84037a55fefa13dee464b48a35c96bcb9a53e7ed461c3a1607ee00c3c302fd47cd73fda7493e947c9834a92d63dcfbd65aa7c38c3e3a2748bb5d9a58e7495d243d6b741078c8f7ee9c8813e473a323375702702b0afae1550c8341eedf5247627343a95240cb02e3e17d5dca16f8d8d3b2228e19c06399f8ec5c5e9dbe4caef6a0ea3ffb1d3c7eac03ae030e791fa12e537c80d56b55b764cadf27a8701052df1282ba8b5e3eb62b5dc7973ac40160e00722fa958d95102fc25c549d8c0e84bed95b7acb61ba65700c4de4feebf78d13b9682c52e937d23026fb4c6193e6644e2d3c99f91f4f39a8b9fc6d013f89c3793ef703987954dc0412b550652c01d922f525704d32d70d6d4079bc3551b563fb29577b3aecdc9505011701dddfd94830431e7a4918927ee44fb3831ce8c4513839e2deea1287f3fa1ab9b61a256c09637dbc7b4f0f8fbb783840f9c24526da883b0df0c473cf231656bd7bc1aaba7f321fec0971c8c2c3444bff2f55e1df7fea66ec3e440a612db9aa87bb505163a59e06b96d46f50d8120b92814ac5ab146bc78dbbf91065af26107815678ce6e33812e6bf3285d4ef3b7b04b076f21e7820dcbfdb4ad5218cf4ff6a65812d8fcb98ecc1e95e2fa58e3efe4ce26cd0bd400d6036ab2ad4f6c713082b5e3f1e04eb9e3b6c8f63f57953894b9e220e0130308e1fd91f72d398c1e7962ca2c31be83f31d6157633581a0a6910496de8d55d3d07090b6aa087159e388b7e7dec60f5d8a60d93ca2ae91296bd484d916bfaaa17c8f45ea4b1a91b37c82821199a2b7596672c37156d8701e7352aa48671d3b1bbbd2bd5f0a2268894a25b0cb2514af39c8743f8cce8ab4b523053739fd8a522222a09acf51ac704489cf17e4b7125455cb8f125b4d31af1eba1f8cf7f81a5a100a141a7ee72e8083e065616649c241f233645c5fc865d17f0285f5c52d9f45312c979bfb3ce5f2a1b951deddf280ffb3f370410cffd1583bfa90077835aa201a0712d1dcd1293ee177738b14e6b5e2a496d05220c3253bb6578d6aff774be91946a614dd7e879fb3dcf7451e0b9adb6a8c44f53c2c464bcc0019e9fad89cac7791a0a3f2974f759a9856351d4d2d7c5612c17cfc50f8479945df57716767b120a590f4bf656f4645029a525694d8a238446c5f5c2c1c995c09c1405b8b1eb9e0352ffdf766cc964f8dcf9f8f043dfab6d102cf4b298021abd78f1d9025fa1f8e1d710b38d9d1652f2d88d1305874ec41609b6617b65c5adb19b6295dc5c5da5fdf69f28144ea12f17c3c6fcce6b9b5157b3dfc969d6725fa5b098a4d9b1d31547ed4c9187452d281d0a5d456008caf1aa251fac8f950ca561982dc2dc908d3691ee3b6ad3ae3d22d002577264ca8e49c523bd51c4846be0d198ad9407bf6f7b82c79893eb2c05fe9981f687a97a4f01fe45ff8c8b7ecc551135cd960a0d6001ad35020be07ffb53cb9e731522ca8ae9364628914b9b8e8cc2f37f03393263603cc2b45295767eb0aac29b0930390eb89587ab2779d2e3decb8042acece725ba42eda650863f418f8d0d50d104e44fbbe5aa7389a4a144a8cecf00f45fb14c39112f9bfb56c0acbd44fa3ff261f5ce4acaa5134c2c1d0cca447040820c81ab1bcdc16aa075b7c68b10d06bbb7ce08b5b805e0238f24402cf24a4b4e00701935a0c68add3de090903f9b85b153cb179a582f57113bfc21c2093803f0cfa4d9d4672c2b05a24f7e4c34a8e9101b70303a7378b9c50b6cddd46814ef7fd73ef6923feceab8fc5aa8b0d185f2e83c7a99dcb1077c0ab5c1f5d5f01ba2f0420443f75c4417db9ebf1665efbb33dca224989920a64b44dc26f682cc77b4632c8454d49135e52503da855bc0f6ff8edc1145451a9772c06891f41064036b66c3119a0fc6e80dffeb65dc456108b7ca0296f4175fff3ed2b0f842cd46bd7e86f4c62dfaf1ddbf836263c00b34803de164983d0811cebfac86e7720c726d3048934c36c23189b02386a722ca9f0fe00233ab50db928d3bccea355cc681144b8b7edcaae4884d5a8f04425c0890ae2c74326e138066d8c05f4c82b29df99b034ea727afde590a1f2177ace3af99cfb1729d6539ce7f7f7314b046aab74497e63dd399e1f7d5f16517c23bd830d1fdee810f3c3b77573dd69c4b97d80d71fb5a632e00acdfa4f8e829faf3580d6a72c40b28a82172f8dcd4627663ebf6069736f21735fd84a226f427cd06bb055f94e7c92f31c48075a2955d82a5b9d2d0198ce0d4e131a112570a8ee40fb80462a81436a58e7db4e34b6e2c422e82f934ecda9949893da5730fc5c23c7c920f363f85ab28cc6a4206713c3152669b47efa8238fa826735f17b4e78750276162024ec85458cd5808e06f40dd9fd43775a456a3ff6cae90550d76d8b2899e0762ad9a371482b3e38083b1274708301d6346c22fea9bb4b73db490ff3ab05b2f7f9e187adef139a7794454b7300b8cc64d3ad76c0e4bc54e08833a4419251550655380d675bc91855aeb82585220bb97f03e976579c08f321b5f8f70988d3061f41465517d53ac571dbf1b24b94443d2e9a8e8a79b392b3d6a4ecdd7f626925c365ef6221305105ce9b5f5b6ecc5bed3d702bd4b7f5008aa8eb8c7aa3ade8ecf6251516fbefeea4e1082aa0e1848eddb31ffe44b04792d296054402826e4bd054e671f223e5557e4c94f89ca01c25c44f1a2ff2c05a70b43408250705e1b858bf0670679fdcd379203e36be3500dd981b1a6422c3cf15224f7fefdef0a5f225c5a09d15767598ecd9e262460bb33a4b5d09a64591efabc57c923d3be406979032ae0bc0997b65336a06dd75b253332ad6a8b63ef043f780a1b3fb6d0b6cad98b1ef4a02535eb39e14a866cfc5fc3a9c5deb2261300d71280ebe66a0776a151469551c3c5fa308757f956655278ec6330ae9e3625468c5f87e02cd9a6489910d4143c1f4ee13aa21a6859d907b788e28572fecee273d44e4a900fa0aa668dd861a60fb6b6b12c2c5ef3c8df1bd7ef5d4b0d1cdb8c15fffbb365b9784bd94abd001c6966216b9b67554ad7cb7f958b70092514f7800fc40244003e0fd1133a9b850fb17f4fcafde07fc87b07fb510670654a5d2d6fc9876ac74728ea41593beef003d6858786a52d3a40af7529596767c17000bfaf8dc52e871359f4ad8bf6e7b2853e5229bdf39657e213580294a5317c5df172865e1e17fe37093b585e04613f5f078f761b2b1752eb32983afda24b523af8851df9a02b37e77f543f18888a782a994a50563334282bf9cdfccc183fdf4fcd75ad86ee0d94f91ee2300a5befbccd14e03a77fc031a8cfe4f01e4c5290f5ac1da0d58ea054bd4837cfd93e5e34fc0eb16e48044ba76131f228d16cde9b0bb978ca7cdcd10653c358bdb26fdb723a530232c32ae0a4cecc06082f46e1c1d596bfe60621ad1e354e01e07b040cc7347c016653f44d926d13ca74e6cbc9d4ab4c99f4491c95c76fff5076b3936eb9d0a286b97c035ca88a3c6309f5febfd4cdaac869e4f58ed409b1e9eb4192fb2f9c2f12176d460fd98286c9d6df84598f260119fd29c63f800c07d8df83d5cc95f8c2fea2812e7890e8a0718bb1e031ecbebc0436dcf3e3b9a58bcc06b4c17f711f80fe1dffc3326a6eb6e00283055c6dabe20d311bfd5019591b7954f8163c9afad9ef8390a38f3582e0a79cdf0353de8eeb6b5f9f27b16ffdef7dd62869b4840ee226ccdce95e02c4545eb981b60571cd83f03dc5eaf8c97a0829a4318a9b3dc06c0e003db700b2260ff1fa8fee66890e637b109abb03ec901b05ca599775f48af50154c0e67d82bf0f558d7d3e0778dc38bea1eb5f74dc8d7f90abdf5511a424be66bf8b6a3cacb477d2e7ef4db68d2eba4d5289122d851f9501ba7e9c4957d8eba3be3fc8e785c4265a1d65c46f2809b70846c693864b169c9dcb78be26ea14b8613f145b01887222979a9e67aee5f800caa6f5c4229bdeefc901232ace6143c9865e4d9c07f51aa200afaf7e48a7d1d8faf366023beab12906ffcb3eaf72c0eb68075e4daf3c080e0c31911befc16f0cc4a09908bb7c1e26abab38bd7b788e1a09c0edf1a35a38d2ff1d3ed47fcdaae2f0934224694f5b56705b9409b6d3d64f3833b686f7576ec64bbdd6ff174e56c2d1edac0011f904681a73face26573fbba4e34652f7ae84acfb2fa5a5b3046f98178cd0831df7477de70e06a4c00e305f31aafc026ef064dd68fd3e4252b1b91d617b26c6d09b6891a00df68f105b5962e7f9d82da101dd595d286da721443b72b2aba2377f6e7772e33b3a5e3753da9c2578c5d1daab80187f55518c72a64ee150a7cb5649823c08c9f62cd7d020b45ec2cba8310db1a7785a46ab24785b4d54ff1660b5ca78e05a9a55edba9c60bf044737bc468101c4e8bd1480d749be5024adefca1d998abe33eaeb6b11fbb39da5d905fdd3f611b2e51517ccee4b8af72c2d948573505590d61a6783ab7278fc43fe55b1fcc0e7216444d3c8039bb8145ef1ce01c50e95a3f3feab0aee883fdb94cc13ee4d21c542aa795e18932228981690f4d4c57ca4db6eb5c092e29d8a05139d509a8aeb48baa1eb97a76e597a32b280b5e9d6c36859064c98ff96ef5126130264fa8d2f49213870d9fb036cff95da51f270311d9976208554e48ffd486470d0ecdb4e619ccbd8226147204baf8e235f54d8b1cba8fa34a9a4d055de515cdf180d2bb6739a175183c472e30b5c914d09eeb1b7dafd6872b38b48c6afc146101200e6e6a44fe5684e220adc11f5c403ddb15df8051e6bdef09117a3a5349938513776286473a3cf1d2788bb875052a2e6459fa7926da33380149c7f98d7700528a60c954e6f5ecb65842fde69d614be69eaa2040a4819ae6e756accf936e14c1e894489744a79c1f2c1eb295d13e2d767c09964b61f9cfe497649f712",
+			inIgnore:              false,
+			midXOurs:              "33a32d10066fa3963a9518a14d1bd1cb5ccaceaeaaeddb4d7aead90c08395bfd",
+			midXTheirs:            "568146140669e69646a6ffeb3793e8010e2732209b4c34ec13e209a070109183",
+			midXShared:            "a1017beaa8784f283dee185cd847ae3a327a981e62ae21e8c5face175fc97e9b",
+			midSharedSecret:       "250b93570d411149105ab8cb0bc5079914906306368c23e9d77c2a33265b994c",
+			midInitiatorL:         "4ec7daf7294a4a2c717442dd21cf2f052a3bfe9d535b55da0f66fecf87a27534",
+			midInitiatorP:         "52ab4db9c4b06621f8ded3405691eb32465b1360d15a6b127ded4d15f9cde466",
+			midResponderL:         "ba9906da802407ddedf6733e29f3996c62425e79d3cbfeebbd6ec4cdc7c976a8",
+			midResponderP:         "ee661e18c97319ad071106bf35fe1085034832f70718d92f887932128b6100c7",
+			midSendGarbageTerm:    "d4e3f18ac2e2095edb5c3b94236118ad",
+			midRecvGarbageTerm:    "4faa6c4233d9fd53d170ede4172142a8",
+			outSessionID:          "23f154ac43cfc59c4243e9fc68aeec8f19ad3942d74108e833b36f0dd3dcd357",
+			outCiphertext:         "8da7de6ea7bf2a81a396a42880ba1f5756734c4821309ac9aeffa2a26ce86873b9dc4935a772de6ec5162c6d075b14536800fb174841153511bfb597e992e2fe8a450c4bce102cc550bb37fd564c4d60bf884e",
+			outCiphertextEndsWith: "",
+		},
+		{
+			inIdx:                 223,
+			inPrivOurs:            "6c77432d1fda31e9f942f8af44607e10f3ad38a65f8a4bddae823e5eff90dc38",
+			inEllswiftOurs:        "d2685070c1e6376e633e825296634fd461fa9e5bdf2109bcebd735e5a91f3e587c5cb782abb797fbf6bb5074fd1542a474f2a45b673763ec2db7fb99b737bbb9",
+			inEllswiftTheirs:      "56bd0c06f10352c3a1a9f4b4c92f6fa2b26df124b57878353c1fc691c51abea77c8817daeeb9fa546b77c8daf79d89b22b0e1b87574ece42371f00237aa9d83a",
+			inInitiating:          false,
+			inContents:            "7e0e78eb6990b059e6cf0ded66ea93ef82e72aa2f18ac24f2fc6ebab561ae557420729da103f64cecfa20527e15f9fb669a49bbbf274ef0389b3e43c8c44e5f60bf2ac38e2b55e7ec4273dba15ba41d21f8f5b3ee1688b3c29951218caf847a97fb50d75a86515d445699497d968164bf740012679b8962de573be941c62b7ef",
+			inMultiply:            1,
+			inAad:                 "",
+			inIgnore:              true,
+			midXOurs:              "193d019db571162e52567e0cfdf9dd6964394f32769ae2edc4933b03b502d771",
+			midXTheirs:            "2dd7b9cc85524f8670f695c3143ac26b45cebcabb2782a85e0fe15aee3956535",
+			midXShared:            "5e35f94adfd57976833bffec48ef6dde983d18a55501154191ea352ef06732ee",
+			midSharedSecret:       "1918b741ef5f9d1d7670b050c152b4a4ead2c31be9aecb0681c0cd4324150853",
+			midInitiatorL:         "97124c56236425d792b1ec85e34b846e8d88c9b9f1d4f23ac6cdcc4c177055a0",
+			midInitiatorP:         "8c71b468c61119415e3c1dfdd184134211951e2f623199629a46bff9673611f2",
+			midResponderL:         "b43b8791b51ed682f56d64351601be28e478264411dcf963b14ee60b9ae427fa",
+			midResponderP:         "794dde4b38ef04250c534a7fa638f2e8cc8b6d2c6110ec290ab0171fdf277d51",
+			midSendGarbageTerm:    "cf2e25f23501399f30738d7eee652b90",
+			midRecvGarbageTerm:    "225a477a28a54ea7671d2b217a9c29db",
+			outSessionID:          "7ec02fea8c1484e3d0875f978c5f36d63545e2e4acf56311394422f4b66af612",
+			outCiphertext:         "",
+			outCiphertextEndsWith: "729847a3e9eba7a5bff454b5de3b393431ee360736b6c030d7a5bd01d1203d2e98f528543fd2bf886ccaa1ada5e215a730a36b3f4abfc4e252c89eb01d9512f94916dae8a76bf16e4da28986ffe159090fe5267ee3394300b7ccf4dfad389a26321b3a3423e4594a82ccfbad16d6561ecb8772b0cb040280ff999a29e3d9d4fd",
+		},
+		{
+			inIdx:                 448,
+			inPrivOurs:            "a6ec25127ca1aa4cf16b20084ba1e6516baae4d32422288e9b36d8bddd2de35a",
+			inEllswiftOurs:        "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff053d7ecca53e33e185a8b9be4e7699a97c6ff4c795522e5918ab7cd6b6884f67e683f3dc",
+			inEllswiftTheirs:      "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffa7730be30000000000000000000000000000000000000000000000000000000000000000",
+			inInitiating:          true,
+			inContents:            "00cf68f8f7ac49ffaa02c4864fdf6dfe7bbf2c740b88d98c50ebafe32c92f3427f57601ffcb21a3435979287db8fee6c302926741f9d5e464c647eeb9b7acaeda46e00abd7506fc9a719847e9a7328215801e96198dac141a15c7c2f68e0690dd1176292a0dded04d1f548aad88f1aebdc0a8f87da4bb22df32dd7c160c225b843e83f6525d6d484f502f16d923124fc538794e21da2eb689d18d87406ecced5b9f92137239ed1d37bcfa7836641a83cf5e0a1cf63f51b06f158e499a459ede41c",
+			inMultiply:            1,
+			inAad:                 "",
+			inIgnore:              false,
+			midXOurs:              "02b225089255f7b02b20276cfe9779144df8fb1957b477bff3239d802d1256e9",
+			midXTheirs:            "5232c4b6bde9d3d45d7b763ebd7495399bb825cc21de51011761cd81a51bdc84",
+			midXShared:            "379223d2f1ea7f8a22043c4ce4122623098309e15b1ce58286ebe3d3bf40f4e1",
+			midSharedSecret:       "dd210aa6629f20bb328e5d89daa6eb2ac3d1c658a725536ff154f31b536c23b2",
+			midInitiatorL:         "393472f85a5cc6b0f02c4bd466db7a2dc5b91fc9dcb15c0dd6dc21116ece8bca",
+			midInitiatorP:         "c80b87b793db47320b2795db66d331bd3021cc24e360d59d0fa8974f54687e0c",
+			midResponderL:         "ef16a43d77e2b270b0a145ee1618d35f3c943cc7877d6cfcff2287d41692be39",
+			midResponderP:         "20d4b62e2d982c61bb0cc39a93283d98af36530ef12331d44b2477b0e521b490",
+			midSendGarbageTerm:    "fead69be77825a23daec377c362aa560",
+			midRecvGarbageTerm:    "511d4980526c5e64aa7187462faeafdd",
+			outSessionID:          "acb8f084ea763ddd1b92ac4ed23bf44de20b84ab677d4e4e6666a6090d40353d",
+			outCiphertext:         "",
+			outCiphertextEndsWith: "77b4656934a82de1a593d8481f020194ddafd8cac441f9d72aeb8721e6a14f49698ca6d9b2b6d59d07a01aa552fd4d5b68d0d1617574c77dea10bfadbaa31b83885b7ceac2fd45e3e4a331c51a74e7b1698d81b64c87c73c5b9258b4d83297f9debc2e9aa07f8572ff434dc792b83ecf07b3197de8dc9cf7be56acb59c66cff5",
+		},
+		{
+			inIdx:                 673,
+			inPrivOurs:            "0af952659ed76f80f585966b95ab6e6fd68654672827878684c8b547b1b94f5a",
+			inEllswiftOurs:        "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffc81017fd92fd31637c26c906b42092e11cc0d3afae8d9019d2578af22735ce7bc469c72d",
+			inEllswiftTheirs:      "9652d78baefc028cd37a6a92625b8b8f85fde1e4c944ad3f20e198bef8c02f19fffffffffffffffffffffffffffffffffffffffffffffffffffffffff2e91870",
+			inInitiating:          false,
+			inContents:            "5c6272ee55da855bbbf7b1246d9885aa7aa601a715ab86fa46c50da533badf82b97597c968293ae04e",
+			inMultiply:            97561,
+			inAad:                 "",
+			inIgnore:              false,
+			midXOurs:              "4b1767466fe2fb8deddf2dc52cc19c7e2032007e19bfb420b30a80152d0f22d6",
+			midXTheirs:            "64c383e0e78ac99476ddff2061683eeefa505e3666673a1371342c3e6c26981d",
+			midXShared:            "5bcfeac98d87e87e158bf839f1269705429f7af2a25b566a25811b5f9aef9560",
+			midSharedSecret:       "3568f2aea2e14ef4ee4a3c2a8b8d31bc5e3187ba86db10739b4ff8ec92ff6655",
+			midInitiatorL:         "c7df866a62b7d404eb530b2be245a7aece0fb4791402a1de8f33530cbf777cc1",
+			midInitiatorP:         "8f732e4aae2ba9314e0982492fa47954de9c189d92fbc549763b27b1b47642ce",
+			midResponderL:         "992085edfecb92c62a3a7f96ea416f853f34d0dfe065b966b6968b8b87a83081",
+			midResponderP:         "c5ba5eaf9e1c807154ebab3ea472499e815a7be56dfaf0c201cf6e91ffeca8e6",
+			midSendGarbageTerm:    "5e2375ac629b8df1e4ff3617c6255a70",
+			midRecvGarbageTerm:    "70bcbffcb62e4d29d2605d30bceef137",
+			outSessionID:          "7332e92a3f9d2792c4d444fac5ed888c39a073043a65eefb626318fd649328f8",
+			outCiphertext:         "",
+			outCiphertextEndsWith: "657a4a19711ce593c3844cb391b224f60124aba7e04266233bc50cafb971e26c7716b76e98376448f7d214dd11e629ef9a974d60e3770a695810a61c4ba66d78b936ee7892b98f0b48ddae9fcd8b599dca1c9b43e9b95e0226cf8d4459b8a7c2c4e6db80f1d58c7b20dd7208fa5c1057fb78734223ee801dbd851db601fee61e",
+		},
+		{
+			inIdx:                 1024,
+			inPrivOurs:            "f90e080c64b05824c5a24b2501d5aeaf08af3872ee860aa80bdcd430f7b63494",
+			inEllswiftOurs:        "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff115173765dc202cf029ad3f15479735d57697af12b0131dd21430d5772e4ef11474d58b9",
+			inEllswiftTheirs:      "12a50f3fafea7c1eeada4cf8d33777704b77361453afc83bda91eef349ae044d20126c6200547ea5a6911776c05dee2a7f1a9ba7dfbabbbd273c3ef29ef46e46",
+			inInitiating:          true,
+			inContents:            "5f67d15d22ca9b2804eeab0a66f7f8e3a10fa5de5809a046084348cbc5304e843ef96f59a59c7d7fdfe5946489f3ea297d941bac326225df316a25fc90f0e65b0d31a9c497e960fdbf8c482516bc8a9c1c77b7f6d0e1143810c737f76f9224e6f2c9af5186b4f7259c7e8d165b6e4fe3d38a60bdbdd4d06ecdcaaf62086070dbb68686b802d53dfd7db14b18743832605f5461ad81e2af4b7e8ff0eff0867a25b93cec7becf15c43131895fed09a83bf1ee4a87d44dd0f02a837bf5a1232e201cb882734eb9643dc2dc4d4e8b5690840766212c7ac8f38ad8a9ec47c7a9b3e022ae3eb6a32522128b518bd0d0085dd81c5",
+			inMultiply:            69615,
+			inAad:                 "",
+			inIgnore:              true,
+			midXOurs:              "8b8de966150bf872b4b695c9983df519c909811954d5d76e99ed0d5f1860247b",
+			midXTheirs:            "eef379db9bd4b1aa90fc347fad33f7d53083389e22e971036f59f4e29d325ac2",
+			midXShared:            "0a402d812314646ccc2565c315d1429ec1ed130ff92ff3f48d948f29c3762cf1",
+			midSharedSecret:       "e25461fb0e4c162e18123ecde88342d54d449631e9b75a266fd9260c2bb2f41d",
+			midInitiatorL:         "97771ce2ce17a25c3d65bf9f8e4acb830dce8d41392be3e4b8ed902a3106681a",
+			midInitiatorP:         "2e7022b4eae9152942f68160a93e25d3e197a557385594aa587cb5e431bb470d",
+			midResponderL:         "613f85a82d783ce450cfd7e91a027fcc4ad5610872f83e4dbe9e2202184c6d6e",
+			midResponderP:         "cb5de4ed1083222e381401cf88e3167796bc9ab5b8aa1f27b718f39d1e6c0e87",
+			midSendGarbageTerm:    "b709dea25e0be287c50e3603482c2e98",
+			midRecvGarbageTerm:    "1f677e9d7392ebe3633fd82c9efb0f16",
+			outSessionID:          "889f339285564fd868401fac8380bb9887925122ec8f31c8ae51ce067def103b",
+			outCiphertext:         "",
+			outCiphertextEndsWith: "7c4b9e1e6c1ce69da7b01513cdc4588fd93b04dafefaf87f31561763d906c672bac3dfceb751ebd126728ac017d4d580e931b8e5c7d5dfe0123be4dc9b2d2238b655c8a7fadaf8082c31e310909b5b731efc12f0a56e849eae6bfeedcc86dd27ef9b91d159256aa8e8d2b71a311f73350863d70f18d0d7302cf551e4303c7733",
+		},
+	}
+
+	for _, test := range tests {
+		inInitiating := test.inInitiating
+
+		// We need to convert the FieldVal into a ModNScalar so that we can use
+		// the ScalarBaseMultNonConst.
+		inPrivOurs := setHex(test.inPrivOurs)
+		inPrivOursBytes := inPrivOurs.Bytes()
+
+		var inPrivOursScalar btcec.ModNScalar
+		overflow := inPrivOursScalar.SetBytes(inPrivOursBytes)
+		if overflow == 1 {
+			t.Fatalf("unexpected reduction")
+		}
+
+		var inPubOurs btcec.JacobianPoint
+		btcec.ScalarBaseMultNonConst(&inPrivOursScalar, &inPubOurs)
+		inPubOurs.ToAffine()
+
+		midXOurs := setHex(test.midXOurs)
+		if !midXOurs.Equals(&inPubOurs.X) {
+			t.Fatalf("expected mid-state to match our public key")
+		}
+
+		// ellswift_decode takes in ellswift_bytes and returns a proper key.
+		// 1. convert from hex to bytes
+		bytesEllswiftOurs, err := hex.DecodeString(test.inEllswiftOurs)
+		if err != nil {
+			t.Fatalf("unexpected error decoding string")
+		}
+
+		uEllswiftOurs := bytesEllswiftOurs[:32]
+		tEllswiftOurs := bytesEllswiftOurs[32:]
+
+		var (
+			uEllswiftOursFV btcec.FieldVal
+			tEllswiftOursFV btcec.FieldVal
+		)
+
+		truncated := uEllswiftOursFV.SetByteSlice(uEllswiftOurs)
+		if truncated {
+			uEllswiftOursFV.Normalize()
+		}
+
+		truncated = tEllswiftOursFV.SetByteSlice(tEllswiftOurs)
+		if truncated {
+			tEllswiftOursFV.Normalize()
+		}
+
+		xEllswiftOurs, err := ellswift.XSwiftEC(
+			&uEllswiftOursFV, &tEllswiftOursFV,
+		)
+		if err != nil {
+			t.Fatalf("unexpected error during XSwiftEC")
+		}
+
+		if !midXOurs.Equals(xEllswiftOurs) {
+			t.Fatalf("expected mid-state to match decoded ellswift key")
+		}
+
+		bytesEllswiftTheirs, err := hex.DecodeString(test.inEllswiftTheirs)
+		if err != nil {
+			t.Fatalf("unexpected error decoding string")
+		}
+
+		uEllswiftTheirs := bytesEllswiftTheirs[:32]
+		tEllswiftTheirs := bytesEllswiftTheirs[32:]
+
+		var (
+			uEllswiftTheirsFV btcec.FieldVal
+			tEllswiftTheirsFV btcec.FieldVal
+		)
+
+		truncated = uEllswiftTheirsFV.SetByteSlice(uEllswiftTheirs)
+		if truncated {
+			uEllswiftTheirsFV.Normalize()
+		}
+
+		truncated = tEllswiftTheirsFV.SetByteSlice(tEllswiftTheirs)
+		if truncated {
+			tEllswiftTheirsFV.Normalize()
+		}
+
+		xEllswiftTheirs, err := ellswift.XSwiftEC(
+			&uEllswiftTheirsFV, &tEllswiftTheirsFV,
+		)
+		if err != nil {
+			t.Fatalf("unexpected error during XSwiftEC")
+		}
+
+		midXTheirs := setHex(test.midXTheirs)
+		if !midXTheirs.Equals(xEllswiftTheirs) {
+			t.Fatalf("expected mid-state to match decoded ellswift key")
+		}
+
+		privKeyOurs, _ := btcec.PrivKeyFromBytes((*inPrivOursBytes)[:])
+
+		var bytesEllswiftTheirs64 [64]byte
+		copy(bytesEllswiftTheirs64[:], bytesEllswiftTheirs)
+
+		xShared, err := ellswift.EllswiftECDHXOnly(
+			bytesEllswiftTheirs64, privKeyOurs,
+		)
+		if err != nil {
+			t.Fatalf("unexpected error when computing shared x")
+		}
+
+		var xSharedFV btcec.FieldVal
+		overflow = xSharedFV.SetBytes(&xShared)
+		if overflow == 1 {
+			t.Fatalf("unexpected truncation")
+		}
+
+		midXShared := setHex(test.midXShared)
+
+		if !midXShared.Equals(&xSharedFV) {
+			t.Fatalf("expected mid-state x shared")
+		}
+
+		var bytesEllswiftOurs64 [64]byte
+		copy(bytesEllswiftOurs64[:], bytesEllswiftOurs)
+
+		sharedSecret, err := ellswift.V2Ecdh(
+			privKeyOurs, bytesEllswiftTheirs64, bytesEllswiftOurs64, inInitiating,
+		)
+		if err != nil {
+			t.Fatalf("unexpected error when calculating shared secret")
+		}
+
+		midShared, err := hex.DecodeString(test.midSharedSecret)
+		if err != nil {
+			t.Fatalf("unexpected hex decode failure")
+		}
+
+		if !bytes.Equal(midShared, sharedSecret[:]) {
+			t.Fatalf("expected mid shared secret")
+		}
+
+		p := NewPeer()
+
+		buf := bytes.NewBuffer(nil)
+		p.UseReadWriter(buf)
+
+		err = p.createV2Ciphers(midShared, inInitiating, wire.MainNet)
+		if err != nil {
+			t.Fatalf("error initiating v2 transport")
+		}
+
+		midInitiatorL, err := hex.DecodeString(test.midInitiatorL)
+		if err != nil {
+			t.Fatalf("unexpected error decoding midInitiatorL")
+		}
+
+		if !bytes.Equal(midInitiatorL, p.initiatorL) {
+			t.Fatalf("expected mid-state initiatorL to match computed value")
+		}
+
+		midInitiatorP, err := hex.DecodeString(test.midInitiatorP)
+		if err != nil {
+			t.Fatalf("unexpected error decoding midInitiatorP")
+		}
+
+		if !bytes.Equal(midInitiatorP, p.initiatorP) {
+			t.Fatalf("expected mid-state initiatorP to match computed value")
+		}
+
+		midResponderL, err := hex.DecodeString(test.midResponderL)
+		if err != nil {
+			t.Fatalf("unexpected error decoding midResponderL")
+		}
+
+		if !bytes.Equal(midResponderL, p.responderL) {
+			t.Fatalf("expected mid-state responderL to match computed value")
+		}
+
+		midResponderP, err := hex.DecodeString(test.midResponderP)
+		if err != nil {
+			t.Fatalf("unexpected error decoding midResponderP")
+		}
+
+		if !bytes.Equal(midResponderP, p.responderP) {
+			t.Fatalf("expected mid-state responderP to match computed value")
+		}
+
+		midSendGarbageTerm, err := hex.DecodeString(test.midSendGarbageTerm)
+		if err != nil {
+			t.Fatalf("unexpected error decoding midSendGarbageTerm")
+		}
+
+		if !bytes.Equal(midSendGarbageTerm, p.sendGarbageTerm[:]) {
+			t.Fatalf("expected mid-state sendGarbageTerm to match computed value")
+		}
+
+		midRecvGarbageTerm, err := hex.DecodeString(test.midRecvGarbageTerm)
+		if err != nil {
+			t.Fatalf("unexpected error decoding midRecvGarbageTerm")
+		}
+
+		if !bytes.Equal(midRecvGarbageTerm, p.recvGarbageTerm) {
+			t.Fatalf("expected mid-state recvGarbageTerm to match computed value")
+		}
+
+		outSessionID, err := hex.DecodeString(test.outSessionID)
+		if err != nil {
+			t.Fatalf("unexpected error decoding outSessionID")
+		}
+
+		if !bytes.Equal(outSessionID, p.sessionID) {
+			t.Fatalf("expected sessionID to match computed value")
+		}
+
+		for i := 0; i < test.inIdx; i++ {
+			_, _, err = p.V2EncPacket([]byte{}, []byte{}, false)
+			if err != nil {
+				t.Fatalf("unexpected error while encrypting packet")
+			}
+		}
+
+		initialContents, err := hex.DecodeString(test.inContents)
+		if err != nil {
+			t.Fatalf("unexpected error decoding contents")
+		}
+
+		aad, err := hex.DecodeString(test.inAad)
+		if err != nil {
+			t.Fatalf("unexpected error decoding aad")
+		}
+
+		var contents []byte
+
+		copy(contents, initialContents)
+
+		for i := 0; i < test.inMultiply; i++ {
+			contents = append(contents, initialContents...)
+		}
+
+		ciphertext, _, err := p.V2EncPacket(contents, aad, test.inIgnore)
+		if err != nil {
+			t.Fatalf("unexpected error when encrypting packet: %v", err)
+		}
+
+		if len(test.outCiphertext) != 0 {
+			outCiphertextBytes, err := hex.DecodeString(test.outCiphertext)
+			if err != nil {
+				t.Fatalf("unexpected error decoding outCiphertext: %v", err)
+			}
+
+			if !bytes.Equal(outCiphertextBytes, ciphertext) {
+				t.Fatalf("ciphertext mismatch")
+			}
+		}
+
+		if len(test.outCiphertextEndsWith) != 0 {
+			ciphertextHex := hex.EncodeToString(ciphertext)
+			if !strings.HasSuffix(ciphertextHex, test.outCiphertextEndsWith) {
+				t.Fatalf("suffix mismatch")
+			}
+		}
+	}
+}
diff --git a/wire/protocol_test.go b/wire/protocol_test.go
index 46498849..c795751e 100644
--- a/wire/protocol_test.go
+++ b/wire/protocol_test.go
@@ -26,7 +26,7 @@ func TestServiceFlagStringer(t *testing.T) {
 		{SFNodeCF, "SFNodeCF"},
 		{SFNode2X, "SFNode2X"},
 		{SFNodeNetworkLimited, "SFNodeNetworkLimited"},
-		{0xffffffff, "SFNodeNetwork|SFNodeGetUTXO|SFNodeBloom|SFNodeWitness|SFNodeXthin|SFNodeBit5|SFNodeCF|SFNode2X|SFNodeNetworkLimited|0xfffffb00"},
+		{0xffffffff, "SFNodeNetwork|SFNodeGetUTXO|SFNodeBloom|SFNodeWitness|SFNodeXthin|SFNodeBit5|SFNodeCF|SFNode2X|SFNodeNetworkLimited|SFNodeP2PV2|0xfffff300"},
 	}
 
 	t.Logf("Running %d tests", len(tests))