From 89bc60d84e0b1a4cb9800dee41e8bdb592be61e8 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Thu, 26 Mar 2026 07:14:42 +0000 Subject: [PATCH 01/17] [ci] Add DB versions of Splice.Amulet.CryptoHash Signed-off-by: Tim Emiola --- .../V065__app_reward_hash_functions.sql | 67 ++++++ .../store/DbCryptoHashFunctionsTest.scala | 208 ++++++++++++++++++ test-full-class-names-non-integration.log | 1 + 3 files changed, 276 insertions(+) create mode 100644 apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql create mode 100644 apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala diff --git a/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql new file mode 100644 index 0000000000..4f23e8b681 --- /dev/null +++ b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql @@ -0,0 +1,67 @@ +-- V065: plpgsql functions for Merkle tree hash computation over app reward batches. +-- These replicate the Daml CryptoHash module (Splice.Amulet.CryptoHash) from PR #3964: +-- text-based SHA-256 hashing with pipe-delimited encoding. +-- All hash functions return 64-char hex strings (text), matching Daml's Hash type. +-- Callers that store results in bytea columns (batch_hash, root_hash) must +-- convert with decode(result, 'hex'). + +-- ============================================================================ +-- Primitive hash functions matching Daml CryptoHash encoding +-- ============================================================================ + +-- Hash a text scalar: sha256(value) → 64-char hex string. +-- Matches: instance Hashable Text where hash = hashText . id +CREATE FUNCTION crypto_hash_text(s text) RETURNS text + AS $$ SELECT encode(extensions.digest(s, 'sha256'), 'hex') $$ + LANGUAGE sql IMMUTABLE; + +-- Hash a list of already-hashed elements. +-- Matches: hashListInternal ts = sha256(intercalate "|" (show(length ts) :: ts)) +CREATE FUNCTION crypto_hash_list(elems text[]) RETURNS text + AS $$ + SELECT encode(extensions.digest( + array_to_string( + ARRAY[COALESCE(array_length(elems, 1), 0)::text] || COALESCE(elems, ARRAY[]::text[]), + '|' + ), + 'sha256' + ), 'hex') + $$ LANGUAGE sql IMMUTABLE; + +-- Hash a variant with a tag and field hashes. +-- Matches: hashVariant tag fields = sha256(intercalate "|" (tag :: show(length fields) :: map (.value) fields)) +CREATE FUNCTION crypto_hash_variant(tag text, fields text[]) RETURNS text + AS $$ + SELECT encode(extensions.digest( + array_to_string( + ARRAY[tag, COALESCE(array_length(fields, 1), 0)::text] || COALESCE(fields, ARRAY[]::text[]), + '|' + ), + 'sha256' + ), 'hex') + $$ LANGUAGE sql IMMUTABLE; + + +-- ============================================================================ +-- Domain-specific hash functions for reward accounting +-- ============================================================================ + +-- Hash a single MintingAllowance record. +-- Matches: hash MintingAllowance{provider, amount} = hashRecord [hash provider, hash amount] +CREATE FUNCTION hash_minting_allowance(provider text, amount text) RETURNS text + AS $$ SELECT crypto_hash_list(ARRAY[crypto_hash_text(provider), crypto_hash_text(amount)]) $$ + LANGUAGE sql IMMUTABLE; + +-- Hash a BatchOfMintingAllowances variant. +-- Matches: hash (BatchOfMintingAllowances allowances) = hashVariant "BatchOfMintingAllowances" [hash allowances] +-- where hash allowances = hashList (map hash allowances) +CREATE FUNCTION hash_batch_of_minting_allowances(allowance_hashes text[]) RETURNS text + AS $$ SELECT crypto_hash_variant('BatchOfMintingAllowances', ARRAY[crypto_hash_list(allowance_hashes)]) $$ + LANGUAGE sql IMMUTABLE; + +-- Hash a BatchOfBatches variant. +-- Matches: hash (BatchOfBatches batchHashes) = hashVariant "BatchOfBatches" [hash batchHashes] +-- where hash batchHashes = hashList (map hash batchHashes) (identity on Hash) +CREATE FUNCTION hash_batch_of_batches(child_hashes text[]) RETURNS text + AS $$ SELECT crypto_hash_variant('BatchOfBatches', ARRAY[crypto_hash_list(child_hashes)]) $$ + LANGUAGE sql IMMUTABLE; diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala new file mode 100644 index 0000000000..fcd4458a09 --- /dev/null +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala @@ -0,0 +1,208 @@ +package org.lfdecentralizedtrust.splice.scan.store + +import com.digitalasset.canton.HasExecutionContext +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import org.lfdecentralizedtrust.splice.store.StoreTestBase +import org.lfdecentralizedtrust.splice.store.db.SplicePostgresTest +import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton + +import java.security.MessageDigest +import scala.concurrent.Future + +/** Tests that the plpgsql hash functions produce output identical to + * the Daml CryptoHash module (Splice.Amulet.CryptoHash). + */ +class DbCryptoHashFunctionsTest + extends StoreTestBase + with HasExecutionContext + with SplicePostgresTest { + + import DbCryptoHashFunctionsTest.DamlCryptoHash + + private case class HashTestCase( + description: String, + sqlExpr: String, + expected: String, + ) + + // -- Primitive function tests ----------------------------------------------- + + private val primitiveTestCases = { + val hAlice = DamlCryptoHash.hashText("alice::provider") + val h10 = DamlCryptoHash.hashText("10.0") + val hOnly = DamlCryptoHash.hashText("only") + + Seq( + HashTestCase( + "crypto_hash_text('hello')", + "crypto_hash_text('hello')", + DamlCryptoHash.hashText("hello"), + ), + HashTestCase( + "crypto_hash_text on empty string", + "crypto_hash_text('')", + DamlCryptoHash.hashText(""), + ), + HashTestCase( + "crypto_hash_list with two elements", + s"""crypto_hash_list(ARRAY[ + crypto_hash_text('alice::provider'), + crypto_hash_text('10.0') + ]::text[])""", + DamlCryptoHash.hashList(Seq(hAlice, h10)), + ), + HashTestCase( + "crypto_hash_list on empty array", + "crypto_hash_list(ARRAY[]::text[])", + DamlCryptoHash.hashList(Seq.empty), + ), + HashTestCase( + "crypto_hash_list on single element", + "crypto_hash_list(ARRAY[crypto_hash_text('only')]::text[])", + DamlCryptoHash.hashList(Seq(hOnly)), + ), + HashTestCase( + "crypto_hash_variant with tag and one field", + s"""crypto_hash_variant('TestTag', ARRAY[ + crypto_hash_text('alice::provider') + ]::text[])""", + DamlCryptoHash.hashVariant("TestTag", Seq(hAlice)), + ), + ) + } + + // -- Golden values from Daml CryptoHash unit tests -------------------------- + // Source: daml/splice-amulet-test/daml/Splice/Scripts/UnitTests/Amulet/CryptoHash.daml + // These are hardcoded expected hashes from the Daml test suite, not computed + // by our Scala oracle. They anchor the hash scheme to the canonical Daml output. + + private val goldenTestCases = { + // Daml: RecV1{a=1, b="x"} => hashRecord [hash 1, hash "x"] + // hash Int = hashText . show, hash Text = hashText + val h1 = DamlCryptoHash.hashText("1") + val hx = DamlCryptoHash.hashText("x") + + Seq( + HashTestCase( + "golden: hashRecord [hash 1, hash 'x'] (Daml RecV1)", + s"crypto_hash_list(ARRAY['$h1', '$hx']::text[])", + "e2878cf11d8a10aa17f359e1f61f711756fdbbc256bf541baec14c21b6888f6e", + ), + HashTestCase( + "golden: hashVariant 'V1' [hash 1, hash 'x'] (Daml VarV1.V1)", + s"crypto_hash_variant('V1', ARRAY['$h1', '$hx']::text[])", + "ca314e0bdf0fc327940a89334ff6df58f234b395d36328af1a0cce2339227e5c", + ), + ) + } + + // -- Domain-specific function tests ----------------------------------------- + + private val domainTestCases = { + val maAlice = DamlCryptoHash.hashMintingAllowance("alice::provider", "10.0000000000") + val maBob = DamlCryptoHash.hashMintingAllowance("bob::provider", "0") + + val leaf1 = DamlCryptoHash.hashBatchOfMintingAllowances( + Seq(DamlCryptoHash.hashMintingAllowance("alice::provider", "5.0")) + ) + val leaf2 = DamlCryptoHash.hashBatchOfMintingAllowances( + Seq(DamlCryptoHash.hashMintingAllowance("bob::provider", "3.0")) + ) + + Seq( + HashTestCase( + "hash_minting_allowance(alice, 10.0)", + "hash_minting_allowance('alice::provider', '10.0000000000')", + maAlice, + ), + HashTestCase( + "hash_minting_allowance(bob, 0)", + "hash_minting_allowance('bob::provider', '0')", + maBob, + ), + HashTestCase( + "hash_batch_of_minting_allowances with two allowances", + s"""hash_batch_of_minting_allowances(ARRAY[ + hash_minting_allowance('alice::provider', '10.0000000000'), + hash_minting_allowance('bob::provider', '0') + ]::text[])""", + DamlCryptoHash.hashBatchOfMintingAllowances(Seq(maAlice, maBob)), + ), + HashTestCase( + "hash_batch_of_batches with two leaf batches", + s"""hash_batch_of_batches(ARRAY[ + hash_batch_of_minting_allowances(ARRAY[ + hash_minting_allowance('alice::provider', '5.0') + ]::text[]), + hash_batch_of_minting_allowances(ARRAY[ + hash_minting_allowance('bob::provider', '3.0') + ]::text[]) + ]::text[])""", + DamlCryptoHash.hashBatchOfBatches(Seq(leaf1, leaf2)), + ), + ) + } + + "plpgsql crypto hash functions" should { + (primitiveTestCases ++ goldenTestCases ++ domainTestCases).foreach { tc => + tc.description in { + for { + result <- runSqlText( + sql"""select #${tc.sqlExpr}""".as[String].head + ) + } yield { + result shouldBe tc.expected + } + } + } + } + + private def runSqlText( + action: slick.dbio.DBIOAction[String, slick.dbio.NoStream, slick.dbio.Effect.Read] + ): Future[String] = + futureUnlessShutdownToFuture( + storage.underlying.query(action, "test.runSqlText") + ) + + override protected def cleanDb( + storage: DbStorage + )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = + resetAllAppTables(storage) +} + +object DbCryptoHashFunctionsTest { + + /** Scala reimplementation of Daml's CryptoHash module (Splice.Amulet.CryptoHash). + * Used as the test oracle to verify plpgsql hash functions produce identical output. + */ + private[store] object DamlCryptoHash { + def hashText(s: String): String = sha256Hex(s) + + def hashList(elems: Seq[String]): String = { + val parts = elems.size.toString +: elems + sha256Hex(parts.mkString("|")) + } + + def hashVariant(tag: String, fieldHashes: Seq[String]): String = { + val parts = tag +: fieldHashes.size.toString +: fieldHashes + sha256Hex(parts.mkString("|")) + } + + def hashMintingAllowance(provider: String, amount: String): String = + hashList(Seq(hashText(provider), hashText(amount))) + + def hashBatchOfMintingAllowances(allowanceHashes: Seq[String]): String = + hashVariant("BatchOfMintingAllowances", Seq(hashList(allowanceHashes))) + + def hashBatchOfBatches(childHashes: Seq[String]): String = + hashVariant("BatchOfBatches", Seq(hashList(childHashes))) + + private def sha256Hex(s: String): String = { + val digest = MessageDigest.getInstance("SHA-256") + digest.digest(s.getBytes("UTF-8")).map("%02x".format(_)).mkString + } + } +} diff --git a/test-full-class-names-non-integration.log b/test-full-class-names-non-integration.log index 42ac6fa1e8..17d4f15ae2 100644 --- a/test-full-class-names-non-integration.log +++ b/test-full-class-names-non-integration.log @@ -15,6 +15,7 @@ org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTriggerTest org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfigTest org.lfdecentralizedtrust.splice.scan.rewards.RewardComputationInputsTest org.lfdecentralizedtrust.splice.scan.store.DbAppActivityRecordStoreTest +org.lfdecentralizedtrust.splice.scan.store.DbCryptoHashFunctionsTest org.lfdecentralizedtrust.splice.scan.store.DbScanAppRewardsStoreTest org.lfdecentralizedtrust.splice.scan.store.ScanEventStoreTest org.lfdecentralizedtrust.splice.scan.store.ScanKeyValueProviderTest From fc90de16690e49321d7ba928685cfc59dbfaa9ea Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Mon, 30 Mar 2026 03:29:43 +0000 Subject: [PATCH 02/17] [ci] Improve ppsql functions Signed-off-by: Tim Emiola --- .../V065__app_reward_hash_functions.sql | 37 +++++++++++-------- .../store/DbCryptoHashFunctionsTest.scala | 34 ++++++++--------- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql index 4f23e8b681..fda3f388c9 100644 --- a/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql +++ b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql @@ -1,6 +1,5 @@ -- V065: plpgsql functions for Merkle tree hash computation over app reward batches. --- These replicate the Daml CryptoHash module (Splice.Amulet.CryptoHash) from PR #3964: --- text-based SHA-256 hashing with pipe-delimited encoding. +-- These replicate the Daml CryptoHash module (Splice.Amulet.CryptoHash) -- All hash functions return 64-char hex strings (text), matching Daml's Hash type. -- Callers that store results in bytea columns (batch_hash, root_hash) must -- convert with decode(result, 'hex'). @@ -11,35 +10,38 @@ -- Hash a text scalar: sha256(value) → 64-char hex string. -- Matches: instance Hashable Text where hash = hashText . id -CREATE FUNCTION crypto_hash_text(s text) RETURNS text +CREATE FUNCTION daml_crypto_hash_text(s text) RETURNS text + RETURNS NULL ON NULL INPUT AS $$ SELECT encode(extensions.digest(s, 'sha256'), 'hex') $$ - LANGUAGE sql IMMUTABLE; + LANGUAGE sql IMMUTABLE PARALLEL SAFE; -- Hash a list of already-hashed elements. -- Matches: hashListInternal ts = sha256(intercalate "|" (show(length ts) :: ts)) -CREATE FUNCTION crypto_hash_list(elems text[]) RETURNS text +CREATE FUNCTION daml_crypto_hash_list(elems text[]) RETURNS text + RETURNS NULL ON NULL INPUT AS $$ SELECT encode(extensions.digest( array_to_string( - ARRAY[COALESCE(array_length(elems, 1), 0)::text] || COALESCE(elems, ARRAY[]::text[]), + ARRAY[cardinality(elems)::text] || elems, '|' ), 'sha256' ), 'hex') - $$ LANGUAGE sql IMMUTABLE; + $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; -- Hash a variant with a tag and field hashes. -- Matches: hashVariant tag fields = sha256(intercalate "|" (tag :: show(length fields) :: map (.value) fields)) -CREATE FUNCTION crypto_hash_variant(tag text, fields text[]) RETURNS text +CREATE FUNCTION daml_crypto_hash_variant(tag text, fields text[]) RETURNS text + RETURNS NULL ON NULL INPUT AS $$ SELECT encode(extensions.digest( array_to_string( - ARRAY[tag, COALESCE(array_length(fields, 1), 0)::text] || COALESCE(fields, ARRAY[]::text[]), + ARRAY[tag, cardinality(fields)::text] || fields, '|' ), 'sha256' ), 'hex') - $$ LANGUAGE sql IMMUTABLE; + $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; -- ============================================================================ @@ -49,19 +51,22 @@ CREATE FUNCTION crypto_hash_variant(tag text, fields text[]) RETURNS text -- Hash a single MintingAllowance record. -- Matches: hash MintingAllowance{provider, amount} = hashRecord [hash provider, hash amount] CREATE FUNCTION hash_minting_allowance(provider text, amount text) RETURNS text - AS $$ SELECT crypto_hash_list(ARRAY[crypto_hash_text(provider), crypto_hash_text(amount)]) $$ - LANGUAGE sql IMMUTABLE; + RETURNS NULL ON NULL INPUT + AS $$ SELECT daml_crypto_hash_list(ARRAY[daml_crypto_hash_text(provider), daml_crypto_hash_text(amount)]) $$ + LANGUAGE sql IMMUTABLE PARALLEL SAFE; -- Hash a BatchOfMintingAllowances variant. -- Matches: hash (BatchOfMintingAllowances allowances) = hashVariant "BatchOfMintingAllowances" [hash allowances] -- where hash allowances = hashList (map hash allowances) CREATE FUNCTION hash_batch_of_minting_allowances(allowance_hashes text[]) RETURNS text - AS $$ SELECT crypto_hash_variant('BatchOfMintingAllowances', ARRAY[crypto_hash_list(allowance_hashes)]) $$ - LANGUAGE sql IMMUTABLE; + RETURNS NULL ON NULL INPUT + AS $$ SELECT daml_crypto_hash_variant('BatchOfMintingAllowances', ARRAY[daml_crypto_hash_list(allowance_hashes)]) $$ + LANGUAGE sql IMMUTABLE PARALLEL SAFE; -- Hash a BatchOfBatches variant. -- Matches: hash (BatchOfBatches batchHashes) = hashVariant "BatchOfBatches" [hash batchHashes] -- where hash batchHashes = hashList (map hash batchHashes) (identity on Hash) CREATE FUNCTION hash_batch_of_batches(child_hashes text[]) RETURNS text - AS $$ SELECT crypto_hash_variant('BatchOfBatches', ARRAY[crypto_hash_list(child_hashes)]) $$ - LANGUAGE sql IMMUTABLE; + RETURNS NULL ON NULL INPUT + AS $$ SELECT daml_crypto_hash_variant('BatchOfBatches', ARRAY[daml_crypto_hash_list(child_hashes)]) $$ + LANGUAGE sql IMMUTABLE PARALLEL SAFE; diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala index fcd4458a09..2e7978e544 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala @@ -37,37 +37,37 @@ class DbCryptoHashFunctionsTest Seq( HashTestCase( - "crypto_hash_text('hello')", - "crypto_hash_text('hello')", + "daml_crypto_hash_text('hello')", + "daml_crypto_hash_text('hello')", DamlCryptoHash.hashText("hello"), ), HashTestCase( - "crypto_hash_text on empty string", - "crypto_hash_text('')", + "daml_crypto_hash_text on empty string", + "daml_crypto_hash_text('')", DamlCryptoHash.hashText(""), ), HashTestCase( - "crypto_hash_list with two elements", - s"""crypto_hash_list(ARRAY[ - crypto_hash_text('alice::provider'), - crypto_hash_text('10.0') + "daml_crypto_hash_list with two elements", + s"""daml_crypto_hash_list(ARRAY[ + daml_crypto_hash_text('alice::provider'), + daml_crypto_hash_text('10.0') ]::text[])""", DamlCryptoHash.hashList(Seq(hAlice, h10)), ), HashTestCase( - "crypto_hash_list on empty array", - "crypto_hash_list(ARRAY[]::text[])", + "daml_crypto_hash_list on empty array", + "daml_crypto_hash_list(ARRAY[]::text[])", DamlCryptoHash.hashList(Seq.empty), ), HashTestCase( - "crypto_hash_list on single element", - "crypto_hash_list(ARRAY[crypto_hash_text('only')]::text[])", + "daml_crypto_hash_list on single element", + "daml_crypto_hash_list(ARRAY[daml_crypto_hash_text('only')]::text[])", DamlCryptoHash.hashList(Seq(hOnly)), ), HashTestCase( - "crypto_hash_variant with tag and one field", - s"""crypto_hash_variant('TestTag', ARRAY[ - crypto_hash_text('alice::provider') + "daml_crypto_hash_variant with tag and one field", + s"""daml_crypto_hash_variant('TestTag', ARRAY[ + daml_crypto_hash_text('alice::provider') ]::text[])""", DamlCryptoHash.hashVariant("TestTag", Seq(hAlice)), ), @@ -88,12 +88,12 @@ class DbCryptoHashFunctionsTest Seq( HashTestCase( "golden: hashRecord [hash 1, hash 'x'] (Daml RecV1)", - s"crypto_hash_list(ARRAY['$h1', '$hx']::text[])", + s"daml_crypto_hash_list(ARRAY['$h1', '$hx']::text[])", "e2878cf11d8a10aa17f359e1f61f711756fdbbc256bf541baec14c21b6888f6e", ), HashTestCase( "golden: hashVariant 'V1' [hash 1, hash 'x'] (Daml VarV1.V1)", - s"crypto_hash_variant('V1', ARRAY['$h1', '$hx']::text[])", + s"daml_crypto_hash_variant('V1', ARRAY['$h1', '$hx']::text[])", "ca314e0bdf0fc327940a89334ff6df58f234b395d36328af1a0cce2339227e5c", ), ) From 0382c176d31e9b42b64e7c1477c2bd50bf7b3994 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Mon, 30 Mar 2026 04:43:12 +0000 Subject: [PATCH 03/17] Add Splice.Testing.CryptoHashProxy to splice-amulet-testing Signed-off-by: Tim Emiola --- .../splice-amulet-name-service-test/daml.yaml | 2 +- daml/splice-amulet-test/daml.yaml | 2 +- .../daml/Splice/Testing/CryptoHashProxy.daml | 93 +++++++++++++++++++ daml/splice-dso-governance-test/daml.yaml | 2 +- daml/splice-wallet-test/daml.yaml | 2 +- daml/splitwell-test/daml.yaml | 2 +- 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml diff --git a/daml/splice-amulet-name-service-test/daml.yaml b/daml/splice-amulet-name-service-test/daml.yaml index d4dfda48ca..22ab8c22be 100644 --- a/daml/splice-amulet-name-service-test/daml.yaml +++ b/daml/splice-amulet-name-service-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-amulet-name-service-test source: daml -version: 0.1.21 +version: 0.1.22 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-amulet-test/daml.yaml b/daml/splice-amulet-test/daml.yaml index c739b84549..a4b7014838 100644 --- a/daml/splice-amulet-test/daml.yaml +++ b/daml/splice-amulet-test/daml.yaml @@ -6,7 +6,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-amulet-test source: daml -version: 0.1.20 +version: 0.1.21 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml b/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml new file mode 100644 index 0000000000..381543a421 --- /dev/null +++ b/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml @@ -0,0 +1,93 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Testing.CryptoHashProxy where + +import DA.Text (intercalate, sha256) + +-- TODO(#3964): When Splice.Amulet.CryptoHash is merged, remove these +-- test-only copies and import from the real module instead. + +-- | Hash a text scalar: sha256(value). +-- Matches: instance Hashable Text where hash = hashText . id +hashText : Text -> Text +hashText = sha256 + +-- | Hash a list of already-hashed elements. +-- Matches: hashListInternal ts = sha256(intercalate "|" (show(length ts) :: ts)) +hashList : [Text] -> Text +hashList elems = + let parts = show (length elems) :: elems + in sha256 (intercalate "|" parts) + +-- | Hash a variant with a tag and field hashes. +-- Matches: hashVariant tag fields = sha256(intercalate "|" (tag :: show(length fields) :: fields)) +hashVariant : Text -> [Text] -> Text +hashVariant tag fieldHashes = + let parts = tag :: show (length fieldHashes) :: fieldHashes + in sha256 (intercalate "|" parts) + +-- | Hash a record as a list of field hashes. +hashRecord : [Text] -> Text +hashRecord = hashList + +-- | Hash a MintingAllowance{provider, amount}. +hashMintingAllowance : Text -> Text -> Text +hashMintingAllowance provider amount = + hashRecord [hashText provider, hashText amount] + +-- | Hash a BatchOfMintingAllowances variant. +hashBatchOfMintingAllowances : [Text] -> Text +hashBatchOfMintingAllowances allowanceHashes = + hashVariant "BatchOfMintingAllowances" [hashList allowanceHashes] + +-- | Hash a BatchOfBatches variant. +hashBatchOfBatches : [Text] -> Text +hashBatchOfBatches childHashes = + hashVariant "BatchOfBatches" [hashList childHashes] + +-- | Proxy template exposing hash functions as exercisable choices. +-- Allows Scala integration tests to call Daml hash functions via the +-- ledger API and compare results to SQL implementations. +template CryptoHashProxy with + owner : Party + where + signatory owner + + nonconsuming choice CryptoHashProxy_HashText : Text + with + input : Text + controller owner + do pure (hashText input) + + nonconsuming choice CryptoHashProxy_HashList : Text + with + elems : [Text] + controller owner + do pure (hashList elems) + + nonconsuming choice CryptoHashProxy_HashVariant : Text + with + tag : Text + fields : [Text] + controller owner + do pure (hashVariant tag fields) + + nonconsuming choice CryptoHashProxy_HashMintingAllowance : Text + with + provider : Text + amount : Text + controller owner + do pure (hashMintingAllowance provider amount) + + nonconsuming choice CryptoHashProxy_HashBatchOfMintingAllowances : Text + with + allowanceHashes : [Text] + controller owner + do pure (hashBatchOfMintingAllowances allowanceHashes) + + nonconsuming choice CryptoHashProxy_HashBatchOfBatches : Text + with + childHashes : [Text] + controller owner + do pure (hashBatchOfBatches childHashes) diff --git a/daml/splice-dso-governance-test/daml.yaml b/daml/splice-dso-governance-test/daml.yaml index a8bdf68f18..0e0acfcb9f 100644 --- a/daml/splice-dso-governance-test/daml.yaml +++ b/daml/splice-dso-governance-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-dso-governance-test source: daml -version: 0.1.27 +version: 0.1.28 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-wallet-test/daml.yaml b/daml/splice-wallet-test/daml.yaml index 3ea53ca28d..490618978d 100644 --- a/daml/splice-wallet-test/daml.yaml +++ b/daml/splice-wallet-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-wallet-test source: daml -version: 0.1.21 +version: 0.1.22 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splitwell-test/daml.yaml b/daml/splitwell-test/daml.yaml index 8473a63783..7ef619ce3c 100644 --- a/daml/splitwell-test/daml.yaml +++ b/daml/splitwell-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splitwell-test source: daml -version: 0.1.21 +version: 0.1.22 dependencies: - daml-prim - daml-stdlib From 4b90a0ae502f81cd09039113175b038410f65345 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Mon, 30 Mar 2026 07:13:17 +0000 Subject: [PATCH 04/17] Update build files to allow use of TestEngine in splice tests Signed-off-by: Tim Emiola --- build.sbt | 1 + project/BuildCommon.scala | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 3d78d54a71..cf416aecf6 100644 --- a/build.sbt +++ b/build.sbt @@ -1128,6 +1128,7 @@ lazy val `apps-scan` = .in(file("apps/scan")) .dependsOn( `apps-common` % "compile->compile;test->test", + `canton-ledger-api-core` % "test->test", `splice-dso-governance-daml`, ) .settings( diff --git a/project/BuildCommon.scala b/project/BuildCommon.scala index 2a4cdab8b6..4eaec04066 100644 --- a/project/BuildCommon.scala +++ b/project/BuildCommon.scala @@ -785,11 +785,12 @@ object BuildCommon { ) .settings( removeTestSources, - // We only need 3 files out of a lot of test files so add them explicitly + // We only need a few files out of a lot of test files so add them explicitly Test / managedSources := Seq( (Test / sourceDirectory).value / "scala/com/digitalasset/canton/HasActorSystem.scala", (Test / sourceDirectory).value / "scala/com/digitalasset/canton/store/db/DbTest.scala", (Test / sourceDirectory).value / "scala/com/digitalasset/canton/store/db/DbStorageIdempotency.scala", + (Test / sourceDirectory).value / "scala/com/digitalasset/canton/crypto/TestSalt.scala", ), disableTests, sharedCantonSettings, @@ -1265,6 +1266,10 @@ object BuildCommon { ) // to accommodate different daml repo coding style .settings( removeTestSources, + // Re-add TestEngine so Splice tests can run Daml code in-memory + Test / unmanagedSources := Seq( + (Test / sourceDirectory).value / "scala/com/digitalasset/canton/util/TestEngine.scala" + ), sharedCantonSettings, sharedSettings, scalacOptions += "-Wconf:src=src_managed/.*:silent", From d33ebf8ac7566e39c33bac3af6adf854fe2a26b8 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Mon, 30 Mar 2026 07:14:34 +0000 Subject: [PATCH 05/17] [ci] Replace DbCryptoHashFunctionsTest with CryptoHashEquivalenceTest - the new test does three-way testing between db, daml and a scala oracle Signed-off-by: Tim Emiola --- .../store/CryptoHashEquivalenceTest.scala | 262 ++++++++++++++++++ .../store/DbCryptoHashFunctionsTest.scala | 208 -------------- test-full-class-names-non-integration.log | 2 +- 3 files changed, 263 insertions(+), 209 deletions(-) create mode 100644 apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala delete mode 100644 apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala new file mode 100644 index 0000000000..1f9c7e6710 --- /dev/null +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala @@ -0,0 +1,262 @@ +package org.lfdecentralizedtrust.splice.scan.store + +import com.daml.ledger.javaapi.data.{ + CreateAndExerciseCommand, + DamlList, + DamlRecord, + Identifier, + Party, + Text, + Value, +} +import com.digitalasset.canton.HasExecutionContext +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.TestEngine +import com.digitalasset.daml.lf.data.Ref +import com.digitalasset.daml.lf.transaction.Node +import com.digitalasset.daml.lf.value.{Value as LfValue} +import org.lfdecentralizedtrust.splice.store.StoreTestBase +import org.lfdecentralizedtrust.splice.store.db.SplicePostgresTest +import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton + +import java.security.MessageDigest +import scala.concurrent.Future +import scala.jdk.CollectionConverters.* + +/** Three-way equivalence test: Daml == SQL == Scala oracle. + * + * Each test case is a `HashOp` describing what to hash. The SQL expression, + * Daml choice arguments, and Scala expected value are all derived from it — + * no duplication, no chance of mismatch between the test inputs. + * + * All three must agree. + */ +class CryptoHashEquivalenceTest + extends StoreTestBase + with HasExecutionContext + with SplicePostgresTest { + + import CryptoHashEquivalenceTest.* + + "crypto hash three-way equivalence" should { + allTestCases.foreach { tc => + tc.description in { + val damlResult = exerciseDaml(tc.op) + for { + sqlResult <- runSqlText(sql"""select #${toSqlExpr(tc.op)}""".as[String].head) + } yield { + val scalaResult = toScalaExpected(tc.op) + withClue(s"Daml vs Scala for ${tc.description}:") { + damlResult shouldBe scalaResult + } + withClue(s"SQL vs Scala for ${tc.description}:") { + sqlResult shouldBe scalaResult + } + } + } + } + } + + private def runSqlText( + action: slick.dbio.DBIOAction[String, slick.dbio.NoStream, slick.dbio.Effect.Read] + ): Future[String] = + futureUnlessShutdownToFuture( + storage.underlying.query(action, "test.runSqlText") + ) + + override protected def cleanDb( + storage: DbStorage + )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = + resetAllAppTables(storage) +} + +object CryptoHashEquivalenceTest { + + // -- Test cases --------------------------------------------------------------- + + case class TestCase(description: String, op: HashOp) + + private val hAlice = ScalaOracle.hashText("alice::provider") + private val h10 = ScalaOracle.hashText("10.0") + private val hOnly = ScalaOracle.hashText("only") + private val h1 = ScalaOracle.hashText("1") + private val hx = ScalaOracle.hashText("x") + + val allTestCases: Seq[TestCase] = { + val maAlice = ScalaOracle.hashMintingAllowance("alice::provider", "10.0000000000") + val maBob = ScalaOracle.hashMintingAllowance("bob::provider", "0") + val leaf1 = ScalaOracle.hashBatchOfMintingAllowances( + Seq(ScalaOracle.hashMintingAllowance("alice::provider", "5.0")) + ) + val leaf2 = ScalaOracle.hashBatchOfMintingAllowances( + Seq(ScalaOracle.hashMintingAllowance("bob::provider", "3.0")) + ) + + Seq( + TestCase("hashText('hello')", HashText("hello")), + TestCase("hashText on empty string", HashText("")), + TestCase("hashList with two elements", HashList(Seq(hAlice, h10))), + TestCase("hashList on empty array", HashList(Seq.empty)), + TestCase("hashList on single element", HashList(Seq(hOnly))), + TestCase("hashVariant with tag and one field", HashVariant("TestTag", Seq(hAlice))), + TestCase( + "hash_minting_allowance(alice, 10.0)", + HashMintingAllowance("alice::provider", "10.0000000000"), + ), + TestCase("hash_minting_allowance(bob, 0)", HashMintingAllowance("bob::provider", "0")), + TestCase( + "hash_batch_of_minting_allowances with two", + HashBatchOfMintingAllowances(Seq(maAlice, maBob)), + ), + TestCase("hash_batch_of_batches with two leaves", HashBatchOfBatches(Seq(leaf1, leaf2))), + // These inputs match the Daml CryptoHash unit test suite (RecV1, VarV1.V1) + TestCase("hashRecord [hash 1, hash 'x']", HashList(Seq(h1, hx))), + TestCase("hashVariant 'V1' [hash 1, hash 'x']", HashVariant("V1", Seq(h1, hx))), + ) + } + + // -- HashOp: structured description of what to hash --------------------------- + + sealed trait HashOp + case class HashText(value: String) extends HashOp + case class HashList(elems: Seq[String]) extends HashOp + case class HashVariant(tag: String, fields: Seq[String]) extends HashOp + case class HashMintingAllowance(provider: String, amount: String) extends HashOp + case class HashBatchOfMintingAllowances(allowanceHashes: Seq[String]) extends HashOp + case class HashBatchOfBatches(childHashes: Seq[String]) extends HashOp + + // -- Derive SQL expression from HashOp ---------------------------------------- + + def toSqlExpr(op: HashOp): String = op match { + case HashText(v) => + s"daml_crypto_hash_text('${escapeSql(v)}')" + case HashList(elems) => + val arr = elems.map(e => s"'$e'").mkString(", ") + s"daml_crypto_hash_list(ARRAY[$arr]::text[])" + case HashVariant(tag, fields) => + val arr = fields.map(f => s"'$f'").mkString(", ") + s"daml_crypto_hash_variant('${escapeSql(tag)}', ARRAY[$arr]::text[])" + case HashMintingAllowance(provider, amount) => + s"hash_minting_allowance('${escapeSql(provider)}', '${escapeSql(amount)}')" + case HashBatchOfMintingAllowances(hashes) => + val arr = hashes.map(h => s"'$h'").mkString(", ") + s"hash_batch_of_minting_allowances(ARRAY[$arr]::text[])" + case HashBatchOfBatches(hashes) => + val arr = hashes.map(h => s"'$h'").mkString(", ") + s"hash_batch_of_batches(ARRAY[$arr]::text[])" + } + + private def escapeSql(s: String): String = s.replace("'", "''") + + // -- Derive Daml choice + arg from HashOp ------------------------------------- + + private def toDamlChoiceAndArg(op: HashOp): (String, Value) = op match { + case HashText(v) => + ("CryptoHashProxy_HashText", record("input" -> text(v))) + case HashList(elems) => + ("CryptoHashProxy_HashList", record("elems" -> textList(elems))) + case HashVariant(tag, fields) => + ("CryptoHashProxy_HashVariant", record("tag" -> text(tag), "fields" -> textList(fields))) + case HashMintingAllowance(provider, amount) => + ( + "CryptoHashProxy_HashMintingAllowance", + record("provider" -> text(provider), "amount" -> text(amount)), + ) + case HashBatchOfMintingAllowances(hashes) => + ( + "CryptoHashProxy_HashBatchOfMintingAllowances", + record("allowanceHashes" -> textList(hashes)), + ) + case HashBatchOfBatches(hashes) => + ("CryptoHashProxy_HashBatchOfBatches", record("childHashes" -> textList(hashes))) + } + + private def text(s: String): Value = new Text(s) + private def textList(ss: Seq[String]): Value = DamlList.of(ss.map(s => new Text(s): Value).asJava) + private def record(fields: (String, Value)*): Value = + new DamlRecord(fields.map { case (k, v) => new DamlRecord.Field(k, v) }.asJava) + + // -- Derive Scala expected from HashOp ---------------------------------------- + + def toScalaExpected(op: HashOp): String = op match { + case HashText(v) => ScalaOracle.hashText(v) + case HashList(elems) => ScalaOracle.hashList(elems) + case HashVariant(tag, fields) => ScalaOracle.hashVariant(tag, fields) + case HashMintingAllowance(p, a) => ScalaOracle.hashMintingAllowance(p, a) + case HashBatchOfMintingAllowances(h) => ScalaOracle.hashBatchOfMintingAllowances(h) + case HashBatchOfBatches(h) => ScalaOracle.hashBatchOfBatches(h) + } + + // -- Scala oracle ------------------------------------------------------------- + + private[store] object ScalaOracle { + def hashText(s: String): String = sha256Hex(s) + + def hashList(elems: Seq[String]): String = { + val parts = elems.size.toString +: elems + sha256Hex(parts.mkString("|")) + } + + def hashVariant(tag: String, fieldHashes: Seq[String]): String = { + val parts = tag +: fieldHashes.size.toString +: fieldHashes + sha256Hex(parts.mkString("|")) + } + + def hashMintingAllowance(provider: String, amount: String): String = + hashList(Seq(hashText(provider), hashText(amount))) + + def hashBatchOfMintingAllowances(allowanceHashes: Seq[String]): String = + hashVariant("BatchOfMintingAllowances", Seq(hashList(allowanceHashes))) + + def hashBatchOfBatches(childHashes: Seq[String]): String = + hashVariant("BatchOfBatches", Seq(hashList(childHashes))) + + private def sha256Hex(s: String): String = { + val digest = MessageDigest.getInstance("SHA-256") + digest.digest(s.getBytes("UTF-8")).map("%02x".format(_)).mkString + } + } + + // -- Daml TestEngine ---------------------------------------------------------- + + private val darPath = "daml/splice-amulet-test/.daml/dist/splice-amulet-test-current.dar" + private val testEngine = new TestEngine(packagePaths = Seq(darPath)) + + private val packageId: String = { + val moduleName = Ref.ModuleName.assertFromString("Splice.Testing.CryptoHashProxy") + testEngine.packageStore.packages + .collectFirst { + case (pkgId, (_, pkg)) if pkg.modules.contains(moduleName) => pkgId + } + .getOrElse(sys.error(s"Could not find package containing $moduleName")) + .toString + } + + private val templateId = new Identifier( + packageId, + "Splice.Testing.CryptoHashProxy", + "CryptoHashProxy", + ) + + private val party = "alice" + private val createArgs = new DamlRecord( + java.util.List.of(new DamlRecord.Field("owner", new Party(party))) + ) + + private def exerciseDaml(op: HashOp): String = { + val (choiceName, choiceArg) = toDamlChoiceAndArg(op) + val cmd = new CreateAndExerciseCommand(templateId, createArgs, choiceName, choiceArg) + val (tx, _) = testEngine.submitAndConsume(cmd, party) + + tx.roots.toSeq + .map(nid => tx.nodes(nid)) + .collectFirst { case ex: Node.Exercise => ex } + .getOrElse(sys.error("No exercise node found in transaction")) + .exerciseResult + .collect { case LfValue.ValueText(text) => text } + .getOrElse(sys.error("Exercise result is not Text")) + } +} diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala deleted file mode 100644 index 2e7978e544..0000000000 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbCryptoHashFunctionsTest.scala +++ /dev/null @@ -1,208 +0,0 @@ -package org.lfdecentralizedtrust.splice.scan.store - -import com.digitalasset.canton.HasExecutionContext -import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.resource.DbStorage -import com.digitalasset.canton.lifecycle.FutureUnlessShutdown -import org.lfdecentralizedtrust.splice.store.StoreTestBase -import org.lfdecentralizedtrust.splice.store.db.SplicePostgresTest -import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture -import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton - -import java.security.MessageDigest -import scala.concurrent.Future - -/** Tests that the plpgsql hash functions produce output identical to - * the Daml CryptoHash module (Splice.Amulet.CryptoHash). - */ -class DbCryptoHashFunctionsTest - extends StoreTestBase - with HasExecutionContext - with SplicePostgresTest { - - import DbCryptoHashFunctionsTest.DamlCryptoHash - - private case class HashTestCase( - description: String, - sqlExpr: String, - expected: String, - ) - - // -- Primitive function tests ----------------------------------------------- - - private val primitiveTestCases = { - val hAlice = DamlCryptoHash.hashText("alice::provider") - val h10 = DamlCryptoHash.hashText("10.0") - val hOnly = DamlCryptoHash.hashText("only") - - Seq( - HashTestCase( - "daml_crypto_hash_text('hello')", - "daml_crypto_hash_text('hello')", - DamlCryptoHash.hashText("hello"), - ), - HashTestCase( - "daml_crypto_hash_text on empty string", - "daml_crypto_hash_text('')", - DamlCryptoHash.hashText(""), - ), - HashTestCase( - "daml_crypto_hash_list with two elements", - s"""daml_crypto_hash_list(ARRAY[ - daml_crypto_hash_text('alice::provider'), - daml_crypto_hash_text('10.0') - ]::text[])""", - DamlCryptoHash.hashList(Seq(hAlice, h10)), - ), - HashTestCase( - "daml_crypto_hash_list on empty array", - "daml_crypto_hash_list(ARRAY[]::text[])", - DamlCryptoHash.hashList(Seq.empty), - ), - HashTestCase( - "daml_crypto_hash_list on single element", - "daml_crypto_hash_list(ARRAY[daml_crypto_hash_text('only')]::text[])", - DamlCryptoHash.hashList(Seq(hOnly)), - ), - HashTestCase( - "daml_crypto_hash_variant with tag and one field", - s"""daml_crypto_hash_variant('TestTag', ARRAY[ - daml_crypto_hash_text('alice::provider') - ]::text[])""", - DamlCryptoHash.hashVariant("TestTag", Seq(hAlice)), - ), - ) - } - - // -- Golden values from Daml CryptoHash unit tests -------------------------- - // Source: daml/splice-amulet-test/daml/Splice/Scripts/UnitTests/Amulet/CryptoHash.daml - // These are hardcoded expected hashes from the Daml test suite, not computed - // by our Scala oracle. They anchor the hash scheme to the canonical Daml output. - - private val goldenTestCases = { - // Daml: RecV1{a=1, b="x"} => hashRecord [hash 1, hash "x"] - // hash Int = hashText . show, hash Text = hashText - val h1 = DamlCryptoHash.hashText("1") - val hx = DamlCryptoHash.hashText("x") - - Seq( - HashTestCase( - "golden: hashRecord [hash 1, hash 'x'] (Daml RecV1)", - s"daml_crypto_hash_list(ARRAY['$h1', '$hx']::text[])", - "e2878cf11d8a10aa17f359e1f61f711756fdbbc256bf541baec14c21b6888f6e", - ), - HashTestCase( - "golden: hashVariant 'V1' [hash 1, hash 'x'] (Daml VarV1.V1)", - s"daml_crypto_hash_variant('V1', ARRAY['$h1', '$hx']::text[])", - "ca314e0bdf0fc327940a89334ff6df58f234b395d36328af1a0cce2339227e5c", - ), - ) - } - - // -- Domain-specific function tests ----------------------------------------- - - private val domainTestCases = { - val maAlice = DamlCryptoHash.hashMintingAllowance("alice::provider", "10.0000000000") - val maBob = DamlCryptoHash.hashMintingAllowance("bob::provider", "0") - - val leaf1 = DamlCryptoHash.hashBatchOfMintingAllowances( - Seq(DamlCryptoHash.hashMintingAllowance("alice::provider", "5.0")) - ) - val leaf2 = DamlCryptoHash.hashBatchOfMintingAllowances( - Seq(DamlCryptoHash.hashMintingAllowance("bob::provider", "3.0")) - ) - - Seq( - HashTestCase( - "hash_minting_allowance(alice, 10.0)", - "hash_minting_allowance('alice::provider', '10.0000000000')", - maAlice, - ), - HashTestCase( - "hash_minting_allowance(bob, 0)", - "hash_minting_allowance('bob::provider', '0')", - maBob, - ), - HashTestCase( - "hash_batch_of_minting_allowances with two allowances", - s"""hash_batch_of_minting_allowances(ARRAY[ - hash_minting_allowance('alice::provider', '10.0000000000'), - hash_minting_allowance('bob::provider', '0') - ]::text[])""", - DamlCryptoHash.hashBatchOfMintingAllowances(Seq(maAlice, maBob)), - ), - HashTestCase( - "hash_batch_of_batches with two leaf batches", - s"""hash_batch_of_batches(ARRAY[ - hash_batch_of_minting_allowances(ARRAY[ - hash_minting_allowance('alice::provider', '5.0') - ]::text[]), - hash_batch_of_minting_allowances(ARRAY[ - hash_minting_allowance('bob::provider', '3.0') - ]::text[]) - ]::text[])""", - DamlCryptoHash.hashBatchOfBatches(Seq(leaf1, leaf2)), - ), - ) - } - - "plpgsql crypto hash functions" should { - (primitiveTestCases ++ goldenTestCases ++ domainTestCases).foreach { tc => - tc.description in { - for { - result <- runSqlText( - sql"""select #${tc.sqlExpr}""".as[String].head - ) - } yield { - result shouldBe tc.expected - } - } - } - } - - private def runSqlText( - action: slick.dbio.DBIOAction[String, slick.dbio.NoStream, slick.dbio.Effect.Read] - ): Future[String] = - futureUnlessShutdownToFuture( - storage.underlying.query(action, "test.runSqlText") - ) - - override protected def cleanDb( - storage: DbStorage - )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = - resetAllAppTables(storage) -} - -object DbCryptoHashFunctionsTest { - - /** Scala reimplementation of Daml's CryptoHash module (Splice.Amulet.CryptoHash). - * Used as the test oracle to verify plpgsql hash functions produce identical output. - */ - private[store] object DamlCryptoHash { - def hashText(s: String): String = sha256Hex(s) - - def hashList(elems: Seq[String]): String = { - val parts = elems.size.toString +: elems - sha256Hex(parts.mkString("|")) - } - - def hashVariant(tag: String, fieldHashes: Seq[String]): String = { - val parts = tag +: fieldHashes.size.toString +: fieldHashes - sha256Hex(parts.mkString("|")) - } - - def hashMintingAllowance(provider: String, amount: String): String = - hashList(Seq(hashText(provider), hashText(amount))) - - def hashBatchOfMintingAllowances(allowanceHashes: Seq[String]): String = - hashVariant("BatchOfMintingAllowances", Seq(hashList(allowanceHashes))) - - def hashBatchOfBatches(childHashes: Seq[String]): String = - hashVariant("BatchOfBatches", Seq(hashList(childHashes))) - - private def sha256Hex(s: String): String = { - val digest = MessageDigest.getInstance("SHA-256") - digest.digest(s.getBytes("UTF-8")).map("%02x".format(_)).mkString - } - } -} diff --git a/test-full-class-names-non-integration.log b/test-full-class-names-non-integration.log index 17d4f15ae2..c3ee052f2d 100644 --- a/test-full-class-names-non-integration.log +++ b/test-full-class-names-non-integration.log @@ -14,8 +14,8 @@ org.lfdecentralizedtrust.splice.scan.automation.AcsSnapshotTriggerTest org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTriggerTest org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfigTest org.lfdecentralizedtrust.splice.scan.rewards.RewardComputationInputsTest +org.lfdecentralizedtrust.splice.scan.store.CryptoHashEquivalenceTest org.lfdecentralizedtrust.splice.scan.store.DbAppActivityRecordStoreTest -org.lfdecentralizedtrust.splice.scan.store.DbCryptoHashFunctionsTest org.lfdecentralizedtrust.splice.scan.store.DbScanAppRewardsStoreTest org.lfdecentralizedtrust.splice.scan.store.ScanEventStoreTest org.lfdecentralizedtrust.splice.scan.store.ScanKeyValueProviderTest From 112f1727416e0c2d34ec77d2c87a2ac695205e5b Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Mon, 30 Mar 2026 08:44:22 +0000 Subject: [PATCH 06/17] [static] Update dars.lock Signed-off-by: Tim Emiola --- daml/dars.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/daml/dars.lock b/daml/dars.lock index 0a78c3c8fc..88e234a941 100644 --- a/daml/dars.lock +++ b/daml/dars.lock @@ -35,8 +35,8 @@ splice-amulet-name-service 0.1.6 a208aab2c4a248ab2eff352bd382f8b3bbadc92464123db splice-amulet-name-service 0.1.7 ba7806d9b2d593eac74a050161c54ae1325d170bf175cb66a9c1e5e5ffb88c3d splice-amulet-name-service 0.1.8 efeb3f9b2b92e55fac4ec2d6164f95407a01477240c7465e576df4e310f54bd3 splice-amulet-name-service 0.1.9 f1b5915ad45ded616f43f83c735b7ee158b5eb58abe758a721e50eee19b3e531 -splice-amulet-name-service-test 0.1.21 b120f1061b3a4b278badcc27d2ab40bf9f5593a65b92fd1faa568ff02f6eba62 -splice-amulet-test 0.1.20 a9850c98ef59f0550ef7e036d11464bb8b7e97c4b7e88c8a390bbd27e946cca5 +splice-amulet-name-service-test 0.1.22 7be24690bd4b39a76d7245ddfae0c25a2f66c32099e17e856ddb92f5b944bea3 +splice-amulet-test 0.1.21 5657829c3084a8cf5a66c8da180d30aeb5977969e41723e3493ec82830407d17 splice-api-featured-app-v1 1.0.0 7804375fe5e4c6d5afe067bd314c42fe0b7d005a1300019c73154dd939da4dda splice-api-featured-app-v2 1.0.0 dd22e3e168a8c7fd0313171922dabf1f7a3b131bd9bfc9ff98e606f8c57707ea splice-api-token-allocation-instruction-v1 1.0.0 275064aacfe99cea72ee0c80563936129563776f67415ef9f13e4297eecbc520 @@ -72,7 +72,7 @@ splice-dso-governance 0.1.6 4e7653cfbf7ca249de4507aca9cd3b91060e5489042a522c589d splice-dso-governance 0.1.7 d406eba1132d464605f4dae3edf8cf5ecbbb34bd8edef0e047e7e526d328718c splice-dso-governance 0.1.8 1790a114f83d5f290261fae1e7e46fba75a861a3dd603c6b4ef6b67b49053948 splice-dso-governance 0.1.9 9ee83bfd872f91e659b8a8439c5b4eaf240bcf6f19698f884d7d7993ab48c401 -splice-dso-governance-test 0.1.27 46a2441bd370d21757a4f94fa44ba865f1cddf53ecec0e5a38bff84700977f00 +splice-dso-governance-test 0.1.28 33910e178389129fd916c577fbfd0a58ce86fe33db6a358e7dc988ee2ec014e8 splice-token-standard-test 1.0.11 b641f7509aff67cdfa82b66ebccfff072b2226b39e5bd998ac118570c926fc50 splice-token-test-dummy-holding 0.0.1 1cd171c6c42ab46dc9cf12d80c6111369e00cea5cdf054924b4f26ce94b1ef5b splice-token-test-dummy-holding 0.0.2 4f40fb033ef3db89623642c1b494e846097fa32af138b3864a63aa15937a323d @@ -139,7 +139,7 @@ splice-wallet-payments 0.1.6 6124379528eeb6fa17ecdab15577c29abb33d0c0d34dc5f2680 splice-wallet-payments 0.1.7 4e3e0d9cdadf80f4bf8f3cd3660d5287c084c9a29f23c901aabce597d72fd467 splice-wallet-payments 0.1.8 e48ea337ee3335c8bb3206a2501ce947ac1a7bdb1825cee8f28bad64f5a7bc4b splice-wallet-payments 0.1.9 7f4e081ad96f2ccded0c053b0cf5ddddae1139dfc3bb89cefcf77ea70f2cecb7 -splice-wallet-test 0.1.21 c751f85e42e928f0e300d561bda15be7bb42dd19681a52a4955d839f47f6b695 +splice-wallet-test 0.1.22 9d28b14cc32d128d0d6384eaae90c505f985a0c45d706caca483690b023efe60 splitwell 0.1.0 075c76de553ab88383a7c69de134afa82aacfdf8ea8fcfe8852c4b199c3b2669 splitwell 0.1.1 ccb1a0215053062202052e1a052f9214da3fdae5253a6d43e2e155ff4f57fe75 splitwell 0.1.10 d42676a366f7ca7a2409974dd3054aa4d83ab29baa3b2086ad021407b0a1a295 @@ -159,4 +159,4 @@ splitwell 0.1.6 872da0dd7986fd768930f85d6a7310a94a0ef924e7fbb7bb7a4e149f2b5feb74 splitwell 0.1.7 841d1c9c86b5c8f3a39059459ecd8febedf7703e18f117300bb0ebf4423db096 splitwell 0.1.8 63b8153a08ceb4bf40d807acc5712372c3eac548c266be4d5e92470b4f655515 splitwell 0.1.9 b6267905698d2798b9ef171e27d49fb88e052ec0ec0e0675a3a1b275c7d037d4 -splitwell-test 0.1.21 7cf052802bd7c9f3dcbe7f7af893e595310f7ec98fee05362208cb4808b3b61f \ No newline at end of file +splitwell-test 0.1.22 68088430da8cafaa5f8907aacdd4e257d838a6b58a3c1a29eb5555c8145483e4 \ No newline at end of file From 8ec806d4966af2b5e72e343231f66b06b6a35461 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 04:47:07 +0000 Subject: [PATCH 07/17] Switched the TODO reference to distinct issue Signed-off-by: Tim Emiola --- .../splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml b/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml index 381543a421..0c13b83c02 100644 --- a/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml +++ b/daml/splice-amulet-test/daml/Splice/Testing/CryptoHashProxy.daml @@ -5,7 +5,7 @@ module Splice.Testing.CryptoHashProxy where import DA.Text (intercalate, sha256) --- TODO(#3964): When Splice.Amulet.CryptoHash is merged, remove these +-- TODO(#4749): When Splice.Amulet.CryptoHash is merged, remove these -- test-only copies and import from the real module instead. -- | Hash a text scalar: sha256(value). From d1356fea5a7345fff4a25a4673a9ae628169d694 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 06:29:17 +0000 Subject: [PATCH 08/17] Implement CryptoHashEquivalenceIntegrationTest - no need for dependencies on Canton Signed-off-by: Tim Emiola --- ...CryptoHashEquivalenceIntegrationTest.scala | 287 ++++++++++++++++++ test-full-class-names.log | 1 + 2 files changed, 288 insertions(+) create mode 100644 apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala new file mode 100644 index 0000000000..9e27352827 --- /dev/null +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -0,0 +1,287 @@ +package org.lfdecentralizedtrust.splice.integration.tests + +import com.daml.ledger.javaapi.data.{ + CreatedEvent, + CreateCommand, + DamlList, + DamlRecord, + ExerciseCommand, + ExercisedEvent, + Identifier, + Party, + Text, + Value, +} +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.daml.lf.data.Ref +import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition +import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest +import org.lfdecentralizedtrust.splice.util.{DarUtil, WalletTestUtil} +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton + +import java.io.File +import java.security.MessageDigest +import scala.jdk.CollectionConverters.* + +/** Three-way integration test: Daml (real participant) == SQL (scan Postgres) == Scala oracle. + * + * This complements the store-level CryptoHashEquivalenceTest (which uses + * Canton's in-memory TestEngine) by validating against a real ledger and + * the actual scan database. + * + * Uses a shared environment so the dar is uploaded and proxy created once, + * then each hash function gets its own test. + */ +class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTestUtil { + + import CryptoHashEquivalenceIntegrationTest.* + + override def environmentDefinition: SpliceEnvironmentDefinition = + EnvironmentDefinition + .simpleTopology1Sv(this.getClass.getSimpleName) + + "Three-way CryptoHash equivalence (Daml, SQL, Scala)" should { + "set up CryptoHashProxy" in { implicit env => + svParty = sv1Backend.getDsoInfo().svParty + + val (cid, _) = actAndCheck( + "Upload dar and create CryptoHashProxy", { + sv1Backend.participantClientWithAdminToken.dars.upload(darPath) + val createArgs = new DamlRecord( + java.util.List.of( + new DamlRecord.Field("owner", new Party(svParty.toProtoPrimitive)) + ) + ) + val createTx = + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(svParty), + commands = Seq(new CreateCommand(templateId, createArgs)), + ) + createTx.getEvents.asScala + .collectFirst { case ce: CreatedEvent => ce.getContractId } + .getOrElse(fail("No CreatedEvent from CryptoHashProxy create")) + }, + )( + "CryptoHashProxy contract is created", + contractId => contractId should not be empty, + ) + proxyContractId = cid + scanDb = sv1ScanBackend.appState.storage match { + case db: DbStorage => db + case s => fail(s"non-DB storage configured: ${s.getClass}") + } + } + + allTestCases.foreach { tc => + tc.description in { implicit env => + val scalaHash = toScalaExpected(tc.op) + + clue(s"Daml vs Scala for ${tc.description}") { + val (choiceName, choiceArg) = toDamlChoiceAndArg(tc.op) + actAndCheck( + s"Exercise Daml ${tc.description}", { + val cmd = + new ExerciseCommand(templateId, proxyContractId, choiceName, choiceArg) + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(svParty), + commands = Seq(cmd), + ) + }, + )( + "Daml result matches Scala oracle", + tx => { + val damlHash = tx.getEventsById.values.asScala + .collectFirst { case ex: ExercisedEvent => + ex.getExerciseResult match { + case t: Text => t.getValue + case other => + fail(s"Expected Text result but got ${other.getClass.getSimpleName}") + } + } + .getOrElse(fail(s"No ExercisedEvent in transaction")) + + damlHash shouldBe scalaHash + }, + ) + } + + clue(s"SQL vs Scala for ${tc.description}") { + val sqlHash = scanDb + .query( + sql"""select #${toSqlExpr(tc.op)}""".as[String].head, + s"test.sqlHash.${tc.description}", + ) + .futureValueUS + + sqlHash shouldBe scalaHash + } + } + } + } + + private var svParty: com.digitalasset.canton.topology.PartyId = _ + private var proxyContractId: String = _ + private var scanDb: DbStorage = _ +} + +object CryptoHashEquivalenceIntegrationTest { + + // -- Package discovery ------------------------------------------------------ + + private val darPath = "daml/splice-amulet-test/.daml/dist/splice-amulet-test-current.dar" + private val moduleName = "Splice.Testing.CryptoHashProxy" + + private val packageId: String = { + val dar = DarUtil.readDar(new File(darPath)) + val modName = Ref.ModuleName.assertFromString(moduleName) + dar.all + .collectFirst { case (pkgId, pkg) if pkg.modules.contains(modName) => pkgId } + .getOrElse(sys.error(s"Could not find package containing $moduleName")) + .toString + } + + private val templateId = new Identifier(packageId, moduleName, "CryptoHashProxy") + + // -- Scala oracle (mirrors Daml CryptoHash module) -------------------------- + + private def sha256Hex(s: String): String = { + val digest = MessageDigest.getInstance("SHA-256") + digest.digest(s.getBytes("UTF-8")).map("%02x".format(_)).mkString + } + + private def hashText(s: String): String = sha256Hex(s) + + private def hashList(elems: Seq[String]): String = { + val parts = elems.size.toString +: elems + sha256Hex(parts.mkString("|")) + } + + private def hashVariant(tag: String, fieldHashes: Seq[String]): String = { + val parts = tag +: fieldHashes.size.toString +: fieldHashes + sha256Hex(parts.mkString("|")) + } + + private def hashMintingAllowance(provider: String, amount: String): String = + hashList(Seq(hashText(provider), hashText(amount))) + + private def hashBatchOfMintingAllowances(allowanceHashes: Seq[String]): String = + hashVariant("BatchOfMintingAllowances", Seq(hashList(allowanceHashes))) + + private def hashBatchOfBatches(childHashes: Seq[String]): String = + hashVariant("BatchOfBatches", Seq(hashList(childHashes))) + + // -- HashOp: structured description of what to hash ------------------------- + + sealed trait HashOp + case class HashText(value: String) extends HashOp + case class HashList(elems: Seq[String]) extends HashOp + case class HashVariant(tag: String, fields: Seq[String]) extends HashOp + case class HashMintingAllowance(provider: String, amount: String) extends HashOp + case class HashBatchOfMintingAllowances(allowanceHashes: Seq[String]) extends HashOp + case class HashBatchOfBatches(childHashes: Seq[String]) extends HashOp + + // -- Derive Daml choice + arg from HashOp ---------------------------------- + + private def text(s: String): Value = new Text(s) + private def textList(ss: Seq[String]): Value = + DamlList.of(ss.map(s => new Text(s): Value).asJava) + private def record(fields: (String, Value)*): Value = + new DamlRecord(fields.map { case (k, v) => new DamlRecord.Field(k, v) }.asJava) + + def toDamlChoiceAndArg(op: HashOp): (String, Value) = op match { + case HashText(v) => + ("CryptoHashProxy_HashText", record("input" -> text(v))) + case HashList(elems) => + ("CryptoHashProxy_HashList", record("elems" -> textList(elems))) + case HashVariant(tag, fields) => + ("CryptoHashProxy_HashVariant", record("tag" -> text(tag), "fields" -> textList(fields))) + case HashMintingAllowance(provider, amount) => + ( + "CryptoHashProxy_HashMintingAllowance", + record("provider" -> text(provider), "amount" -> text(amount)), + ) + case HashBatchOfMintingAllowances(hashes) => + ( + "CryptoHashProxy_HashBatchOfMintingAllowances", + record("allowanceHashes" -> textList(hashes)), + ) + case HashBatchOfBatches(hashes) => + ("CryptoHashProxy_HashBatchOfBatches", record("childHashes" -> textList(hashes))) + } + + // -- Derive SQL expression from HashOp ------------------------------------- + + private def escapeSql(s: String): String = s.replace("'", "''") + private def sqlArray(elems: Seq[String]): String = + s"ARRAY[${elems.map(e => s"'$e'").mkString(", ")}]::text[]" + + def toSqlExpr(op: HashOp): String = op match { + case HashText(v) => + s"daml_crypto_hash_text('${escapeSql(v)}')" + case HashList(elems) => + s"daml_crypto_hash_list(${sqlArray(elems)})" + case HashVariant(tag, fields) => + s"daml_crypto_hash_variant('${escapeSql(tag)}', ${sqlArray(fields)})" + case HashMintingAllowance(provider, amount) => + s"hash_minting_allowance('${escapeSql(provider)}', '${escapeSql(amount)}')" + case HashBatchOfMintingAllowances(hashes) => + s"hash_batch_of_minting_allowances(${sqlArray(hashes)})" + case HashBatchOfBatches(hashes) => + s"hash_batch_of_batches(${sqlArray(hashes)})" + } + + // -- Derive Scala expected from HashOp ------------------------------------- + + def toScalaExpected(op: HashOp): String = op match { + case HashText(v) => hashText(v) + case HashList(elems) => hashList(elems) + case HashVariant(tag, fields) => hashVariant(tag, fields) + case HashMintingAllowance(p, a) => hashMintingAllowance(p, a) + case HashBatchOfMintingAllowances(h) => hashBatchOfMintingAllowances(h) + case HashBatchOfBatches(h) => hashBatchOfBatches(h) + } + + // -- Test cases ------------------------------------------------------------ + + case class TestCase(description: String, op: HashOp) + + private val hAlice = hashText("alice::provider") + private val h10 = hashText("10.0") + private val hOnly = hashText("only") + private val h1 = hashText("1") + private val hx = hashText("x") + + val allTestCases: Seq[TestCase] = { + val maAlice = hashMintingAllowance("alice::provider", "10.0000000000") + val maBob = hashMintingAllowance("bob::provider", "0") + val leaf1 = hashBatchOfMintingAllowances( + Seq(hashMintingAllowance("alice::provider", "5.0")) + ) + val leaf2 = hashBatchOfMintingAllowances( + Seq(hashMintingAllowance("bob::provider", "3.0")) + ) + + Seq( + TestCase("hashText('hello')", HashText("hello")), + TestCase("hashText on empty string", HashText("")), + TestCase("hashList with two elements", HashList(Seq(hAlice, h10))), + TestCase("hashList on empty array", HashList(Seq.empty)), + TestCase("hashList on single element", HashList(Seq(hOnly))), + TestCase("hashVariant with tag and one field", HashVariant("TestTag", Seq(hAlice))), + TestCase( + "hash_minting_allowance(alice, 10.0)", + HashMintingAllowance("alice::provider", "10.0000000000"), + ), + TestCase("hash_minting_allowance(bob, 0)", HashMintingAllowance("bob::provider", "0")), + TestCase( + "hash_batch_of_minting_allowances with two", + HashBatchOfMintingAllowances(Seq(maAlice, maBob)), + ), + TestCase("hash_batch_of_batches with two leaves", HashBatchOfBatches(Seq(leaf1, leaf2))), + TestCase("hashRecord [hash 1, hash 'x']", HashList(Seq(h1, hx))), + TestCase("hashVariant 'V1' [hash 1, hash 'x']", HashVariant("V1", Seq(h1, hx))), + ) + } +} diff --git a/test-full-class-names.log b/test-full-class-names.log index aaca015258..28e194e112 100644 --- a/test-full-class-names.log +++ b/test-full-class-names.log @@ -11,6 +11,7 @@ org.lfdecentralizedtrust.splice.integration.tests.BootstrapPackageConfigDarUploa org.lfdecentralizedtrust.splice.integration.tests.BootstrapTest org.lfdecentralizedtrust.splice.integration.tests.CombinedDumpDirectoryExportIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.ConfigurationProvidedBftScanConnectionIntegrationTest +org.lfdecentralizedtrust.splice.integration.tests.CryptoHashEquivalenceIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.DevelopmentFundCouponIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.DirectoryPeriodicBackupIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.DistributedDomainIntegrationTest From ca100a205372ee917f23c9dbf8acb952888fbc11 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 06:33:49 +0000 Subject: [PATCH 09/17] Remove the non-integration test Signed-off-by: Tim Emiola --- .../store/CryptoHashEquivalenceTest.scala | 262 ------------------ test-full-class-names-non-integration.log | 1 - 2 files changed, 263 deletions(-) delete mode 100644 apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala deleted file mode 100644 index 1f9c7e6710..0000000000 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/CryptoHashEquivalenceTest.scala +++ /dev/null @@ -1,262 +0,0 @@ -package org.lfdecentralizedtrust.splice.scan.store - -import com.daml.ledger.javaapi.data.{ - CreateAndExerciseCommand, - DamlList, - DamlRecord, - Identifier, - Party, - Text, - Value, -} -import com.digitalasset.canton.HasExecutionContext -import com.digitalasset.canton.lifecycle.FutureUnlessShutdown -import com.digitalasset.canton.resource.DbStorage -import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.TestEngine -import com.digitalasset.daml.lf.data.Ref -import com.digitalasset.daml.lf.transaction.Node -import com.digitalasset.daml.lf.value.{Value as LfValue} -import org.lfdecentralizedtrust.splice.store.StoreTestBase -import org.lfdecentralizedtrust.splice.store.db.SplicePostgresTest -import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture -import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton - -import java.security.MessageDigest -import scala.concurrent.Future -import scala.jdk.CollectionConverters.* - -/** Three-way equivalence test: Daml == SQL == Scala oracle. - * - * Each test case is a `HashOp` describing what to hash. The SQL expression, - * Daml choice arguments, and Scala expected value are all derived from it — - * no duplication, no chance of mismatch between the test inputs. - * - * All three must agree. - */ -class CryptoHashEquivalenceTest - extends StoreTestBase - with HasExecutionContext - with SplicePostgresTest { - - import CryptoHashEquivalenceTest.* - - "crypto hash three-way equivalence" should { - allTestCases.foreach { tc => - tc.description in { - val damlResult = exerciseDaml(tc.op) - for { - sqlResult <- runSqlText(sql"""select #${toSqlExpr(tc.op)}""".as[String].head) - } yield { - val scalaResult = toScalaExpected(tc.op) - withClue(s"Daml vs Scala for ${tc.description}:") { - damlResult shouldBe scalaResult - } - withClue(s"SQL vs Scala for ${tc.description}:") { - sqlResult shouldBe scalaResult - } - } - } - } - } - - private def runSqlText( - action: slick.dbio.DBIOAction[String, slick.dbio.NoStream, slick.dbio.Effect.Read] - ): Future[String] = - futureUnlessShutdownToFuture( - storage.underlying.query(action, "test.runSqlText") - ) - - override protected def cleanDb( - storage: DbStorage - )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = - resetAllAppTables(storage) -} - -object CryptoHashEquivalenceTest { - - // -- Test cases --------------------------------------------------------------- - - case class TestCase(description: String, op: HashOp) - - private val hAlice = ScalaOracle.hashText("alice::provider") - private val h10 = ScalaOracle.hashText("10.0") - private val hOnly = ScalaOracle.hashText("only") - private val h1 = ScalaOracle.hashText("1") - private val hx = ScalaOracle.hashText("x") - - val allTestCases: Seq[TestCase] = { - val maAlice = ScalaOracle.hashMintingAllowance("alice::provider", "10.0000000000") - val maBob = ScalaOracle.hashMintingAllowance("bob::provider", "0") - val leaf1 = ScalaOracle.hashBatchOfMintingAllowances( - Seq(ScalaOracle.hashMintingAllowance("alice::provider", "5.0")) - ) - val leaf2 = ScalaOracle.hashBatchOfMintingAllowances( - Seq(ScalaOracle.hashMintingAllowance("bob::provider", "3.0")) - ) - - Seq( - TestCase("hashText('hello')", HashText("hello")), - TestCase("hashText on empty string", HashText("")), - TestCase("hashList with two elements", HashList(Seq(hAlice, h10))), - TestCase("hashList on empty array", HashList(Seq.empty)), - TestCase("hashList on single element", HashList(Seq(hOnly))), - TestCase("hashVariant with tag and one field", HashVariant("TestTag", Seq(hAlice))), - TestCase( - "hash_minting_allowance(alice, 10.0)", - HashMintingAllowance("alice::provider", "10.0000000000"), - ), - TestCase("hash_minting_allowance(bob, 0)", HashMintingAllowance("bob::provider", "0")), - TestCase( - "hash_batch_of_minting_allowances with two", - HashBatchOfMintingAllowances(Seq(maAlice, maBob)), - ), - TestCase("hash_batch_of_batches with two leaves", HashBatchOfBatches(Seq(leaf1, leaf2))), - // These inputs match the Daml CryptoHash unit test suite (RecV1, VarV1.V1) - TestCase("hashRecord [hash 1, hash 'x']", HashList(Seq(h1, hx))), - TestCase("hashVariant 'V1' [hash 1, hash 'x']", HashVariant("V1", Seq(h1, hx))), - ) - } - - // -- HashOp: structured description of what to hash --------------------------- - - sealed trait HashOp - case class HashText(value: String) extends HashOp - case class HashList(elems: Seq[String]) extends HashOp - case class HashVariant(tag: String, fields: Seq[String]) extends HashOp - case class HashMintingAllowance(provider: String, amount: String) extends HashOp - case class HashBatchOfMintingAllowances(allowanceHashes: Seq[String]) extends HashOp - case class HashBatchOfBatches(childHashes: Seq[String]) extends HashOp - - // -- Derive SQL expression from HashOp ---------------------------------------- - - def toSqlExpr(op: HashOp): String = op match { - case HashText(v) => - s"daml_crypto_hash_text('${escapeSql(v)}')" - case HashList(elems) => - val arr = elems.map(e => s"'$e'").mkString(", ") - s"daml_crypto_hash_list(ARRAY[$arr]::text[])" - case HashVariant(tag, fields) => - val arr = fields.map(f => s"'$f'").mkString(", ") - s"daml_crypto_hash_variant('${escapeSql(tag)}', ARRAY[$arr]::text[])" - case HashMintingAllowance(provider, amount) => - s"hash_minting_allowance('${escapeSql(provider)}', '${escapeSql(amount)}')" - case HashBatchOfMintingAllowances(hashes) => - val arr = hashes.map(h => s"'$h'").mkString(", ") - s"hash_batch_of_minting_allowances(ARRAY[$arr]::text[])" - case HashBatchOfBatches(hashes) => - val arr = hashes.map(h => s"'$h'").mkString(", ") - s"hash_batch_of_batches(ARRAY[$arr]::text[])" - } - - private def escapeSql(s: String): String = s.replace("'", "''") - - // -- Derive Daml choice + arg from HashOp ------------------------------------- - - private def toDamlChoiceAndArg(op: HashOp): (String, Value) = op match { - case HashText(v) => - ("CryptoHashProxy_HashText", record("input" -> text(v))) - case HashList(elems) => - ("CryptoHashProxy_HashList", record("elems" -> textList(elems))) - case HashVariant(tag, fields) => - ("CryptoHashProxy_HashVariant", record("tag" -> text(tag), "fields" -> textList(fields))) - case HashMintingAllowance(provider, amount) => - ( - "CryptoHashProxy_HashMintingAllowance", - record("provider" -> text(provider), "amount" -> text(amount)), - ) - case HashBatchOfMintingAllowances(hashes) => - ( - "CryptoHashProxy_HashBatchOfMintingAllowances", - record("allowanceHashes" -> textList(hashes)), - ) - case HashBatchOfBatches(hashes) => - ("CryptoHashProxy_HashBatchOfBatches", record("childHashes" -> textList(hashes))) - } - - private def text(s: String): Value = new Text(s) - private def textList(ss: Seq[String]): Value = DamlList.of(ss.map(s => new Text(s): Value).asJava) - private def record(fields: (String, Value)*): Value = - new DamlRecord(fields.map { case (k, v) => new DamlRecord.Field(k, v) }.asJava) - - // -- Derive Scala expected from HashOp ---------------------------------------- - - def toScalaExpected(op: HashOp): String = op match { - case HashText(v) => ScalaOracle.hashText(v) - case HashList(elems) => ScalaOracle.hashList(elems) - case HashVariant(tag, fields) => ScalaOracle.hashVariant(tag, fields) - case HashMintingAllowance(p, a) => ScalaOracle.hashMintingAllowance(p, a) - case HashBatchOfMintingAllowances(h) => ScalaOracle.hashBatchOfMintingAllowances(h) - case HashBatchOfBatches(h) => ScalaOracle.hashBatchOfBatches(h) - } - - // -- Scala oracle ------------------------------------------------------------- - - private[store] object ScalaOracle { - def hashText(s: String): String = sha256Hex(s) - - def hashList(elems: Seq[String]): String = { - val parts = elems.size.toString +: elems - sha256Hex(parts.mkString("|")) - } - - def hashVariant(tag: String, fieldHashes: Seq[String]): String = { - val parts = tag +: fieldHashes.size.toString +: fieldHashes - sha256Hex(parts.mkString("|")) - } - - def hashMintingAllowance(provider: String, amount: String): String = - hashList(Seq(hashText(provider), hashText(amount))) - - def hashBatchOfMintingAllowances(allowanceHashes: Seq[String]): String = - hashVariant("BatchOfMintingAllowances", Seq(hashList(allowanceHashes))) - - def hashBatchOfBatches(childHashes: Seq[String]): String = - hashVariant("BatchOfBatches", Seq(hashList(childHashes))) - - private def sha256Hex(s: String): String = { - val digest = MessageDigest.getInstance("SHA-256") - digest.digest(s.getBytes("UTF-8")).map("%02x".format(_)).mkString - } - } - - // -- Daml TestEngine ---------------------------------------------------------- - - private val darPath = "daml/splice-amulet-test/.daml/dist/splice-amulet-test-current.dar" - private val testEngine = new TestEngine(packagePaths = Seq(darPath)) - - private val packageId: String = { - val moduleName = Ref.ModuleName.assertFromString("Splice.Testing.CryptoHashProxy") - testEngine.packageStore.packages - .collectFirst { - case (pkgId, (_, pkg)) if pkg.modules.contains(moduleName) => pkgId - } - .getOrElse(sys.error(s"Could not find package containing $moduleName")) - .toString - } - - private val templateId = new Identifier( - packageId, - "Splice.Testing.CryptoHashProxy", - "CryptoHashProxy", - ) - - private val party = "alice" - private val createArgs = new DamlRecord( - java.util.List.of(new DamlRecord.Field("owner", new Party(party))) - ) - - private def exerciseDaml(op: HashOp): String = { - val (choiceName, choiceArg) = toDamlChoiceAndArg(op) - val cmd = new CreateAndExerciseCommand(templateId, createArgs, choiceName, choiceArg) - val (tx, _) = testEngine.submitAndConsume(cmd, party) - - tx.roots.toSeq - .map(nid => tx.nodes(nid)) - .collectFirst { case ex: Node.Exercise => ex } - .getOrElse(sys.error("No exercise node found in transaction")) - .exerciseResult - .collect { case LfValue.ValueText(text) => text } - .getOrElse(sys.error("Exercise result is not Text")) - } -} diff --git a/test-full-class-names-non-integration.log b/test-full-class-names-non-integration.log index c3ee052f2d..42ac6fa1e8 100644 --- a/test-full-class-names-non-integration.log +++ b/test-full-class-names-non-integration.log @@ -14,7 +14,6 @@ org.lfdecentralizedtrust.splice.scan.automation.AcsSnapshotTriggerTest org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTriggerTest org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfigTest org.lfdecentralizedtrust.splice.scan.rewards.RewardComputationInputsTest -org.lfdecentralizedtrust.splice.scan.store.CryptoHashEquivalenceTest org.lfdecentralizedtrust.splice.scan.store.DbAppActivityRecordStoreTest org.lfdecentralizedtrust.splice.scan.store.DbScanAppRewardsStoreTest org.lfdecentralizedtrust.splice.scan.store.ScanEventStoreTest From db323ad87d9a24b6dc6ba6da819f79954d11a359 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 06:37:51 +0000 Subject: [PATCH 10/17] [ci] Reverts 4b90a0ae5, those BUILD dependency changes are no longer needed Signed-off-by: Tim Emiola --- build.sbt | 1 - project/BuildCommon.scala | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/build.sbt b/build.sbt index cf416aecf6..3d78d54a71 100644 --- a/build.sbt +++ b/build.sbt @@ -1128,7 +1128,6 @@ lazy val `apps-scan` = .in(file("apps/scan")) .dependsOn( `apps-common` % "compile->compile;test->test", - `canton-ledger-api-core` % "test->test", `splice-dso-governance-daml`, ) .settings( diff --git a/project/BuildCommon.scala b/project/BuildCommon.scala index 4eaec04066..2a4cdab8b6 100644 --- a/project/BuildCommon.scala +++ b/project/BuildCommon.scala @@ -785,12 +785,11 @@ object BuildCommon { ) .settings( removeTestSources, - // We only need a few files out of a lot of test files so add them explicitly + // We only need 3 files out of a lot of test files so add them explicitly Test / managedSources := Seq( (Test / sourceDirectory).value / "scala/com/digitalasset/canton/HasActorSystem.scala", (Test / sourceDirectory).value / "scala/com/digitalasset/canton/store/db/DbTest.scala", (Test / sourceDirectory).value / "scala/com/digitalasset/canton/store/db/DbStorageIdempotency.scala", - (Test / sourceDirectory).value / "scala/com/digitalasset/canton/crypto/TestSalt.scala", ), disableTests, sharedCantonSettings, @@ -1266,10 +1265,6 @@ object BuildCommon { ) // to accommodate different daml repo coding style .settings( removeTestSources, - // Re-add TestEngine so Splice tests can run Daml code in-memory - Test / unmanagedSources := Seq( - (Test / sourceDirectory).value / "scala/com/digitalasset/canton/util/TestEngine.scala" - ), sharedCantonSettings, sharedSettings, scalacOptions += "-Wconf:src=src_managed/.*:silent", From c32cf530645e8a0d0da88d1dbc8c48449607e3af Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 07:23:34 +0000 Subject: [PATCH 11/17] [ci] Use withManualStart to start only backends whose reference the test uses Signed-off-by: Tim Emiola --- .../tests/CryptoHashEquivalenceIntegrationTest.scala | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index 9e27352827..7bc5d29692 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -39,9 +39,14 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1Sv(this.getClass.getSimpleName) + .withManualStart "Three-way CryptoHash equivalence (Daml, SQL, Scala)" should { "set up CryptoHashProxy" in { implicit env => + startAllSync( + sv1Backend, + sv1ScanBackend, + ) svParty = sv1Backend.getDsoInfo().svParty val (cid, _) = actAndCheck( From 93f190fd0e2d7f6d0ce530182a9800bd0f662b41 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 09:13:59 +0000 Subject: [PATCH 12/17] Replace actAndCheck with clue for synchronous checks Signed-off-by: Tim Emiola --- ...CryptoHashEquivalenceIntegrationTest.scala | 82 ++++++++----------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index 7bc5d29692..fcedace548 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -49,29 +49,23 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe ) svParty = sv1Backend.getDsoInfo().svParty - val (cid, _) = actAndCheck( - "Upload dar and create CryptoHashProxy", { - sv1Backend.participantClientWithAdminToken.dars.upload(darPath) - val createArgs = new DamlRecord( - java.util.List.of( - new DamlRecord.Field("owner", new Party(svParty.toProtoPrimitive)) - ) + clue("Upload dar and create CryptoHashProxy") { + sv1Backend.participantClientWithAdminToken.dars.upload(darPath) + val createArgs = new DamlRecord( + java.util.List.of( + new DamlRecord.Field("owner", new Party(svParty.toProtoPrimitive)) ) - val createTx = - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - actAs = Seq(svParty), - commands = Seq(new CreateCommand(templateId, createArgs)), - ) - createTx.getEvents.asScala - .collectFirst { case ce: CreatedEvent => ce.getContractId } - .getOrElse(fail("No CreatedEvent from CryptoHashProxy create")) - }, - )( - "CryptoHashProxy contract is created", - contractId => contractId should not be empty, - ) - proxyContractId = cid + ) + val createTx = + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(svParty), + commands = Seq(new CreateCommand(templateId, createArgs)), + ) + proxyContractId = createTx.getEvents.asScala + .collectFirst { case ce: CreatedEvent => ce.getContractId } + .getOrElse(fail("No CreatedEvent from CryptoHashProxy create")) + } scanDb = sv1ScanBackend.appState.storage match { case db: DbStorage => db case s => fail(s"non-DB storage configured: ${s.getClass}") @@ -84,32 +78,24 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe clue(s"Daml vs Scala for ${tc.description}") { val (choiceName, choiceArg) = toDamlChoiceAndArg(tc.op) - actAndCheck( - s"Exercise Daml ${tc.description}", { - val cmd = - new ExerciseCommand(templateId, proxyContractId, choiceName, choiceArg) - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - actAs = Seq(svParty), - commands = Seq(cmd), - ) - }, - )( - "Daml result matches Scala oracle", - tx => { - val damlHash = tx.getEventsById.values.asScala - .collectFirst { case ex: ExercisedEvent => - ex.getExerciseResult match { - case t: Text => t.getValue - case other => - fail(s"Expected Text result but got ${other.getClass.getSimpleName}") - } - } - .getOrElse(fail(s"No ExercisedEvent in transaction")) - - damlHash shouldBe scalaHash - }, - ) + val cmd = + new ExerciseCommand(templateId, proxyContractId, choiceName, choiceArg) + val tx = sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(svParty), + commands = Seq(cmd), + ) + val damlHash = tx.getEventsById.values.asScala + .collectFirst { case ex: ExercisedEvent => + ex.getExerciseResult match { + case t: Text => t.getValue + case other => + fail(s"Expected Text result but got ${other.getClass.getSimpleName}") + } + } + .getOrElse(fail(s"No ExercisedEvent in transaction")) + + damlHash shouldBe scalaHash } clue(s"SQL vs Scala for ${tc.description}") { From 4aa035bac4805da4e2b940066e77184d3aab36aa Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 09:15:54 +0000 Subject: [PATCH 13/17] [ci] Use update_dar_unless_exises, add NoDamlCompatibilityCheck tag Signed-off-by: Tim Emiola --- .../tests/CryptoHashEquivalenceIntegrationTest.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index fcedace548..26bb9754d8 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -32,6 +32,7 @@ import scala.jdk.CollectionConverters.* * Uses a shared environment so the dar is uploaded and proxy created once, * then each hash function gets its own test. */ +@org.lfdecentralizedtrust.splice.util.scalatesttags.NoDamlCompatibilityCheck class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTestUtil { import CryptoHashEquivalenceIntegrationTest.* @@ -50,7 +51,7 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe svParty = sv1Backend.getDsoInfo().svParty clue("Upload dar and create CryptoHashProxy") { - sv1Backend.participantClientWithAdminToken.dars.upload(darPath) + sv1Backend.participantClient.upload_dar_unless_exists(darPath) val createArgs = new DamlRecord( java.util.List.of( new DamlRecord.Field("owner", new Party(svParty.toProtoPrimitive)) From 76b618e88718b38c982466455ff874bf0d7bf677 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 09:44:33 +0000 Subject: [PATCH 14/17] Use withAdditionalSetup to upload the dar Signed-off-by: Tim Emiola --- .../tests/CryptoHashEquivalenceIntegrationTest.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index 26bb9754d8..c69f37e4cf 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -40,6 +40,9 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1Sv(this.getClass.getSimpleName) + .withAdditionalSetup { implicit env => + sv1Backend.participantClient.upload_dar_unless_exists(darPath) + } .withManualStart "Three-way CryptoHash equivalence (Daml, SQL, Scala)" should { @@ -50,8 +53,7 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe ) svParty = sv1Backend.getDsoInfo().svParty - clue("Upload dar and create CryptoHashProxy") { - sv1Backend.participantClient.upload_dar_unless_exists(darPath) + clue("Create CryptoHashProxy") { val createArgs = new DamlRecord( java.util.List.of( new DamlRecord.Field("owner", new Party(svParty.toProtoPrimitive)) From af9cfa98023def37f6447fa99fd724fd52a39abe Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Tue, 31 Mar 2026 09:50:23 +0000 Subject: [PATCH 15/17] [ci] Update the comment Signed-off-by: Tim Emiola --- .../tests/CryptoHashEquivalenceIntegrationTest.scala | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index c69f37e4cf..4fb2d44dfc 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -23,11 +23,10 @@ import java.io.File import java.security.MessageDigest import scala.jdk.CollectionConverters.* -/** Three-way integration test: Daml (real participant) == SQL (scan Postgres) == Scala oracle. +/** Three-way equivalence test: Daml (real participant) == SQL (scan Postgres) == Scala oracle. * - * This complements the store-level CryptoHashEquivalenceTest (which uses - * Canton's in-memory TestEngine) by validating against a real ledger and - * the actual scan database. + * Validates that the plpgsql hash functions (V065) produce identical results + * to the Daml CryptoHash module, using a Scala oracle as the common reference. * * Uses a shared environment so the dar is uploaded and proxy created once, * then each hash function gets its own test. From 38b43b5077aecf94e9a6ac8a8eabec6aea6526bc Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Thu, 2 Apr 2026 07:11:28 +0000 Subject: [PATCH 16/17] [ci] Validate plpgsql against daml, dropping the Scala oracle Signed-off-by: Tim Emiola --- ...CryptoHashEquivalenceIntegrationTest.scala | 262 +++++++++--------- 1 file changed, 131 insertions(+), 131 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index 4fb2d44dfc..2dbc812fa3 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -13,6 +13,7 @@ import com.daml.ledger.javaapi.data.{ Value, } import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.topology.PartyId import com.digitalasset.daml.lf.data.Ref import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest @@ -20,13 +21,13 @@ import org.lfdecentralizedtrust.splice.util.{DarUtil, WalletTestUtil} import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton import java.io.File -import java.security.MessageDigest +import scala.collection.mutable import scala.jdk.CollectionConverters.* -/** Three-way equivalence test: Daml (real participant) == SQL (scan Postgres) == Scala oracle. +/** Equivalence test: Daml (real participant) == SQL (scan Postgres). * - * Validates that the plpgsql hash functions (V065) produce identical results - * to the Daml CryptoHash module, using a Scala oracle as the common reference. + * Validates that the plpgsql hash functions produce identical results + * to the Daml CryptoHash module. Daml is the authority. * * Uses a shared environment so the dar is uploaded and proxy created once, * then each hash function gets its own test. @@ -44,8 +45,9 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe } .withManualStart - "Three-way CryptoHash equivalence (Daml, SQL, Scala)" should { - "set up CryptoHashProxy" in { implicit env => + "CryptoHash equivalence (Daml == SQL)" should { + + "set up environment" in { implicit env => startAllSync( sv1Backend, sv1ScanBackend, @@ -74,47 +76,58 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe } } - allTestCases.foreach { tc => - tc.description in { implicit env => - val scalaHash = toScalaExpected(tc.op) - - clue(s"Daml vs Scala for ${tc.description}") { - val (choiceName, choiceArg) = toDamlChoiceAndArg(tc.op) - val cmd = - new ExerciseCommand(templateId, proxyContractId, choiceName, choiceArg) - val tx = sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - actAs = Seq(svParty), - commands = Seq(cmd), - ) - val damlHash = tx.getEventsById.values.asScala - .collectFirst { case ex: ExercisedEvent => - ex.getExerciseResult match { - case t: Text => t.getValue - case other => - fail(s"Expected Text result but got ${other.getClass.getSimpleName}") - } - } - .getOrElse(fail(s"No ExercisedEvent in transaction")) - - damlHash shouldBe scalaHash - } - - clue(s"SQL vs Scala for ${tc.description}") { + allTestCaseDefs.foreach { tcDef => + tcDef.description in { implicit env => + val damlHash = clue("Daml") { exerciseDaml(tcDef.op) } + intermediates(tcDef.description) = damlHash + clue("SQL should match Daml") { val sqlHash = scanDb .query( - sql"""select #${toSqlExpr(tc.op)}""".as[String].head, - s"test.sqlHash.${tc.description}", + sql"""select #${toSqlExpr(tcDef.op, resolveRef)}""".as[String].head, + "test.sqlHash", ) .futureValueUS - - sqlHash shouldBe scalaHash + sqlHash shouldBe damlHash } } } } - private var svParty: com.digitalasset.canton.topology.PartyId = _ + /** Exercise a Daml choice and return the Text result. */ + private def exerciseDaml(op: HashOp)(implicit env: FixtureParam): String = { + val (choiceName, choiceArg) = toDamlChoiceAndArg(op, resolveRef) + val cmd = new ExerciseCommand(templateId, proxyContractId, choiceName, choiceArg) + val tx = sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(svParty), + commands = Seq(cmd), + ) + tx.getEventsById.values.asScala + .collectFirst { case ex: ExercisedEvent => + ex.getExerciseResult match { + case t: Text => t.getValue + case other => + throw new IllegalStateException( + s"Expected Text result but got ${other.getClass.getSimpleName}" + ) + } + } + .getOrElse(throw new IllegalStateException("No ExercisedEvent in transaction")) + } + + private def resolveRef(ref: HashRef): String = ref match { + case Lit(v) => v + case IntermediateRef(name) => + intermediates.getOrElse( + name, + throw new IllegalStateException(s"Intermediate '$name' not found"), + ) + } + + // Progressive map: each test stores its Daml result for later tests to reference + private val intermediates: mutable.Map[String, String] = mutable.Map.empty + + private var svParty: PartyId = _ private var proxyContractId: String = _ private var scanDb: DbStorage = _ } @@ -137,43 +150,23 @@ object CryptoHashEquivalenceIntegrationTest { private val templateId = new Identifier(packageId, moduleName, "CryptoHashProxy") - // -- Scala oracle (mirrors Daml CryptoHash module) -------------------------- - - private def sha256Hex(s: String): String = { - val digest = MessageDigest.getInstance("SHA-256") - digest.digest(s.getBytes("UTF-8")).map("%02x".format(_)).mkString - } - - private def hashText(s: String): String = sha256Hex(s) - - private def hashList(elems: Seq[String]): String = { - val parts = elems.size.toString +: elems - sha256Hex(parts.mkString("|")) - } - - private def hashVariant(tag: String, fieldHashes: Seq[String]): String = { - val parts = tag +: fieldHashes.size.toString +: fieldHashes - sha256Hex(parts.mkString("|")) - } - - private def hashMintingAllowance(provider: String, amount: String): String = - hashList(Seq(hashText(provider), hashText(amount))) + // -- HashRef: symbolic references to intermediate hash values --------------- - private def hashBatchOfMintingAllowances(allowanceHashes: Seq[String]): String = - hashVariant("BatchOfMintingAllowances", Seq(hashList(allowanceHashes))) + sealed trait HashRef + case class Lit(value: String) extends HashRef + case class IntermediateRef(name: String) extends HashRef - private def hashBatchOfBatches(childHashes: Seq[String]): String = - hashVariant("BatchOfBatches", Seq(hashList(childHashes))) - - // -- HashOp: structured description of what to hash ------------------------- + // -- HashOp: test case operations (may contain HashRef) --------------------- sealed trait HashOp case class HashText(value: String) extends HashOp - case class HashList(elems: Seq[String]) extends HashOp - case class HashVariant(tag: String, fields: Seq[String]) extends HashOp + case class HashList(elems: Seq[HashRef]) extends HashOp + case class HashVariant(tag: String, fields: Seq[HashRef]) extends HashOp case class HashMintingAllowance(provider: String, amount: String) extends HashOp - case class HashBatchOfMintingAllowances(allowanceHashes: Seq[String]) extends HashOp - case class HashBatchOfBatches(childHashes: Seq[String]) extends HashOp + case class HashBatchOfMintingAllowances(hashes: Seq[HashRef]) extends HashOp + case class HashBatchOfBatches(hashes: Seq[HashRef]) extends HashOp + + case class TestCaseDef(description: String, op: HashOp) // -- Derive Daml choice + arg from HashOp ---------------------------------- @@ -183,13 +176,16 @@ object CryptoHashEquivalenceIntegrationTest { private def record(fields: (String, Value)*): Value = new DamlRecord(fields.map { case (k, v) => new DamlRecord.Field(k, v) }.asJava) - def toDamlChoiceAndArg(op: HashOp): (String, Value) = op match { + def toDamlChoiceAndArg(op: HashOp, r: HashRef => String): (String, Value) = op match { case HashText(v) => ("CryptoHashProxy_HashText", record("input" -> text(v))) case HashList(elems) => - ("CryptoHashProxy_HashList", record("elems" -> textList(elems))) + ("CryptoHashProxy_HashList", record("elems" -> textList(elems.map(r)))) case HashVariant(tag, fields) => - ("CryptoHashProxy_HashVariant", record("tag" -> text(tag), "fields" -> textList(fields))) + ( + "CryptoHashProxy_HashVariant", + record("tag" -> text(tag), "fields" -> textList(fields.map(r))), + ) case HashMintingAllowance(provider, amount) => ( "CryptoHashProxy_HashMintingAllowance", @@ -198,10 +194,10 @@ object CryptoHashEquivalenceIntegrationTest { case HashBatchOfMintingAllowances(hashes) => ( "CryptoHashProxy_HashBatchOfMintingAllowances", - record("allowanceHashes" -> textList(hashes)), + record("allowanceHashes" -> textList(hashes.map(r))), ) case HashBatchOfBatches(hashes) => - ("CryptoHashProxy_HashBatchOfBatches", record("childHashes" -> textList(hashes))) + ("CryptoHashProxy_HashBatchOfBatches", record("childHashes" -> textList(hashes.map(r)))) } // -- Derive SQL expression from HashOp ------------------------------------- @@ -210,71 +206,75 @@ object CryptoHashEquivalenceIntegrationTest { private def sqlArray(elems: Seq[String]): String = s"ARRAY[${elems.map(e => s"'$e'").mkString(", ")}]::text[]" - def toSqlExpr(op: HashOp): String = op match { + def toSqlExpr(op: HashOp, r: HashRef => String): String = op match { case HashText(v) => s"daml_crypto_hash_text('${escapeSql(v)}')" case HashList(elems) => - s"daml_crypto_hash_list(${sqlArray(elems)})" + s"daml_crypto_hash_list(${sqlArray(elems.map(r))})" case HashVariant(tag, fields) => - s"daml_crypto_hash_variant('${escapeSql(tag)}', ${sqlArray(fields)})" + s"daml_crypto_hash_variant('${escapeSql(tag)}', ${sqlArray(fields.map(r))})" case HashMintingAllowance(provider, amount) => s"hash_minting_allowance('${escapeSql(provider)}', '${escapeSql(amount)}')" case HashBatchOfMintingAllowances(hashes) => - s"hash_batch_of_minting_allowances(${sqlArray(hashes)})" + s"hash_batch_of_minting_allowances(${sqlArray(hashes.map(r))})" case HashBatchOfBatches(hashes) => - s"hash_batch_of_batches(${sqlArray(hashes)})" + s"hash_batch_of_batches(${sqlArray(hashes.map(r))})" } - // -- Derive Scala expected from HashOp ------------------------------------- - - def toScalaExpected(op: HashOp): String = op match { - case HashText(v) => hashText(v) - case HashList(elems) => hashList(elems) - case HashVariant(tag, fields) => hashVariant(tag, fields) - case HashMintingAllowance(p, a) => hashMintingAllowance(p, a) - case HashBatchOfMintingAllowances(h) => hashBatchOfMintingAllowances(h) - case HashBatchOfBatches(h) => hashBatchOfBatches(h) - } - - // -- Test cases ------------------------------------------------------------ - - case class TestCase(description: String, op: HashOp) - - private val hAlice = hashText("alice::provider") - private val h10 = hashText("10.0") - private val hOnly = hashText("only") - private val h1 = hashText("1") - private val hx = hashText("x") - - val allTestCases: Seq[TestCase] = { - val maAlice = hashMintingAllowance("alice::provider", "10.0000000000") - val maBob = hashMintingAllowance("bob::provider", "0") - val leaf1 = hashBatchOfMintingAllowances( - Seq(hashMintingAllowance("alice::provider", "5.0")) - ) - val leaf2 = hashBatchOfMintingAllowances( - Seq(hashMintingAllowance("bob::provider", "3.0")) - ) - - Seq( - TestCase("hashText('hello')", HashText("hello")), - TestCase("hashText on empty string", HashText("")), - TestCase("hashList with two elements", HashList(Seq(hAlice, h10))), - TestCase("hashList on empty array", HashList(Seq.empty)), - TestCase("hashList on single element", HashList(Seq(hOnly))), - TestCase("hashVariant with tag and one field", HashVariant("TestTag", Seq(hAlice))), - TestCase( - "hash_minting_allowance(alice, 10.0)", - HashMintingAllowance("alice::provider", "10.0000000000"), - ), - TestCase("hash_minting_allowance(bob, 0)", HashMintingAllowance("bob::provider", "0")), - TestCase( - "hash_batch_of_minting_allowances with two", - HashBatchOfMintingAllowances(Seq(maAlice, maBob)), - ), - TestCase("hash_batch_of_batches with two leaves", HashBatchOfBatches(Seq(leaf1, leaf2))), - TestCase("hashRecord [hash 1, hash 'x']", HashList(Seq(h1, hx))), - TestCase("hashVariant 'V1' [hash 1, hash 'x']", HashVariant("V1", Seq(h1, hx))), - ) - } + // -- Test case definitions (fully static) ----------------------------------- + + // Test cases are defined statically using HashRef (Lit/IntermediateRef) to + // reference intermediate hashes by name. Each test resolves its refs from + // a shared map populated progressively as earlier tests run. + // + // Order matters: intermediates must precede composites that reference them. + val allTestCaseDefs: Seq[TestCaseDef] = Seq( + // Primitive text hashes — used as intermediates by composite cases + TestCaseDef("hAlice", HashText("alice::provider")), + TestCaseDef("h10", HashText("10.0")), + TestCaseDef("hOnly", HashText("only")), + TestCaseDef("h1", HashText("1")), + TestCaseDef("hx", HashText("x")), + + // Minting allowance hashes — used as intermediates by batch cases + TestCaseDef("maAlice", HashMintingAllowance("alice::provider", "10.0000000000")), + TestCaseDef("maBob", HashMintingAllowance("bob::provider", "0")), + TestCaseDef("maAlice5", HashMintingAllowance("alice::provider", "5.0")), + TestCaseDef("maBob3", HashMintingAllowance("bob::provider", "3.0")), + // Batch hashes — used as intermediates by batch-of-batches + TestCaseDef("leaf1", HashBatchOfMintingAllowances(Seq(IntermediateRef("maAlice5")))), + TestCaseDef("leaf2", HashBatchOfMintingAllowances(Seq(IntermediateRef("maBob3")))), + + // Independent cases (no intermediate references) + TestCaseDef("hash of 'hello'", HashText("hello")), + TestCaseDef("hash of empty string", HashText("")), + TestCaseDef("hashList on empty array", HashList(Seq.empty)), + + // Composite cases using intermediate hashes + TestCaseDef( + "hashList with two elements", + HashList(Seq(IntermediateRef("hAlice"), IntermediateRef("h10"))), + ), + TestCaseDef("hashList on single element", HashList(Seq(IntermediateRef("hOnly")))), + TestCaseDef( + "hashVariant with tag and one field", + HashVariant("TestTag", Seq(IntermediateRef("hAlice"))), + ), + TestCaseDef( + "hashRecord [hash 1, hash 'x']", + HashList(Seq(IntermediateRef("h1"), IntermediateRef("hx"))), + ), + TestCaseDef( + "hashVariant 'V1' [hash 1, hash 'x']", + HashVariant("V1", Seq(IntermediateRef("h1"), IntermediateRef("hx"))), + ), + TestCaseDef( + "hash_batch_of_minting_allowances with two", + HashBatchOfMintingAllowances(Seq(IntermediateRef("maAlice"), IntermediateRef("maBob"))), + ), + TestCaseDef( + "hash_batch_of_batches with two leaves", + HashBatchOfBatches(Seq(IntermediateRef("leaf1"), IntermediateRef("leaf2"))), + ), + ) } From fedee094335b853e1b52e25fa7647a8ce6b97a99 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Thu, 2 Apr 2026 07:52:53 +0000 Subject: [PATCH 17/17] [ci] Uses svApp db reference; move dar upload back into test setup Signed-off-by: Tim Emiola --- ...CryptoHashEquivalenceIntegrationTest.scala | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala index 2dbc812fa3..ccb86e5c7e 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala @@ -40,21 +40,17 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1Sv(this.getClass.getSimpleName) - .withAdditionalSetup { implicit env => - sv1Backend.participantClient.upload_dar_unless_exists(darPath) - } .withManualStart "CryptoHash equivalence (Daml == SQL)" should { "set up environment" in { implicit env => - startAllSync( - sv1Backend, - sv1ScanBackend, - ) + startAllSync(sv1Backend) svParty = sv1Backend.getDsoInfo().svParty + svDb = sv1Backend.appState.storage - clue("Create CryptoHashProxy") { + clue("Upload dar and create CryptoHashProxy") { + sv1Backend.participantClient.upload_dar_unless_exists(darPath) val createArgs = new DamlRecord( java.util.List.of( new DamlRecord.Field("owner", new Party(svParty.toProtoPrimitive)) @@ -70,10 +66,6 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe .collectFirst { case ce: CreatedEvent => ce.getContractId } .getOrElse(fail("No CreatedEvent from CryptoHashProxy create")) } - scanDb = sv1ScanBackend.appState.storage match { - case db: DbStorage => db - case s => fail(s"non-DB storage configured: ${s.getClass}") - } } allTestCaseDefs.foreach { tcDef => @@ -81,7 +73,7 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe val damlHash = clue("Daml") { exerciseDaml(tcDef.op) } intermediates(tcDef.description) = damlHash clue("SQL should match Daml") { - val sqlHash = scanDb + val sqlHash = svDb .query( sql"""select #${toSqlExpr(tcDef.op, resolveRef)}""".as[String].head, "test.sqlHash", @@ -129,7 +121,8 @@ class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTe private var svParty: PartyId = _ private var proxyContractId: String = _ - private var scanDb: DbStorage = _ + private var svDb: DbStorage = _ + } object CryptoHashEquivalenceIntegrationTest {