test: add comprehensive unit tests for U256, FieldP, ScalarN, ECPoint

Tests each layer of the secp256k1 implementation independently, not just
through the public Secp256k1 API. This catches bugs in the foundations
that could silently produce wrong results for specific input values.

U256Test (22 tests):
- isZero: zero, non-zero, high-bit cases
- cmp: equal, less-than, greater-than
- addTo/subTo: simple, carry/borrow, overflow/underflow
- mulWide: small values, large values, sqrWide consistency
- sqrWide: matches mulWide for arbitrary and max values
- fromBytes/toBytes: round-trip, zero encoding
- getNibble: low nibbles, high nibbles
- testBit: low bits, high bits
- xorTo: basic XOR

FieldPTest (22 tests):
- Identities: add zero, sub self, mul one, neg twice
- Reduction: add near p, overflow past p, underflow past 0
- Commutativity, distributivity of mul
- sqr matches mul(a,a)
- inv: a * a^(-1) = 1, inv(1) = 1, inv(p-1) = p-1
- half: even values, odd values, half-then-double round-trip
- sqrt: square root of perfect square, non-residue rejection, generator point
- reduceWide: (p-1)² = 1 mod p
- In-place operations: add, sqr into output arrays

ScalarNTest (18 tests):
- isValid: normal, zero, n, n-1
- Identities: add zero, sub self, add/sub round-trip, neg twice, add neg
- Commutativity, distributivity of mul
- inv: a * a^(-1) = 1
- Edge cases: (n-1)+1=0, (n-1)+2=1, (n-1)²=1
- neg(0)=0, reduce(n)=0, reduce(small)=unchanged

PointTest (22 tests):
- Generator on curve: y² = x³ + 7
- doublePoint: matches 2·G, in-place, infinity
- addPoints: G+G=2G, infinity identity, inverse→infinity
- addMixed: matches full addition, infinity input
- mulG: by 1, by 0 (infinity), by n (infinity), matches generic mul
- mulDoubleG: separate vs combined computation
- liftX: generator, invalid x
- Serialization: compress/decompress round-trip, uncompressed round-trip, invalid key rejection

Total: 122 tests (86 new + 36 existing BIP-340/ACINQ vectors)

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-05 17:05:38 +00:00
parent 368d1d16b7
commit e26ccb23cf
4 changed files with 983 additions and 0 deletions
@@ -0,0 +1,265 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.secp256k1
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Tests for field arithmetic modulo p. */
class FieldPTest {
private fun hex(s: String) =
U256.fromBytes(
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(),
)
private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) }
// ==================== Basic identities ====================
@Test
fun addZeroIdentity() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val zero = IntArray(8)
assertEquals(toHex(a), toHex(FieldP.add(a, zero)))
}
@Test
fun subSelfIsZero() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val result = FieldP.sub(a, a)
assertTrue(U256.isZero(result))
}
@Test
fun addThenSubRoundTrips() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
val sum = FieldP.add(a, b)
val back = FieldP.sub(sum, b)
assertEquals(toHex(a), toHex(back))
}
@Test
fun mulOneIdentity() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(a), toHex(FieldP.mul(a, one)))
}
// ==================== Reduction near p ====================
@Test
fun addNearP() {
// (p - 1) + 1 = p ≡ 0 (mod p)
val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e")
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
val result = FieldP.add(pMinus1, one)
assertTrue(U256.isZero(result))
}
@Test
fun addNearPOverflow() {
// (p - 1) + (p - 1) = 2p - 2 ≡ p - 2 (mod p)
val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e")
val result = FieldP.add(pMinus1, pMinus1)
val expected = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2d") // p-2
assertEquals(toHex(expected), toHex(result))
}
@Test
fun subUnderflow() {
// 0 - 1 ≡ p - 1 (mod p)
val zero = IntArray(8)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
val result = FieldP.sub(zero, one)
val expected = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") // p-1
assertEquals(toHex(expected), toHex(result))
}
// ==================== Negation ====================
@Test
fun negTwiceIsIdentity() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
assertEquals(toHex(a), toHex(FieldP.neg(FieldP.neg(a))))
}
@Test
fun negZeroIsZero() {
assertTrue(U256.isZero(FieldP.neg(IntArray(8))))
}
@Test
fun addNegIsZero() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val result = FieldP.add(a, FieldP.neg(a))
assertTrue(U256.isZero(result))
}
// ==================== Multiplication ====================
@Test
fun mulCommutative() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
assertEquals(toHex(FieldP.mul(a, b)), toHex(FieldP.mul(b, a)))
}
@Test
fun sqrMatchesMul() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
assertEquals(toHex(FieldP.mul(a, a)), toHex(FieldP.sqr(a)))
}
@Test
fun mulDistributive() {
// a * (b + c) = a*b + a*c
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
val c = hex("0000000000000000000000000000000000000000000000000000000000000007")
val lhs = FieldP.mul(a, FieldP.add(b, c))
val rhs = FieldP.add(FieldP.mul(a, b), FieldP.mul(a, c))
assertEquals(toHex(lhs), toHex(rhs))
}
// ==================== Inversion ====================
@Test
fun invMulIsOne() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val aInv = FieldP.inv(a)
val product = FieldP.mul(a, aInv)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(one), toHex(product))
}
@Test
fun invOfOne() {
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(one), toHex(FieldP.inv(one)))
}
@Test
fun invOfPMinus1() {
// (p-1)^(-1) = p-1 because (p-1)^2 = 1 mod p
val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e")
assertEquals(toHex(pMinus1), toHex(FieldP.inv(pMinus1)))
}
// ==================== Half ====================
@Test
fun halfOfEven() {
val out = IntArray(8)
val four = intArrayOf(4, 0, 0, 0, 0, 0, 0, 0)
FieldP.half(out, four)
assertEquals(2, out[0])
for (i in 1 until 8) assertEquals(0, out[i])
}
@Test
fun halfOfOdd() {
// half(1) = (1 + p) / 2 = (p + 1) / 2
val out = IntArray(8)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
FieldP.half(out, one)
// Verify: 2 * half(1) = 1 mod p
val doubled = FieldP.add(out, out)
assertEquals(1, doubled[0])
for (i in 1 until 8) assertEquals(0, doubled[i])
}
@Test
fun halfThenDoubleRoundTrips() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val out = IntArray(8)
FieldP.half(out, a)
val doubled = FieldP.add(out, out)
assertEquals(toHex(a), toHex(doubled))
}
// ==================== Square root ====================
@Test
fun sqrtOfSquare() {
// sqrt(a²) should return a or p-a
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val aSq = FieldP.sqr(a)
val root = FieldP.sqrt(aSq)!!
// root² should equal a²
assertEquals(toHex(aSq), toHex(FieldP.sqr(root)))
}
@Test
fun sqrtOfNonResidue() {
// 3 is not a quadratic residue mod p (for secp256k1's p)
val three = intArrayOf(3, 0, 0, 0, 0, 0, 0, 0)
assertNull(FieldP.sqrt(three))
}
@Test
fun sqrtOfSecp256k1Generator() {
// G.y² = G.x³ + 7. sqrt(G.x³ + 7) should give G.y or -G.y
val gx = ECPoint.GX
val gy = ECPoint.GY
val x3 = FieldP.mul(FieldP.sqr(gx), gx)
val y2 = FieldP.add(x3, intArrayOf(7, 0, 0, 0, 0, 0, 0, 0))
val root = FieldP.sqrt(y2)!!
// root should be gy or -gy
val isGy = U256.cmp(root, gy) == 0
val isNegGy = U256.cmp(root, FieldP.neg(gy)) == 0
assertTrue(isGy || isNegGy)
}
// ==================== Reduction edge cases ====================
@Test
fun reduceWideWithMaxValues() {
// Multiply two values near p and verify result is < p
val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e")
val result = FieldP.mul(pMinus1, pMinus1)
assertTrue(U256.cmp(result, FieldP.P) < 0, "Result should be < p")
// (p-1)² ≡ 1 (mod p)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(one), toHex(result))
}
// ==================== In-place operations ====================
@Test
fun inPlaceAdd() {
val a = hex("0000000000000000000000000000000000000000000000000000000000000005")
val b = hex("0000000000000000000000000000000000000000000000000000000000000003")
val out = IntArray(8)
FieldP.add(out, a, b)
assertEquals(8, out[0])
}
@Test
fun inPlaceSqr() {
val a = hex("0000000000000000000000000000000000000000000000000000000000000005")
val out = IntArray(8)
FieldP.sqr(out, a)
assertEquals(25, out[0]) // 5² = 25
}
}
@@ -0,0 +1,333 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.secp256k1
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Tests for elliptic curve point operations. */
class PointTest {
private fun hex(s: String) =
U256.fromBytes(
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(),
)
private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) }
// ==================== Generator point ====================
@Test
fun generatorIsOnCurve() {
// y² = x³ + 7
val x3 = FieldP.mul(FieldP.sqr(ECPoint.GX), ECPoint.GX)
val y2expected = FieldP.add(x3, intArrayOf(7, 0, 0, 0, 0, 0, 0, 0))
val y2actual = FieldP.sqr(ECPoint.GY)
assertEquals(toHex(y2expected), toHex(y2actual))
}
// ==================== Point doubling ====================
@Test
fun doubleGMatchesTwoG() {
// 2·G via doubling
val p = MutablePoint()
p.setAffine(ECPoint.GX, ECPoint.GY)
val doubled = MutablePoint()
ECPoint.doublePoint(doubled, p)
val dx = IntArray(8)
val dy = IntArray(8)
ECPoint.toAffine(doubled, dx, dy)
// 2·G via scalar multiplication
val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0)
val mulResult = MutablePoint()
ECPoint.mulG(mulResult, two)
val mx = IntArray(8)
val my = IntArray(8)
ECPoint.toAffine(mulResult, mx, my)
assertEquals(toHex(mx), toHex(dx))
assertEquals(toHex(my), toHex(dy))
}
@Test
fun doubleInPlace() {
// doublePoint(out, out) should work correctly
val p = MutablePoint()
p.setAffine(ECPoint.GX, ECPoint.GY)
ECPoint.doublePoint(p, p)
val x = IntArray(8)
val y = IntArray(8)
ECPoint.toAffine(p, x, y)
val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0)
val expected = MutablePoint()
ECPoint.mulG(expected, two)
val ex = IntArray(8)
val ey = IntArray(8)
ECPoint.toAffine(expected, ex, ey)
assertEquals(toHex(ex), toHex(x))
}
@Test
fun doubleInfinityIsInfinity() {
val inf = MutablePoint()
inf.setInfinity()
val result = MutablePoint()
ECPoint.doublePoint(result, inf)
assertTrue(result.isInfinity())
}
// ==================== Point addition ====================
@Test
fun addGPlusGEqualsDoubleG() {
val g = MutablePoint()
g.setAffine(ECPoint.GX, ECPoint.GY)
val sum = MutablePoint()
ECPoint.addPoints(sum, g, g)
val sx = IntArray(8)
val sy = IntArray(8)
ECPoint.toAffine(sum, sx, sy)
val doubled = MutablePoint()
ECPoint.doublePoint(doubled, g)
val dx = IntArray(8)
val dy = IntArray(8)
ECPoint.toAffine(doubled, dx, dy)
assertEquals(toHex(dx), toHex(sx))
assertEquals(toHex(dy), toHex(sy))
}
@Test
fun addInfinityIdentity() {
val g = MutablePoint()
g.setAffine(ECPoint.GX, ECPoint.GY)
val inf = MutablePoint()
inf.setInfinity()
val result = MutablePoint()
ECPoint.addPoints(result, g, inf)
val rx = IntArray(8)
val ry = IntArray(8)
ECPoint.toAffine(result, rx, ry)
assertEquals(toHex(ECPoint.GX), toHex(rx))
val result2 = MutablePoint()
ECPoint.addPoints(result2, inf, g)
val r2x = IntArray(8)
val r2y = IntArray(8)
ECPoint.toAffine(result2, r2x, r2y)
assertEquals(toHex(ECPoint.GX), toHex(r2x))
}
@Test
fun addInverseIsInfinity() {
// G + (-G) = infinity
val g = MutablePoint()
g.setAffine(ECPoint.GX, ECPoint.GY)
val negG = MutablePoint()
negG.setAffine(ECPoint.GX, FieldP.neg(ECPoint.GY))
val result = MutablePoint()
ECPoint.addPoints(result, g, negG)
assertTrue(result.isInfinity())
}
// ==================== Mixed addition ====================
@Test
fun addMixedMatchesFull() {
// addMixed should produce the same result as addPoints when q is affine
val three = intArrayOf(3, 0, 0, 0, 0, 0, 0, 0)
val p = MutablePoint()
ECPoint.mulG(p, three) // 3G in Jacobian (z ≠ 1)
// Add G as affine
val mixed = MutablePoint()
ECPoint.addMixed(mixed, p, ECPoint.GX, ECPoint.GY)
val mx = IntArray(8)
val my = IntArray(8)
ECPoint.toAffine(mixed, mx, my)
// Add G as Jacobian
val gJac = MutablePoint()
gJac.setAffine(ECPoint.GX, ECPoint.GY)
val full = MutablePoint()
ECPoint.addPoints(full, p, gJac)
val fx = IntArray(8)
val fy = IntArray(8)
ECPoint.toAffine(full, fx, fy)
assertEquals(toHex(fx), toHex(mx))
assertEquals(toHex(fy), toHex(my))
}
@Test
fun addMixedInfinityInput() {
val inf = MutablePoint()
inf.setInfinity()
val result = MutablePoint()
ECPoint.addMixed(result, inf, ECPoint.GX, ECPoint.GY)
val rx = IntArray(8)
val ry = IntArray(8)
ECPoint.toAffine(result, rx, ry)
assertEquals(toHex(ECPoint.GX), toHex(rx))
}
// ==================== Scalar multiplication ====================
@Test
fun mulGByOne() {
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
val result = MutablePoint()
ECPoint.mulG(result, one)
val rx = IntArray(8)
val ry = IntArray(8)
ECPoint.toAffine(result, rx, ry)
assertEquals(toHex(ECPoint.GX), toHex(rx))
assertEquals(toHex(ECPoint.GY), toHex(ry))
}
@Test
fun mulGByZeroIsInfinity() {
val zero = IntArray(8)
val result = MutablePoint()
ECPoint.mulG(result, zero)
assertTrue(result.isInfinity())
}
@Test
fun mulGByNIsInfinity() {
// n·G = infinity (the group order)
val result = MutablePoint()
ECPoint.mulG(result, ScalarN.N)
assertTrue(result.isInfinity())
}
@Test
fun mulGMatchesMul() {
// mulG(k) should equal mul(G, k)
val k = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val gResult = MutablePoint()
ECPoint.mulG(gResult, k)
val gx = IntArray(8)
val gy = IntArray(8)
ECPoint.toAffine(gResult, gx, gy)
val g = MutablePoint()
g.setAffine(ECPoint.GX, ECPoint.GY)
val mResult = MutablePoint()
ECPoint.mul(mResult, g, k)
val mx = IntArray(8)
val my = IntArray(8)
ECPoint.toAffine(mResult, mx, my)
assertEquals(toHex(mx), toHex(gx))
assertEquals(toHex(my), toHex(gy))
}
@Test
fun mulDoubleGSeparateVsCombined() {
// mulDoubleG(s, P, e) should equal mulG(s) + mul(P, e)
val s = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val e = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
val p = MutablePoint()
val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0)
ECPoint.mulG(p, two) // P = 2·G
// Combined
val combined = MutablePoint()
ECPoint.mulDoubleG(combined, s, p, e)
val cx = IntArray(8)
val cy = IntArray(8)
ECPoint.toAffine(combined, cx, cy)
// Separate
val sG = MutablePoint()
ECPoint.mulG(sG, s)
val eP = MutablePoint()
ECPoint.mul(eP, p, e)
val sep = MutablePoint()
ECPoint.addPoints(sep, sG, eP)
val sx = IntArray(8)
val sy = IntArray(8)
ECPoint.toAffine(sep, sx, sy)
assertEquals(toHex(sx), toHex(cx))
assertEquals(toHex(sy), toHex(cy))
}
// ==================== liftX ====================
@Test
fun liftXGenerator() {
val x = IntArray(8)
val y = IntArray(8)
assertTrue(ECPoint.liftX(x, y, ECPoint.GX))
assertEquals(toHex(ECPoint.GX), toHex(x))
// liftX returns even y
assertTrue(ECPoint.hasEvenY(y))
}
@Test
fun liftXInvalidX() {
// p itself is not a valid x coordinate
val x = IntArray(8)
val y = IntArray(8)
assertFalse(ECPoint.liftX(x, y, FieldP.P))
}
// ==================== Serialization round-trips ====================
@Test
fun compressDecompressRoundTrip() {
val compressed = ECPoint.serializeCompressed(ECPoint.GX, ECPoint.GY)
val x = IntArray(8)
val y = IntArray(8)
assertTrue(ECPoint.parsePublicKey(compressed, x, y))
assertEquals(toHex(ECPoint.GX), toHex(x))
assertEquals(toHex(ECPoint.GY), toHex(y))
}
@Test
fun uncompressedRoundTrip() {
val uncompressed = ECPoint.serializeUncompressed(ECPoint.GX, ECPoint.GY)
val x = IntArray(8)
val y = IntArray(8)
assertTrue(ECPoint.parsePublicKey(uncompressed, x, y))
assertEquals(toHex(ECPoint.GX), toHex(x))
assertEquals(toHex(ECPoint.GY), toHex(y))
}
@Test
fun parseInvalidKey() {
val x = IntArray(8)
val y = IntArray(8)
assertFalse(ECPoint.parsePublicKey(ByteArray(10), x, y))
assertFalse(ECPoint.parsePublicKey(ByteArray(33), x, y)) // wrong prefix (0x00)
}
}
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.secp256k1
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Tests for scalar arithmetic modulo n. */
class ScalarNTest {
private fun hex(s: String) =
U256.fromBytes(
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(),
)
private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) }
// ==================== isValid ====================
@Test
fun isValidNormal() {
assertTrue(ScalarN.isValid(hex("0000000000000000000000000000000000000000000000000000000000000001")))
}
@Test
fun isValidZero() {
assertFalse(ScalarN.isValid(IntArray(8)))
}
@Test
fun isValidN() {
// n itself is not valid (must be < n)
assertFalse(ScalarN.isValid(ScalarN.N))
}
@Test
fun isValidNMinus1() {
val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140")
assertTrue(ScalarN.isValid(nMinus1))
}
// ==================== Arithmetic identities ====================
@Test
fun addZeroIdentity() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
assertEquals(toHex(a), toHex(ScalarN.add(a, IntArray(8))))
}
@Test
fun subSelfIsZero() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
assertTrue(U256.isZero(ScalarN.sub(a, a)))
}
@Test
fun addThenSubRoundTrips() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
assertEquals(toHex(a), toHex(ScalarN.sub(ScalarN.add(a, b), b)))
}
@Test
fun negTwiceIsIdentity() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
assertEquals(toHex(a), toHex(ScalarN.neg(ScalarN.neg(a))))
}
@Test
fun addNegIsZero() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
assertTrue(U256.isZero(ScalarN.add(a, ScalarN.neg(a))))
}
// ==================== Multiplication ====================
@Test
fun mulOneIdentity() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(a), toHex(ScalarN.mul(a, one)))
}
@Test
fun mulCommutative() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
assertEquals(toHex(ScalarN.mul(a, b)), toHex(ScalarN.mul(b, a)))
}
@Test
fun mulDistributive() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3")
val c = hex("0000000000000000000000000000000000000000000000000000000000000007")
val lhs = ScalarN.mul(a, ScalarN.add(b, c))
val rhs = ScalarN.add(ScalarN.mul(a, b), ScalarN.mul(a, c))
assertEquals(toHex(lhs), toHex(rhs))
}
// ==================== Inversion ====================
@Test
fun invMulIsOne() {
val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530")
val aInv = ScalarN.inv(a)
val product = ScalarN.mul(a, aInv)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(one), toHex(product))
}
// ==================== Reduction near n ====================
@Test
fun addNearN() {
// (n-1) + 1 should wrap to 0
val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140")
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertTrue(U256.isZero(ScalarN.add(nMinus1, one)))
}
@Test
fun addNearNWrap() {
// (n-1) + 2 should give 1
val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140")
val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0)
val result = ScalarN.add(nMinus1, two)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(one), toHex(result))
}
@Test
fun mulLargeScalars() {
// (n-1) * (n-1) ≡ 1 mod n (since (n-1) ≡ -1 and (-1)² = 1)
val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140")
val result = ScalarN.mul(nMinus1, nMinus1)
val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
assertEquals(toHex(one), toHex(result))
}
@Test
fun negOfZeroIsZero() {
assertTrue(U256.isZero(ScalarN.neg(IntArray(8))))
}
@Test
fun reduceOfN() {
val result = ScalarN.reduce(ScalarN.N.copyOf())
assertTrue(U256.isZero(result))
}
@Test
fun reduceOfSmall() {
val a = hex("0000000000000000000000000000000000000000000000000000000000000005")
val result = ScalarN.reduce(a)
assertEquals(toHex(a), toHex(result)) // No change — already < n
}
}
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.secp256k1
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Tests for 256-bit unsigned integer arithmetic. */
class U256Test {
private fun hex(s: String) =
U256.fromBytes(
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(),
)
private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) }
// ==================== isZero / cmp ====================
@Test
fun isZeroTrue() = assertTrue(U256.isZero(IntArray(8)))
@Test
fun isZeroFalse() = assertFalse(U256.isZero(intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)))
@Test
fun isZeroHighBit() = assertFalse(U256.isZero(intArrayOf(0, 0, 0, 0, 0, 0, 0, 1)))
@Test
fun cmpEqual() = assertEquals(0, U256.cmp(hex("0000000000000000000000000000000000000000000000000000000000000001"), hex("0000000000000000000000000000000000000000000000000000000000000001")))
@Test
fun cmpLessThan() = assertEquals(-1, U256.cmp(hex("0000000000000000000000000000000000000000000000000000000000000001"), hex("0000000000000000000000000000000000000000000000000000000000000002")))
@Test
fun cmpGreaterThan() = assertEquals(1, U256.cmp(hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), hex("0000000000000000000000000000000000000000000000000000000000000001")))
// ==================== add / sub ====================
@Test
fun addSimple() {
val out = IntArray(8)
val carry = U256.addTo(out, hex("0000000000000000000000000000000000000000000000000000000000000001"), hex("0000000000000000000000000000000000000000000000000000000000000002"))
assertEquals("0000000000000000000000000000000000000000000000000000000000000003", toHex(out))
assertEquals(0, carry)
}
@Test
fun addOverflow() {
val out = IntArray(8)
val carry = U256.addTo(out, hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), hex("0000000000000000000000000000000000000000000000000000000000000001"))
assertEquals("0000000000000000000000000000000000000000000000000000000000000000", toHex(out))
assertEquals(1, carry)
}
@Test
fun addLimbCarry() {
val out = IntArray(8)
U256.addTo(out, hex("00000000000000000000000000000000000000000000000000000000ffffffff"), hex("0000000000000000000000000000000000000000000000000000000000000001"))
assertEquals("0000000000000000000000000000000000000000000000000000000100000000", toHex(out))
}
@Test
fun subSimple() {
val out = IntArray(8)
val borrow = U256.subTo(out, hex("0000000000000000000000000000000000000000000000000000000000000003"), hex("0000000000000000000000000000000000000000000000000000000000000001"))
assertEquals("0000000000000000000000000000000000000000000000000000000000000002", toHex(out))
assertEquals(0, borrow)
}
@Test
fun subUnderflow() {
val out = IntArray(8)
val borrow = U256.subTo(out, hex("0000000000000000000000000000000000000000000000000000000000000000"), hex("0000000000000000000000000000000000000000000000000000000000000001"))
assertEquals("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", toHex(out))
assertEquals(1, borrow)
}
// ==================== mulWide / sqrWide ====================
@Test
fun mulWideSmall() {
val out = IntArray(16)
U256.mulWide(out, hex("0000000000000000000000000000000000000000000000000000000000000003"), hex("0000000000000000000000000000000000000000000000000000000000000007"))
// 3 * 7 = 21 = 0x15
assertEquals(0x15, out[0])
for (i in 1 until 16) assertEquals(0, out[i])
}
@Test
fun mulWideLarge() {
// (2^128 - 1)² consistency: mulWide and sqrWide should match
val out1 = IntArray(16)
val out2 = IntArray(16)
val maxHalf = hex("00000000000000000000000000000000ffffffffffffffffffffffffffffffff")
U256.mulWide(out1, maxHalf, maxHalf)
U256.sqrWide(out2, maxHalf)
for (i in 0 until 16) assertEquals(out1[i], out2[i], "Limb $i mismatch")
// Lowest limb is 1 (from +1 in (2^128-1)² = 2^256 - 2^129 + 1)
assertEquals(1, out1[0])
}
@Test
fun sqrWideMatchesMulWide() {
// sqrWide(a) should produce the same result as mulWide(a, a)
val a = hex("67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530")
val mulResult = IntArray(16)
U256.mulWide(mulResult, a, a)
val sqrResult = IntArray(16)
U256.sqrWide(sqrResult, a)
for (i in 0 until 16) {
assertEquals(mulResult[i], sqrResult[i], "Limb $i mismatch")
}
}
@Test
fun sqrWideMaxValue() {
// (2^256 - 1)^2 should match mulWide
val maxVal = hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
val mulResult = IntArray(16)
U256.mulWide(mulResult, maxVal, maxVal)
val sqrResult = IntArray(16)
U256.sqrWide(sqrResult, maxVal)
for (i in 0 until 16) {
assertEquals(mulResult[i], sqrResult[i], "Limb $i mismatch for max value sqr")
}
}
// ==================== fromBytes / toBytes ====================
@Test
fun bytesRoundTrip() {
val hex = "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530"
val bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val limbs = U256.fromBytes(bytes)
val back = U256.toBytes(limbs)
assertEquals(hex.lowercase(), back.joinToString("") { "%02x".format(it) })
}
@Test
fun bytesZero() {
val limbs = U256.fromBytes(ByteArray(32))
assertTrue(U256.isZero(limbs))
}
// ==================== getNibble ====================
@Test
fun getNibbleValues() {
// 0xAB at the lowest byte = limb[0] = 0x...AB
val a = hex("00000000000000000000000000000000000000000000000000000000000000ab")
assertEquals(0xB, U256.getNibble(a, 0)) // lowest nibble
assertEquals(0xA, U256.getNibble(a, 1)) // second nibble
assertEquals(0, U256.getNibble(a, 2)) // third nibble
}
@Test
fun getNibbleHighBits() {
val a = hex("f000000000000000000000000000000000000000000000000000000000000000")
assertEquals(0xF, U256.getNibble(a, 63)) // highest nibble
assertEquals(0, U256.getNibble(a, 62))
}
// ==================== testBit ====================
@Test
fun testBitLow() {
val a = hex("0000000000000000000000000000000000000000000000000000000000000005") // bits 0 and 2
assertTrue(U256.testBit(a, 0))
assertFalse(U256.testBit(a, 1))
assertTrue(U256.testBit(a, 2))
}
@Test
fun testBitHigh() {
val a = hex("8000000000000000000000000000000000000000000000000000000000000000") // bit 255
assertTrue(U256.testBit(a, 255))
assertFalse(U256.testBit(a, 254))
}
// ==================== xor ====================
@Test
fun xorBasic() {
val out = IntArray(8)
U256.xorTo(out, hex("ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00"), hex("0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"))
assertEquals("f00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00f", toHex(out))
}
}