-
Notifications
You must be signed in to change notification settings - Fork 90
Add DB versions of Splice.Amulet.CryptoHash #4662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
adetokunbo
merged 17 commits into
adetokunbo/feature-cip-104-scan-computation
from
adetokunbo/cip-104-add-compute-hashes-db-hash-function
Apr 6, 2026
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
89bc60d
[ci] Add DB versions of Splice.Amulet.CryptoHash
adetokunbo fc90de1
[ci] Improve ppsql functions
adetokunbo 0382c17
Add Splice.Testing.CryptoHashProxy to splice-amulet-testing
adetokunbo 4b90a0a
Update build files to allow use of TestEngine in splice tests
adetokunbo d33ebf8
[ci] Replace DbCryptoHashFunctionsTest with CryptoHashEquivalenceTest
adetokunbo 112f172
[static] Update dars.lock
adetokunbo 8ec806d
Switched the TODO reference to distinct issue
adetokunbo d1356fe
Implement CryptoHashEquivalenceIntegrationTest
adetokunbo ca100a2
Remove the non-integration test
adetokunbo db323ad
[ci] Reverts 4b90a0ae5, those BUILD dependency changes are no longer …
adetokunbo c32cf53
[ci] Use withManualStart to start only backends whose reference the t…
adetokunbo 93f190f
Replace actAndCheck with clue for synchronous checks
adetokunbo 4aa035b
[ci] Use update_dar_unless_exises, add NoDamlCompatibilityCheck tag
adetokunbo 76b618e
Use withAdditionalSetup to upload the dar
adetokunbo af9cfa9
[ci] Update the comment
adetokunbo 38b43b5
[ci] Validate plpgsql against daml, dropping the Scala oracle
adetokunbo fedee09
[ci] Uses svApp db reference; move dar upload back into test setup
adetokunbo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
273 changes: 273 additions & 0 deletions
273
.../lfdecentralizedtrust/splice/integration/tests/CryptoHashEquivalenceIntegrationTest.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,273 @@ | ||
| 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.canton.topology.PartyId | ||
| 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 scala.collection.mutable | ||
| import scala.jdk.CollectionConverters.* | ||
|
|
||
| /** Equivalence test: Daml (real participant) == SQL (scan Postgres). | ||
| * | ||
| * 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. | ||
| */ | ||
| @org.lfdecentralizedtrust.splice.util.scalatesttags.NoDamlCompatibilityCheck | ||
| class CryptoHashEquivalenceIntegrationTest extends IntegrationTest with WalletTestUtil { | ||
|
|
||
| import CryptoHashEquivalenceIntegrationTest.* | ||
|
|
||
| override def environmentDefinition: SpliceEnvironmentDefinition = | ||
| EnvironmentDefinition | ||
| .simpleTopology1Sv(this.getClass.getSimpleName) | ||
|
adetokunbo marked this conversation as resolved.
|
||
| .withManualStart | ||
|
|
||
| "CryptoHash equivalence (Daml == SQL)" should { | ||
|
|
||
| "set up environment" in { implicit env => | ||
| startAllSync(sv1Backend) | ||
| svParty = sv1Backend.getDsoInfo().svParty | ||
| svDb = sv1Backend.appState.storage | ||
|
|
||
| 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)) | ||
| ) | ||
| ) | ||
| 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")) | ||
| } | ||
| } | ||
|
|
||
| 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 = svDb | ||
| .query( | ||
| sql"""select #${toSqlExpr(tcDef.op, resolveRef)}""".as[String].head, | ||
| "test.sqlHash", | ||
| ) | ||
| .futureValueUS | ||
| sqlHash shouldBe damlHash | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** 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 svDb: 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") | ||
|
|
||
| // -- HashRef: symbolic references to intermediate hash values --------------- | ||
|
|
||
| sealed trait HashRef | ||
| case class Lit(value: String) extends HashRef | ||
| case class IntermediateRef(name: String) extends HashRef | ||
|
|
||
| // -- HashOp: test case operations (may contain HashRef) --------------------- | ||
|
|
||
| sealed trait HashOp | ||
| case class HashText(value: 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(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 ---------------------------------- | ||
|
|
||
| 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, 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.map(r)))) | ||
| case HashVariant(tag, fields) => | ||
| ( | ||
| "CryptoHashProxy_HashVariant", | ||
| record("tag" -> text(tag), "fields" -> textList(fields.map(r))), | ||
| ) | ||
| case HashMintingAllowance(provider, amount) => | ||
| ( | ||
| "CryptoHashProxy_HashMintingAllowance", | ||
| record("provider" -> text(provider), "amount" -> text(amount)), | ||
| ) | ||
| case HashBatchOfMintingAllowances(hashes) => | ||
| ( | ||
| "CryptoHashProxy_HashBatchOfMintingAllowances", | ||
| record("allowanceHashes" -> textList(hashes.map(r))), | ||
| ) | ||
| case HashBatchOfBatches(hashes) => | ||
| ("CryptoHashProxy_HashBatchOfBatches", record("childHashes" -> textList(hashes.map(r)))) | ||
| } | ||
|
|
||
| // -- 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, 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.map(r))})" | ||
| case HashVariant(tag, 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.map(r))})" | ||
| case HashBatchOfBatches(hashes) => | ||
| s"hash_batch_of_batches(${sqlArray(hashes.map(r))})" | ||
| } | ||
|
|
||
| // -- 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"))), | ||
| ), | ||
| ) | ||
| } | ||
72 changes: 72 additions & 0 deletions
72
...resources/db/migration/canton-network/postgres/stable/V065__app_reward_hash_functions.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| -- V065: plpgsql functions for Merkle tree hash computation over app reward batches. | ||
| -- 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'). | ||
|
|
||
| -- ============================================================================ | ||
| -- 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 daml_crypto_hash_text(s text) RETURNS text | ||
| RETURNS NULL ON NULL INPUT | ||
| AS $$ SELECT encode(extensions.digest(s, 'sha256'), 'hex') $$ | ||
| LANGUAGE sql IMMUTABLE PARALLEL SAFE; | ||
|
|
||
| -- Hash a list of already-hashed elements. | ||
| -- Matches: hashListInternal ts = sha256(intercalate "|" (show(length ts) :: ts)) | ||
| CREATE FUNCTION daml_crypto_hash_list(elems text[]) RETURNS text | ||
| RETURNS NULL ON NULL INPUT | ||
| AS $$ | ||
| SELECT encode(extensions.digest( | ||
| array_to_string( | ||
| ARRAY[cardinality(elems)::text] || elems, | ||
| '|' | ||
| ), | ||
| 'sha256' | ||
| ), 'hex') | ||
| $$ 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 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, cardinality(fields)::text] || fields, | ||
| '|' | ||
| ), | ||
| 'sha256' | ||
| ), 'hex') | ||
| $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; | ||
|
|
||
|
|
||
| -- ============================================================================ | ||
| -- 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 | ||
| 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 | ||
| 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 | ||
| RETURNS NULL ON NULL INPUT | ||
| AS $$ SELECT daml_crypto_hash_variant('BatchOfBatches', ARRAY[daml_crypto_hash_list(child_hashes)]) $$ | ||
| LANGUAGE sql IMMUTABLE PARALLEL SAFE; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.