Decentralized party overview
Decentralized party overview
A decentralized party combines three different features:
Decentralization of topology management of the party: A decentralized namespace to ensure that any topology transaction for that party requires signatures from a threshold of keys.
Decentralization of transaction confirmations for the party: A party to participant containing multiple confirming participants and a threshold to ensure that transactions requiring confirmations from that party also require confirmation from a threshold of participant nodes.
Decentralization of transaction submissions for the party: Optionally, protocol signing keys in the party to participant to support submitting transactions that require direct authorization of the external party, for example creating a contract that the party is a signatory on by signing the prepared transaction with a threshold of keys. If no party-to-key mapping is defined, then the initial contracts need to be created when the party to participant threshold is 1 (if this was ever the case), and a node has submission rights, not just confirmation rights.
Setup a decentralized party
While the decentralized namespace and the party to participant mapping can be configured fully independently, a common scenario is that a set of entities jointly control both i.e. both have the same number of members and the same threshold. The instructions here describe that setup with the three members being alice, bob, and charlie, who use participant1, participant2, and participant3 respectively.
First generate the keys used for the decentralized namespace:
@ val aliceNamespaceKey = participant1.keys.secret.generate_signing_key("decentralized-party-namespace", SigningKeyUsage.NamespaceOnly)
aliceNamespaceKey : SigningPublicKey = SigningPublicKey(
id = 12208e8eb8c2...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = namespace
)
@ val bobNamespaceKey = participant2.keys.secret.generate_signing_key("decentralized-party-namespace", SigningKeyUsage.NamespaceOnly)
bobNamespaceKey : SigningPublicKey = SigningPublicKey(
id = 1220d612c995...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = namespace
)
@ val charlieNamespaceKey = participant3.keys.secret.generate_signing_key("decentralized-party-namespace", SigningKeyUsage.NamespaceOnly)
charlieNamespaceKey : SigningPublicKey = SigningPublicKey(
id = 1220b0eedd02...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = namespace
)
@ val aliceNamespace = Namespace(aliceNamespaceKey.fingerprint)
aliceNamespace : Namespace = 12208e8eb8c2...
@ val bobNamespace = Namespace(bobNamespaceKey.fingerprint)
bobNamespace : Namespace = 1220d612c995...
@ val charlieNamespace = Namespace(charlieNamespaceKey.fingerprint)
charlieNamespace : Namespace = 1220b0eedd02...
Next, each node publishes the namespace delegation for that key to the synchronizer. This makes the key known to all nodes connected to the synchronizer and allows it to be used in later transactions:
@ val synchronizerId = participant1.synchronizers.id_of(com.digitalasset.canton.SynchronizerAlias.tryCreate("global"))
synchronizerId : SynchronizerId = global::1220f622b718...
.. code-block:: none
@ participant1.topology.namespace_delegations.propose_delegation(aliceNamespace, aliceNamespaceKey, DelegationRestriction.CanSignAllMappings, store = synchronizerId)
res8: SignedTopologyTransaction[TopologyChangeOp, NamespaceDelegation] = SignedTopologyTransaction(
TopologyTransaction(
NamespaceDelegation(
namespace = 12208e8eb8c2...,
target = SigningPublicKey(
id = 12208e8eb8c2...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = namespace
),
restriction = none
),
serial = 1,
operation = Replace,
hash = SHA-256:0e9d4a598038...
),
signatures = 12208e8eb8c2...
)
.. code-block:: none
@ participant2.topology.namespace_delegations.propose_delegation(bobNamespace, bobNamespaceKey, DelegationRestriction.CanSignAllMappings, store = synchronizerId)
res9: SignedTopologyTransaction[TopologyChangeOp, NamespaceDelegation] = SignedTopologyTransaction(
TopologyTransaction(
NamespaceDelegation(
namespace = 1220d612c995...,
target = SigningPublicKey(
id = 1220d612c995...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = namespace
),
restriction = none
),
serial = 1,
operation = Replace,
hash = SHA-256:6e072d36ca67...
),
signatures = 1220d612c995...
)
.. code-block:: none
@ participant3.topology.namespace_delegations.propose_delegation(charlieNamespace, charlieNamespaceKey, DelegationRestriction.CanSignAllMappings, store = synchronizerId)
res10: SignedTopologyTransaction[TopologyChangeOp, NamespaceDelegation] = SignedTopologyTransaction(
TopologyTransaction(
NamespaceDelegation(
namespace = 1220b0eedd02...,
target = SigningPublicKey(
id = 1220b0eedd02...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = namespace
),
restriction = none
),
serial = 1,
operation = Replace,
hash = SHA-256:077245ba097b...
),
signatures = 1220b0eedd02...
)
Once the namespace delegations are published, you can create the
decentralized namespace definition. For this, each node needs to sign
and publish the same topology transaction to the synchronizer. They
also need to choose a threshold, which determines how many
signatures from the owners of the decentralized namespace are required for
a topology transaction to be authorized on behalf of the decentralized namespace.
This example uses a threshold of two. Note that the threshold does
not apply to the initial transaction that establishes the
decentralized namespace. For that, signatures from all owners are
required, not just a threshold. Once all nodes publish their signed
transaction, the decentralized namespace transaction shows up in the list
command:
@ val namespaceDef = DecentralizedNamespaceDefinition.tryCreate(DecentralizedNamespaceDefinition.computeNamespace(Set(aliceNamespace, bobNamespace, charlieNamespace)), PositiveInt.tryCreate(2), com.daml.nonempty.NonEmpty(Set, aliceNamespace, bobNamespace, charlieNamespace))
namespaceDef : DecentralizedNamespaceDefinition = DecentralizedNamespaceDefinition(
namespace = 12201744eafb...,
threshold = 2,
owners = Seq(12208e8eb8c2..., 1220b0eedd02..., 1220d612c995...)
)
.. code-block:: none
@ participant1.topology.decentralized_namespaces.propose(namespaceDef, store = synchronizerId)
res12: SignedTopologyTransaction[TopologyChangeOp, DecentralizedNamespaceDefinition] = SignedTopologyTransaction(
TopologyTransaction(
DecentralizedNamespaceDefinition(
namespace = 12201744eafb...,
threshold = 2,
owners = Seq(12208e8eb8c2..., 1220b0eedd02..., 1220d612c995...)
),
serial = 1,
operation = Replace,
hash = SHA-256:ffc68e550bed...
),
signatures = 12208e8eb8c2...,
proposal
)
.. code-block:: none
@ participant2.topology.decentralized_namespaces.propose(namespaceDef, store = synchronizerId)
res13: SignedTopologyTransaction[TopologyChangeOp, DecentralizedNamespaceDefinition] = SignedTopologyTransaction(
TopologyTransaction(
DecentralizedNamespaceDefinition(
namespace = 12201744eafb...,
threshold = 2,
owners = Seq(12208e8eb8c2..., 1220b0eedd02..., 1220d612c995...)
),
serial = 1,
operation = Replace,
hash = SHA-256:ffc68e550bed...
),
signatures = 1220d612c995...,
proposal
)
.. code-block:: none
@ participant3.topology.decentralized_namespaces.propose(namespaceDef, store = synchronizerId)
res14: SignedTopologyTransaction[TopologyChangeOp, DecentralizedNamespaceDefinition] = SignedTopologyTransaction(
TopologyTransaction(
DecentralizedNamespaceDefinition(
namespace = 12201744eafb...,
threshold = 2,
owners = Seq(12208e8eb8c2..., 1220b0eedd02..., 1220d612c995...)
),
serial = 1,
operation = Replace,
hash = SHA-256:ffc68e550bed...
),
signatures = 1220b0eedd02...,
proposal
)
.. code-block:: none
@ utils.retry_until_true(participant1.topology.decentralized_namespaces.list(synchronizerId, filterNamespace = namespaceDef.namespace.filterString).nonEmpty)
The next step is to set up the PartyToParticipant mapping. For
this, you need to chose a prefix for the party. The full party ID is
then prefix::namespace. This example uses
decentralized-party as the prefix. You also need to specify the
list of participants that should host that party, the permissions
(this should be Confirmation for all nodes participating in
consensus for that party, but you may have additional read-only nodes
with Observation permissions), and a threshold. The threshold
determines how many confirmations are required for the decentralized
party. This example uses the same threshold of two used
for the decentralized namespace. As for the decentralized
namespace, each node independently publishes the transaction.
Optionally, protocol signing keys can be added to the PartyToParticipant.
This allows submitting transactions directly as the decentralized party
through aggregating signatures offline. It is possible to reuse the
same keys here that are used for the decentralized namespace (provided
you change the SigningKeyUsage to be less restrictive) but we use
separate keys here:
@ val aliceDamlKey = participant1.keys.secret.generate_signing_key("decentralized-party-daml-transactions", SigningKeyUsage.ProtocolOnly)
aliceDamlKey : SigningPublicKey = SigningPublicKey(
id = 122009b04740...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = Set(signing, proof-of-ownership)
)
.. code-block:: none
@ val bobDamlKey = participant2.keys.secret.generate_signing_key("decentralized-party-daml-transactions", SigningKeyUsage.ProtocolOnly)
bobDamlKey : SigningPublicKey = SigningPublicKey(
id = 12200cfef275...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = Set(signing, proof-of-ownership)
)
.. code-block:: none
@ val charlieDamlKey = participant3.keys.secret.generate_signing_key("decentralized-party-daml-transactions", SigningKeyUsage.ProtocolOnly)
charlieDamlKey : SigningPublicKey = SigningPublicKey(
id = 12204ba7ca82...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = Set(signing, proof-of-ownership)
)
.. code-block:: none
@ val partySigningKeysWithThreshold = Some(SigningKeysWithThreshold(com.daml.nonempty.NonEmpty(Set, aliceDamlKey, bobDamlKey, charlieDamlKey), PositiveInt.tryCreate(2)))
partySigningKeysWithThreshold : Some[SigningKeysWithThreshold] = Some(
value = SigningKeysWithThreshold(
keys = Set(
SigningPublicKey(
id = 122009b04740...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = Set(signing, proof-of-ownership)
),
SigningPublicKey(
id = 12200cfef275...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = Set(signing, proof-of-ownership)
),
SigningPublicKey(
id = 12204ba7ca82...,
format = DER-encoded X.509 SubjectPublicKeyInfo,
keySpec = EC-Curve25519,
usage = Set(signing, proof-of-ownership)
)
),
threshold = PositiveNumeric(value = 2)
)
)
Once all of them publish their transactions it becomes valid and shows up in list:
@ val partyId = PartyId(UniqueIdentifier.tryCreate("decentralized-party", namespaceDef.namespace))
partyId : PartyId = decentralized-party::12201744eafb...
.. code-block:: none
@ participant1.topology.party_to_participant_mappings.propose(partyId, Seq((participant1, ParticipantPermission.Confirmation), (participant2, ParticipantPermission.Confirmation), (participant3, ParticipantPermission.Confirmation)), PositiveInt.tryCreate(2), partySigningKeys = partySigningKeysWithThreshold, store = synchronizerId)
res21: SignedTopologyTransaction[TopologyChangeOp, PartyToParticipant] = SignedTopologyTransaction(
TopologyTransaction(
PartyToParticipant(
partyId = decentralized-party::12201744eafb...,
threshold = 2,
participants = Map(
PAR::participant1::12201ff69b1d... -> Confirmation,
PAR::participant2::1220a4d7463b... -> Confirmation,
PAR::participant3::1220d6908163... -> Confirmation
)
),
serial = 1,
operation = Replace,
hash = SHA-256:6957a4c2b83e...
),
signatures = Seq(122009b04740..., 12201ff69b1d..., 12208e8eb8c2...),
proposal
)
.. code-block:: none
@ participant2.topology.party_to_participant_mappings.propose(partyId, Seq((participant1, ParticipantPermission.Confirmation), (participant2, ParticipantPermission.Confirmation), (participant3, ParticipantPermission.Confirmation)), PositiveInt.tryCreate(2), partySigningKeys = partySigningKeysWithThreshold, store = synchronizerId)
res22: SignedTopologyTransaction[TopologyChangeOp, PartyToParticipant] = SignedTopologyTransaction(
TopologyTransaction(
PartyToParticipant(
partyId = decentralized-party::12201744eafb...,
threshold = 2,
participants = Map(
PAR::participant1::12201ff69b1d... -> Confirmation,
PAR::participant2::1220a4d7463b... -> Confirmation,
PAR::participant3::1220d6908163... -> Confirmation
)
),
serial = 1,
operation = Replace,
hash = SHA-256:6957a4c2b83e...
),
signatures = Seq(12200cfef275..., 1220a4d7463b..., 1220d612c995...),
proposal
)
.. code-block:: none
@ participant3.topology.party_to_participant_mappings.propose(partyId, Seq((participant1, ParticipantPermission.Confirmation), (participant2, ParticipantPermission.Confirmation), (participant3, ParticipantPermission.Confirmation)), PositiveInt.tryCreate(2), partySigningKeys = partySigningKeysWithThreshold, store = synchronizerId)
res23: SignedTopologyTransaction[TopologyChangeOp, PartyToParticipant] = SignedTopologyTransaction(
TopologyTransaction(
PartyToParticipant(
partyId = decentralized-party::12201744eafb...,
threshold = 2,
participants = Map(
PAR::participant1::12201ff69b1d... -> Confirmation,
PAR::participant2::1220a4d7463b... -> Confirmation,
PAR::participant3::1220d6908163... -> Confirmation
)
),
serial = 1,
operation = Replace,
hash = SHA-256:6957a4c2b83e...
),
signatures = Seq(12204ba7ca82..., 1220b0eedd02..., 1220d6908163...),
proposal
)
.. code-block:: none
@ utils.retry_until_true(participant3.topology.party_to_participant_mappings.list(synchronizerId, filterParty = partyId.filterString).nonEmpty)
With that, the party is fully set up and can be used.
Changing the set of members
To add and remove members, the steps are the same: There is a threshold of the existing members and any new members must submit the three topology transactions. It is also possible to only add them to some, but not all, of the three mappings, but usually it makes sense to keep the three in sync.
Note that adding a member to PartyToParticipant requires not just
a topology transaction but a full party migration including an ACS
export and import. The details of this are outside of the scope of
this topic.
Next steps
For details on how to submit an externally signed Daml transaction enabled by the signing keys in the PartyToParticipant mapping, refer to the external submission docs.
In this tutorial, both the namespace and protocol keys are held by the participant itself. It is also possible to hold them outside of the participant. The actual flow stays the same, but each submission of a topology transaction must be signed externally. Refer to the external topology signing docs for details on how to do this.
Decentralized namespace computation
In the above example, we used
DecentralizedNamespaceDefinition.computeNamespace(Set(aliceNamespace, bobNamespace, charlieNamespace)) to compute the decentralized
namespace from the namespaces of the initial owners. Note that only the initial owners matter here, the decentralized namespace does not change as owners get added or removed.
However, in some cases you might not run this from a Canton console (for example because you are working directly against the topology gRPC APIs) or need to compute the namespace yourself for other reasons. For those cases, we document how to compute it in Python here:
lexicographic ordering on namespaces:
def compute_decentralized_namespace(owners):
builder = hashlib.sha256()
# hash purpose prefix
builder.update((37).to_bytes(4))
for owner in sorted(owners):
# namespace length
builder.update(len(owner).to_bytes(4))
builder.update(owner.encode("utf-8"))
# 1220 is the Canton prefix for sha256 hashes
return f"1220{builder.hexdigest()}"