From 772b4ea8ed30486e3eea03f14873940d37aeb629 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 16:14:50 +0200 Subject: [PATCH] fix(quartz): repair equals/hashCode contracts in OTS ops and VerifyResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Op instances key Timestamp.ops (MutableMap), so contract violations corrupt hash-map behavior: - OpKECCAK256 defined equals without hashCode, so equal instances hashed by identity — two equal keys could land in different buckets, producing duplicate branches or failed lookups in keccak256 timestamp trees. Add hashCode = TAG, mirroring OpSHA1/OpSHA256/OpRIPEMD160. - OpBinary defined hashCode without equals — and its TAG referenced Op.TAG (0x00), a no-op XOR. Define the equals/hashCode pair once on OpBinary using tag() and drop the duplicated overrides from OpAppend/OpPrepend (behavior unchanged: same tag + same arg content). - VerifyResult.equals cast without a type test (ClassCastException on foreign types instead of false) and hashCode force-cast the nullable timestamp (NPE for null-timestamp results). Convert to a data class; the custom toString and compareTo stay. --- .../quartz/nip03Timestamp/ots/VerifyResult.kt | 9 +- .../quartz/nip03Timestamp/ots/op/OpAppend.kt | 10 -- .../quartz/nip03Timestamp/ots/op/OpBinary.kt | 4 +- .../nip03Timestamp/ots/op/OpKECCAK256.kt | 2 + .../quartz/nip03Timestamp/ots/op/OpPrepend.kt | 10 -- .../ots/OtsEqualsContractTest.kt | 110 ++++++++++++++++++ 6 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt index 8827e0aa17..db8aceb159 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip03Timestamp.ots /** * Class that lets us compare, sort, store and print timestamps. */ -class VerifyResult( +data class VerifyResult( val timestamp: Long?, val height: Int, ) : Comparable { @@ -41,11 +41,4 @@ class VerifyResult( } override fun compareTo(other: VerifyResult): Int = this.height - other.height - - override fun equals(other: Any?): Boolean { - val vr = other as VerifyResult - return this.timestamp == vr.timestamp && this.height == vr.height - } - - override fun hashCode(): Int = (((this.timestamp) as Long).toInt()) xor this.height } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt index e725b19877..9c67e26eae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt @@ -37,16 +37,6 @@ class OpAppend( public override fun call(msg: ByteArray): ByteArray = msg + this.arg - override fun equals(other: Any?): Boolean { - if (other !is OpAppend) { - return false - } - - return this.arg.contentEquals(other.arg) - } - - override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() - companion object { val TAG: Byte = 0xf0.toByte() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt index 40551ac87c..4f7be1ad51 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt @@ -53,7 +53,9 @@ abstract class OpBinary( return this.tag() - other.tag() } - override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() + override fun equals(other: Any?): Boolean = other is OpBinary && this.tag() == other.tag() && this.arg.contentEquals(other.arg) + + override fun hashCode(): Int = this.tag().toInt() xor this.arg.contentHashCode() companion object { @Throws(DeserializationException::class) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt index 79598322c3..51cf4d94e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt @@ -51,6 +51,8 @@ class OpKECCAK256 : OpCrypto() { override fun equals(other: Any?): Boolean = (other is OpKECCAK256) + override fun hashCode(): Int = TAG.toInt() + companion object { val TAG: Byte = 103.toByte() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt index a32189e1f3..bcd4f85a86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt @@ -37,16 +37,6 @@ class OpPrepend( public override fun call(msg: ByteArray): ByteArray = this.arg + msg - override fun equals(other: Any?): Boolean { - if (other !is OpPrepend) { - return false - } - - return this.arg.contentEquals(other.arg) - } - - public override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() - companion object { val TAG: Byte = 0xf1.toByte() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt new file mode 100644 index 0000000000..2f7a0b8b79 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt @@ -0,0 +1,110 @@ +/* + * 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.nip03Timestamp.ots + +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.Op +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpAppend +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpKECCAK256 +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpPrepend +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Equals/hashCode contract tests for the types keying [Timestamp.ops] + * (a MutableMap): equal ops MUST hash equally or map + * lookups produce duplicate branches and failed upgrades. + */ +class OtsEqualsContractTest { + // --- OpKECCAK256: equals existed without hashCode; equal instances hashed by identity --- + + @Test + fun keccakInstancesAreEqualAndHashEqually() { + val a = OpKECCAK256() + val b = OpKECCAK256() + + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun keccakWorksAsMapKey() { + val map = mutableMapOf(OpKECCAK256() to "branch") + + assertEquals("branch", map[OpKECCAK256()]) + } + + // --- OpBinary: equals is defined once on the superclass, keyed on tag() + arg content --- + + @Test + fun binaryOpsWithSameArgAndClassAreEqual() { + val arg = byteArrayOf(1, 2, 3) + + assertEquals(OpAppend(arg), OpAppend(byteArrayOf(1, 2, 3))) + assertEquals(OpAppend(arg).hashCode(), OpAppend(byteArrayOf(1, 2, 3)).hashCode()) + } + + @Test + fun binaryOpsWithDifferentArgAreNotEqual() { + assertNotEquals(OpAppend(byteArrayOf(1, 2, 3)), OpAppend(byteArrayOf(9))) + } + + @Test + fun appendAndPrependWithSameArgAreNotEqual() { + // Same arg content, different tag — the superclass equals must not conflate them. + val arg = byteArrayOf(1, 2, 3) + + assertNotEquals(OpAppend(arg), OpPrepend(arg)) + } + + @Test + fun binaryOpsWorkAsMapKeys() { + val map = mutableMapOf() + map[OpAppend(byteArrayOf(1, 2, 3))] = "append" + map[OpPrepend(byteArrayOf(1, 2, 3))] = "prepend" + + assertEquals(2, map.size) + assertEquals("append", map[OpAppend(byteArrayOf(1, 2, 3))]) + assertEquals("prepend", map[OpPrepend(byteArrayOf(1, 2, 3))]) + } + + // --- VerifyResult: equals used to throw on null/foreign types; hashCode NPEd on null timestamp --- + + @Test + fun verifyResultEqualsNullIsFalse() { + assertFalse(VerifyResult(1234L, 100).equals(null)) + } + + @Test + fun verifyResultEqualsForeignTypeIsFalse() { + assertFalse(VerifyResult(1234L, 100).equals("not a VerifyResult")) + } + + @Test + fun verifyResultWithNullTimestampHashesWithoutThrowing() { + val result = VerifyResult(null, 100) + + result.hashCode() + assertTrue(result == VerifyResult(null, 100)) + } +}