Skip to content

Commit

Permalink
LF: fix contract ID freshness check (#9370)
Browse files Browse the repository at this point in the history
Local contract IDs are collected in exercise children during preprocessing transaction for replay.

CHANGELOG_BEGIN
- [Engine] Fix contract ID freshness check when validating transaction
CHANGELOG_END
  • Loading branch information
remyhaemmerle-da committed Apr 13, 2021
1 parent 1627b70 commit 2dc09ba
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,7 @@ private[engine] final class Preprocessor(compiledPackages: MutableCompiledPackag
node: Node.GenNode[NodeId, Cid]
): Result[(speedy.Command, Set[Value.ContractId])] =
safelyRun(getDependencies(List.empty, List(node.templateId))) {
val (cmd, (globalCids, _)) = unsafeTranslateNode((Set.empty, Set.empty), node)
cmd -> globalCids
unsafeTranslateNode(node)
}

def translateTransactionRoots[Cid <: Value.ContractId](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,95 +28,143 @@ private[preprocessing] final class TransactionPreprocessor(
// Translate a GenNode into an expression re-interpretable by the interpreter
@throws[PreprocessorException]
def unsafeTranslateNode[Cid <: Value.ContractId](
acc: (Set[Value.ContractId], Set[Value.ContractId]),
node: Node.GenNode[NodeId, Cid],
): (speedy.Command, (Set[Value.ContractId], Set[Value.ContractId])) = {

val (localCids, globalCids) = acc
node: Node.GenNode[NodeId, Cid]
): (speedy.Command, Set[Value.ContractId]) = {

node match {
case _: Node.NodeRollback[_] =>
// TODO https://github.com/digital-asset/daml/issues/8020
// how on earth can we turn a rollback node back into a speedy command?
sys.error("rollback nodes are not supported")
case Node.NodeCreate(
coid @ _,
templateId,
arg,
agreementText @ _,
optLoc @ _,
sigs @ _,
stks @ _,
key @ _,
version @ _,
) =>
if (globalCids(coid))
fail("Conflicting discriminators between a global and local contract ID.")

val (cmd, newCids) =
commandPreprocessor.unsafePreprocessCreate(templateId, arg)
val newGlobalCids = globalCids + coid
val newLocalCids = localCids | newCids.filterNot(globalCids)
cmd -> (newLocalCids -> newGlobalCids)

case Node.NodeExercises(
coid,
template,
choice,
optLoc @ _,
consuming @ _,
actingParties @ _,
chosenVal,
stakeholders @ _,
signatories @ _,
choiceObservers @ _,
children @ _,
exerciseResult @ _,
key @ _,
byKey @ _,
version @ _,
) =>
val templateId = template
val (cmd, newCids) =
commandPreprocessor.unsafePreprocessExercise(templateId, coid, choice, chosenVal)
(cmd, (localCids | newCids.filterNot(globalCids), globalCids))
case Node.NodeFetch(coid, templateId, _, _, _, _, _, _, _) =>
val cmd = commandPreprocessor.unsafePreprocessFetch(templateId, coid)
(cmd, acc)
case Node.NodeLookupByKey(templateId, _, key, _, _) =>
val keyValue = unsafeAsValueWithNoContractIds(key.key)
val cmd = commandPreprocessor.unsafePreprocessLookupByKey(templateId, keyValue)
(cmd, acc)
case create: Node.NodeCreate[Cid] =>
commandPreprocessor.unsafePreprocessCreate(create.templateId, create.arg)

case exe: Node.NodeExercises[_, Cid] =>
commandPreprocessor.unsafePreprocessExercise(
exe.templateId,
exe.targetCoid,
exe.choiceId,
exe.chosenValue,
)
case fetch: Node.NodeFetch[Cid] =>
val cmd = commandPreprocessor.unsafePreprocessFetch(fetch.templateId, fetch.coid)
(cmd, Set.empty)
case lookup: Node.NodeLookupByKey[Cid] =>
val keyValue = unsafeAsValueWithNoContractIds(lookup.key.key)
val cmd = commandPreprocessor.unsafePreprocessLookupByKey(lookup.templateId, keyValue)
(cmd, Set.empty)
}
}

// Accumulator used by unsafeTranslateTransactionRoots method.
private[this] case class Acc(
globalCids: Set[ContractId],
localCids: Set[ContractId],
commands: BackStack[speedy.Command],
) {
def update(
newInputCids: Iterable[ContractId],
newLocalCids: Iterable[ContractId],
cmd: speedy.Command,
) = Acc(
globalCids ++ newInputCids.filterNot(localCids),
localCids ++ newLocalCids,
commands :+ cmd,
)
}

/*
* Translates a transaction tree into a sequence of Speedy commands
* and collects the global contract IDs.
* A contract ID `cid` is considered *local* w.r.t. a node `n`, if
* either:
* - it is local in any node appearing previously (w.r.t. traversal
* order) in the transaction, or
* - `n` is a create node such that `n.coid == cid`
*
* A contract ID `cid` is considered *global* in a root node `n`,
* if:
* - `cid` is not considered local w.r.t. `n`, and
* - if `cid` is reference in the input fields of a `n`, i.e. :
* - `n` is a create node and `cid` appears in the payload of the
* create contract (`n.arg`), or
* - `n` is an exercise node and `cid` is the ID of the exercise
* contract (`n.targetCoid`), or
* - `n` is an exercise node and `cid` appears in the exercise
* argument (`n.choosenValue`).
*
* A contract ID is considered *global* w.r.t. a transaction `tx` if
* it is global w.r.t. one of the roots of `tx`.
*
* Note that it is, in general, not possible to recover from a
* transaction, the original sequence of commands that generated this
* transaction. In particular:
* - we cannot distinguish a exercise performed "by ID" from an
* exercise performed "by key" (as of LF v1.13).
* - we cannot distinguish a createAndExercise from a create
* followed by an exercise.
*
* Consequently the sequence of commands and the set of global
* contract IDs generated by this method may be different from the
* original sequence of commands. In particular:
* - all exercises are translated into exercise by ID.
* - a cid is not considered global if there exists a create node
* within the transaction that creates a contract with the same ID.
*
* Under the assumption that the underlying ledger guarantees the
* uniqueness of all contract IDs (including transient contracts),
* the reinterpretation of the generated transaction will succeed
* iff the original submission was valid and succeeded.
*
* See review comments in https://github.com/digital-asset/daml/pull/9370
* for more details.
*/
@throws[PreprocessorException]
def unsafeTranslateTransactionRoots[Cid <: Value.ContractId](
tx: GenTransaction[NodeId, Cid]
): (ImmArray[speedy.Command], Set[ContractId]) = {

type Acc = ((Set[Value.ContractId], Set[Value.ContractId]), BackStack[speedy.Command])

val ((localCids, _), cmds) =
tx.roots.foldLeft[Acc](((Set.empty, Set.empty), BackStack.empty)) {
case ((cids, stack), id) =>
tx.nodes.get(id) match {
case None =>
fail(s"invalid transaction, root refers to non-existing node $id")
case Some(node) =>
node match {
case _: Node.NodeFetch[_] =>
fail(s"Transaction contains a fetch root node $id")
case _: Node.NodeLookupByKey[_] =>
fail(s"Transaction contains a lookup by key root node $id")
case _ =>
val (cmd, acc) = unsafeTranslateNode(cids, node)
(acc, stack :+ cmd)
}
val result = tx.roots.foldLeft(Acc(Set.empty, Set.empty, BackStack.empty)) { (acc, id) =>
tx.nodes.get(id) match {
case None =>
fail(s"invalid transaction, root refers to non-existing node $id")
case Some(node) =>
node match {
case create: Node.NodeCreate[Cid] =>
val (cmd, newCids) =
commandPreprocessor.unsafePreprocessCreate(create.templateId, create.arg)
acc.update(newCids, List(create.coid), cmd)
case exe: Node.NodeExercises[_, Cid] =>
val (cmd, newCids) = commandPreprocessor.unsafePreprocessExercise(
exe.templateId,
exe.targetCoid,
exe.choiceId,
exe.chosenValue,
)
val newLocalCids = GenTransaction(tx.nodes, ImmArray(id)).localContracts.keys
acc.update(newCids, newLocalCids, cmd)
case _: Node.NodeFetch[_] =>
fail(s"Transaction contains a fetch root node $id")
case _: Node.NodeLookupByKey[_] =>
fail(s"Transaction contains a lookup by key root node $id")
case _: Node.NodeRollback[_] =>
// TODO https://github.com/digital-asset/daml/issues/8020
// how on earth can we turn a rollback node back into a speedy command?
sys.error("rollback nodes are not supported")
}
}
}

// The following check ensures that `localCids ∩ globalCids = ∅`.
// It is probably not 100% necessary, as the reinterpretation should catch the cases where it is not true.
// We still prefer to perform the check here as:
// - it is cheap,
// - it catches obviously buggy transaction,
// - it is easier to reason about "soundness" of preprocessing under the disjointness assumption.
if (result.localCids exists result.globalCids)
fail("Conflicting discriminators between a global and local contract ID.")

cmds.toImmArray -> localCids
result.commands.toImmArray -> result.globalCids
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
// SPDX-License-Identifier: Apache-2.0

package com.daml.lf
package engine

import com.daml.lf.data._
import com.daml.lf.engine.Engine
import com.daml.lf.testing.parser.Implicits._
import com.daml.lf.transaction.GlobalKey
import com.daml.lf.transaction.{GenTransaction, GlobalKey, Node, NodeId}
import com.daml.lf.transaction.test.TransactionBuilder
import com.daml.lf.value.Value.ContractId
import com.daml.lf.value.Value
import com.daml.lf.value.Value.ContractId
import org.scalatest.Inside.inside
import org.scalatest.prop.TableDrivenPropertyChecks
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
Expand Down Expand Up @@ -52,12 +53,21 @@ class ContractDiscriminatorFreshnessCheckSpec
Mod:contractParties this
to
upure @Unit (),
choice @nonConsuming LookupByKey (self) (key: Mod:Key) : Option (ContractId Mod:Contract),
controllers
Mod:contractParties this
to
lookup_by_key @Mod:Contract key
choice @nonConsuming Identity (self) (cid: ContractId Mod:Contract) : ContractId Mod:Contract,
controllers
Mod:contractParties this
to
upure @(ContractId Mod:Contract) cid,
choice @nonConsuming LookupByKey (self) (key: Mod:Key) : Option (ContractId Mod:Contract),
controllers
Mod:contractParties this
to
lookup_by_key @Mod:Contract key,
choice @nonConsuming Create (self) (contract: Mod:Contract): (ContractId Mod:Contract),
controllers
Mod:contractParties this
to
create @Mod:Contract contract
},
key @Mod:Key (Mod:Contract {key} this) Mod:keyParties
};
Expand Down Expand Up @@ -210,7 +220,7 @@ class ContractDiscriminatorFreshnessCheckSpec

}

"fails when a local conflicts with a local contract previously fetched" in {
"fails when a local conflicts with a global contract previously fetched" in {

val conflictingCid = {
val createNodeSeed = crypto.Hash.deriveNodeSeed(transactionSeed, 1)
Expand Down Expand Up @@ -281,6 +291,74 @@ class ContractDiscriminatorFreshnessCheckSpec

}

"fail when preprocessing a transaction when a cid appear before before being created" in {

val cid0: ContractId = ContractId.V1(crypto.Hash.hashPrivateKey("test"), Bytes.Empty)

val Right((tx, _)) = submit(
ImmArray(
command.CreateAndExerciseCommand(
tmplId,
contractRecord(alice, 1, List.empty),
"Identity",
Value.ValueContractId(cid0),
),
command.CreateCommand(tmplId, contractRecord(alice, 2, List.empty)),
),
pcs = (cid => if (cid == cid0) Some(contractInstance(alice, 0, List.empty)) else None),
keys = _ => None,
)

val lastCreatedCid = tx.fold(cid0) {
case (_, (_, create: Node.NodeCreate[ContractId])) => create.coid
case (acc, _) => acc
}

assert(lastCreatedCid != cid0)

val newNodes = tx.fold(tx.nodes) {
case (nodes, (nid, exe: Node.NodeExercises[NodeId, ContractId]))
if exe.choiceId == "Identity" =>
nodes.updated(nid, exe.copy(chosenValue = Value.ValueContractId(lastCreatedCid)))
case (acc, _) => acc
}

val result =
new preprocessing.Preprocessor(ConcurrentCompiledPackages(speedy.Compiler.Config.Dev))
.translateTransactionRoots(GenTransaction(newNodes, tx.roots))
.consume(_ => None, pkgs, _ => None)

inside(result) { case Left(err) =>
err.msg should include("Conflicting discriminators")
}

}

"does not fail when replaying an exercise by key of a non-root local Contract" in {
// regression test for https://discuss.daml.com/t/ledger-api-error-message-conflicting-discriminators-between-a-global-and-local-contract-id/2416/3
val Right((tx, txMeta)) = submit(
ImmArray(
command.CreateAndExerciseCommand(
tmplId,
contractRecord(alice, 0, List.empty),
"Create",
contractRecord(alice, 1, List.empty),
),
command.ExerciseByKeyCommand(tmplId, keyRecord(alice, 1), "Noop", Value.ValueUnit),
),
pcs = _ => None,
keys = _ => None,
)
engine.replay(
submitters = Set(alice),
tx,
ledgerEffectiveTime = txMeta.submissionTime,
participantId = participant,
submissionTime = txMeta.submissionTime,
submissionSeed = txMeta.submissionSeed.get,
) shouldBe a[ResultDone[_]]
}

}

private implicit def toName(s: String): Ref.Name = Ref.Name.assertFromString(s)
Expand Down
23 changes: 12 additions & 11 deletions daml-lf/spec/contract-id.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,19 @@ Contract ID uniqueness
----------------------

During interpretation local contract IDs are created without suffix.
Ledger implementations are responsible for enforcing uniqueness of
contract IDs on the whole ledger. This can be done by enforcing
global uniqueness of the seeds or by appropriately suffixing the
contract IDs. No other requirement (except the 94 bytes size limit)
is assumed for those suffices.

The simplest approach consists to suffix all local contract ID with a
Ledger implementations are responsible for enforcing global uniqueness
of all the contract IDs referenced by the ledger, including IDs of
transient contract. This can be done by enforcing global uniqueness of
the seeds or by appropriately suffixing the contract IDs. No other
requirement (except the 94 bytes size limit) is assumed for those
suffixes.

The simplest approach consists to suffix all local contract IDs with a
uniquely global transaction ID. Alternatively central committer ledger
can completely avoid suffixing by enforcing that same pair (submission
seed, submission time) is never used more that once, the discriminator
allocation scheme ensuring in this case the uniqueness of allocated
discriminators.
can completely avoid suffixing by enforcing that the pair (submission
seed, submission time) is not used by two different submission, as the
discriminator allocation scheme ensures in this case the uniqueness of
allocated discriminators.


Submission time
Expand Down

0 comments on commit 2dc09ba

Please sign in to comment.