use canton_core::{Error, Result};
use canton_proto::com::daml::ledger::api::v2 as pb;
use canton_proto::com::daml::ledger::api::v2::interactive as ipb;
use canton_signer::Signer;
#[derive(Clone, Debug)]
pub struct Prepare {
act_as: Vec<String>,
commands: Vec<pb::Command>,
command_id: Option<String>,
user_id: Option<String>,
read_as: Vec<String>,
synchronizer_id: Option<String>,
disclosed_contracts: Vec<pb::DisclosedContract>,
package_id_selection_preference: Vec<String>,
prefetch_contract_keys: Vec<pb::PrefetchContractKey>,
min_ledger_time_abs: Option<prost_types::Timestamp>,
min_ledger_time_rel: Option<std::time::Duration>,
verbose_hashing: bool,
max_record_time: Option<prost_types::Timestamp>,
hashing_scheme_version: Option<i32>,
taps_max_passes: Option<u32>,
estimate_traffic_cost: bool,
trust_participant_hash: bool,
}
impl Prepare {
pub fn new(act_as: impl Into<String>) -> Self {
Self::new_multi(vec![act_as.into()])
}
#[must_use]
pub fn new_multi(act_as: Vec<String>) -> Self {
Self {
act_as,
commands: Vec::new(),
command_id: None,
user_id: None,
read_as: Vec::new(),
synchronizer_id: None,
disclosed_contracts: Vec::new(),
package_id_selection_preference: Vec::new(),
prefetch_contract_keys: Vec::new(),
min_ledger_time_abs: None,
min_ledger_time_rel: None,
verbose_hashing: false,
max_record_time: None,
hashing_scheme_version: Some(ipb::HashingSchemeVersion::V2 as i32),
taps_max_passes: None,
estimate_traffic_cost: true,
trust_participant_hash: false,
}
}
#[must_use]
pub fn add_command(mut self, command: pb::Command) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn add_commands(mut self, commands: impl IntoIterator<Item = pb::Command>) -> Self {
self.commands.extend(commands);
self
}
#[must_use]
pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
self.command_id = Some(command_id.into());
self
}
#[must_use]
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
#[must_use]
pub fn with_read_as(mut self, read_as: Vec<String>) -> Self {
self.read_as = read_as;
self
}
#[must_use]
pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
self.synchronizer_id = Some(synchronizer_id.into());
self
}
#[must_use]
pub fn with_disclosed_contracts(mut self, contracts: Vec<pb::DisclosedContract>) -> Self {
self.disclosed_contracts = contracts;
self
}
#[must_use]
pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
self.package_id_selection_preference = package_ids;
self
}
#[must_use]
pub fn with_prefetch_contract_keys(mut self, keys: Vec<pb::PrefetchContractKey>) -> Self {
self.prefetch_contract_keys = keys;
self
}
#[must_use]
pub fn with_min_ledger_time_abs(mut self, time: prost_types::Timestamp) -> Self {
self.min_ledger_time_abs = Some(time);
self.min_ledger_time_rel = None;
self
}
#[must_use]
pub fn with_min_ledger_time_rel(mut self, duration: std::time::Duration) -> Self {
self.min_ledger_time_rel = Some(duration);
self.min_ledger_time_abs = None;
self
}
#[must_use]
pub fn with_verbose_hashing(mut self, verbose: bool) -> Self {
self.verbose_hashing = verbose;
self
}
#[must_use]
pub fn with_max_record_time(mut self, time: prost_types::Timestamp) -> Self {
self.max_record_time = Some(time);
self
}
#[must_use]
pub fn with_hashing_scheme_version(mut self, version: ipb::HashingSchemeVersion) -> Self {
self.hashing_scheme_version = Some(version as i32);
self
}
#[must_use]
pub fn trust_participant_hash(mut self) -> Self {
self.trust_participant_hash = true;
self
}
#[must_use]
pub fn with_taps_max_passes(mut self, passes: u32) -> Self {
self.taps_max_passes = Some(passes);
self
}
#[must_use]
pub fn without_traffic_cost_estimation(mut self) -> Self {
self.estimate_traffic_cost = false;
self
}
#[must_use]
pub fn act_as(&self) -> &[String] {
&self.act_as
}
#[must_use]
pub fn read_as(&self) -> &[String] {
&self.read_as
}
#[must_use]
pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
&self.disclosed_contracts
}
pub(crate) fn requested_hashing_scheme(&self) -> Option<ipb::HashingSchemeVersion> {
self.hashing_scheme_version
.and_then(|v| ipb::HashingSchemeVersion::try_from(v).ok())
}
pub(crate) fn trusts_participant_hash(&self) -> bool {
self.trust_participant_hash
}
pub(crate) fn into_request(self) -> (String, Option<String>, ipb::PrepareSubmissionRequest) {
let command_id = self
.command_id
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let user_id = self.user_id;
let min_ledger_time = min_ledger_time(self.min_ledger_time_abs, self.min_ledger_time_rel);
let request = ipb::PrepareSubmissionRequest {
user_id: user_id.clone().unwrap_or_default(),
command_id: command_id.clone(),
commands: self.commands,
min_ledger_time,
act_as: self.act_as,
read_as: self.read_as,
disclosed_contracts: self.disclosed_contracts,
synchronizer_id: self.synchronizer_id.unwrap_or_default(),
package_id_selection_preference: self.package_id_selection_preference,
verbose_hashing: self.verbose_hashing,
prefetch_contract_keys: self.prefetch_contract_keys,
max_record_time: self.max_record_time,
hashing_scheme_version: self.hashing_scheme_version,
taps_max_passes: self.taps_max_passes,
estimate_traffic_cost: (!self.estimate_traffic_cost).then_some(
ipb::CostEstimationHints {
disabled: true,
expected_signatures: Vec::new(),
},
),
};
(command_id, user_id, request)
}
}
fn duration(value: std::time::Duration) -> prost_types::Duration {
prost_types::Duration {
seconds: i64::try_from(value.as_secs()).unwrap_or(i64::MAX),
nanos: i32::try_from(value.subsec_nanos()).unwrap_or(0),
}
}
fn min_ledger_time(
abs: Option<prost_types::Timestamp>,
rel: Option<std::time::Duration>,
) -> Option<ipb::MinLedgerTime> {
use ipb::min_ledger_time::Time;
let time = match (abs, rel) {
(Some(abs), _) => Time::MinLedgerTimeAbs(abs),
(None, Some(rel)) => Time::MinLedgerTimeRel(duration(rel)),
(None, None) => return None,
};
Some(ipb::MinLedgerTime { time: Some(time) })
}
const PREPARED_HASH_LEN: usize = 32;
#[derive(Clone, Debug)]
pub struct Prepared {
transaction: Option<ipb::PreparedTransaction>,
hash: Vec<u8>,
hashing_scheme_version: i32,
hashing_details: Option<String>,
cost_estimation: Option<ipb::CostEstimation>,
act_as: Vec<String>,
read_as: Vec<String>,
command_id: String,
user_id: Option<String>,
}
impl Prepared {
pub(crate) fn from_response(
response: ipb::PrepareSubmissionResponse,
requested_scheme: Option<ipb::HashingSchemeVersion>,
trust_participant_hash: bool,
act_as: Vec<String>,
read_as: Vec<String>,
command_id: String,
user_id: Option<String>,
) -> Result<Self> {
let Some(transaction) = response.prepared_transaction.as_ref() else {
return Err(Error::UnexpectedResponse(
"prepare returned no prepared transaction, so there is nothing to execute"
.to_string(),
));
};
if response.prepared_transaction_hash.is_empty() {
return Err(Error::UnexpectedResponse(
"prepare returned no transaction hash".to_string(),
));
}
let scheme = ipb::HashingSchemeVersion::try_from(response.hashing_scheme_version)
.ok()
.filter(|s| *s != ipb::HashingSchemeVersion::Unspecified)
.ok_or_else(|| {
Error::UnexpectedResponse(format!(
"prepare answered under an unknown hashing scheme ({})",
response.hashing_scheme_version
))
})?;
if let Some(requested) = requested_scheme.filter(|requested| *requested != scheme) {
return Err(Error::UnexpectedResponse(format!(
"prepare hashed under {scheme:?} but {requested:?} was requested"
)));
}
if response.prepared_transaction_hash.len() != PREPARED_HASH_LEN {
return Err(Error::UnexpectedResponse(format!(
"prepare returned a {}-byte transaction hash; {scheme:?} hashes are {PREPARED_HASH_LEN} bytes",
response.prepared_transaction_hash.len()
)));
}
if matches!(
scheme,
ipb::HashingSchemeVersion::V2 | ipb::HashingSchemeVersion::V3
) {
let recomputed = crate::hashing::hash_prepared_transaction(transaction, scheme)
.map_err(|e| {
Error::UnexpectedResponse(format!(
"the prepared transaction cannot be hashed, so its hash cannot be checked: {e}"
))
})?;
if recomputed[..] != response.prepared_transaction_hash[..] {
return Err(Error::UnexpectedResponse(
"the participant's transaction hash is not the hash of the transaction it \
returned; refusing to sign it"
.to_string(),
));
}
} else if trust_participant_hash {
tracing::warn!(
?scheme,
"the SDK cannot recompute this hashing scheme; the participant's hash is signed \
as returned, as the caller asked"
);
} else {
return Err(Error::UnexpectedResponse(format!(
"prepare hashed under {scheme:?}, which this SDK cannot recompute (it checks V2 \
and V3); refusing to sign an unchecked hash. Prepare::trust_participant_hash() \
signs it as returned"
)));
}
Ok(Self {
transaction: response.prepared_transaction,
hash: response.prepared_transaction_hash,
hashing_scheme_version: response.hashing_scheme_version,
hashing_details: response.hashing_details,
cost_estimation: response.cost_estimation,
act_as,
read_as,
command_id,
user_id,
})
}
#[must_use]
pub fn hash(&self) -> &[u8] {
&self.hash
}
#[must_use]
pub fn transaction(&self) -> Option<&ipb::PreparedTransaction> {
self.transaction.as_ref()
}
#[must_use]
pub fn command_id(&self) -> &str {
&self.command_id
}
#[must_use]
pub fn change_id(&self) -> crate::command::ChangeId {
crate::command::ChangeId::new(
self.user_id.clone().unwrap_or_default(),
self.act_as.clone(),
self.command_id.clone(),
)
}
#[must_use]
pub fn act_as(&self) -> &[String] {
&self.act_as
}
#[must_use]
pub fn hashing_details(&self) -> Option<&str> {
self.hashing_details.as_deref()
}
#[must_use]
pub fn cost_estimation(&self) -> Option<&ipb::CostEstimation> {
self.cost_estimation.as_ref()
}
#[must_use]
pub fn hashing_scheme_version(&self) -> ipb::HashingSchemeVersion {
ipb::HashingSchemeVersion::try_from(self.hashing_scheme_version)
.unwrap_or(ipb::HashingSchemeVersion::Unspecified)
}
pub async fn sign_with(self, signer: &dyn Signer) -> Result<Executable> {
let [party] = self.act_as.as_slice() else {
return Err(Error::InvalidRequest(format!(
"this submission acts as {} parties, so a signature must name \
one: use `sign_as`",
self.act_as.len()
)));
};
let party = party.clone();
self.sign_as(&party, signer).await
}
pub async fn sign_as(self, party: &str, signer: &dyn Signer) -> Result<Executable> {
let signature = self.sign_one(party, signer).await?;
Ok(Executable {
prepared: self,
signatures: vec![signature],
submission_id: None,
transaction_shape: crate::request::TransactionShape::default(),
deduplication: None,
})
}
async fn sign_one(
&self,
party: &str,
signer: &dyn Signer,
) -> Result<ipb::SinglePartySignatures> {
if !self.act_as.iter().any(|p| p == party) {
return Err(Error::InvalidRequest(format!(
"`{party}` is not among the parties this submission acts as ({})",
self.act_as.join(", ")
)));
}
let signature = signer.sign(&self.hash).await?;
Ok(ipb::SinglePartySignatures {
party: party.to_string(),
signatures: vec![pb::Signature::from(&signature)],
})
}
#[must_use]
pub fn read_as(&self) -> &[String] {
&self.read_as
}
}
#[derive(Clone, Copy, Debug)]
enum Deduplication {
Duration(std::time::Duration),
Offset(i64),
}
#[derive(Clone, Debug)]
pub struct Executable {
prepared: Prepared,
signatures: Vec<ipb::SinglePartySignatures>,
submission_id: Option<String>,
transaction_shape: crate::request::TransactionShape,
deduplication: Option<Deduplication>,
}
impl Executable {
pub async fn and_sign_as(mut self, party: &str, signer: &dyn Signer) -> Result<Self> {
let signature = self.prepared.sign_one(party, signer).await?;
self.add(signature);
Ok(self)
}
fn add(&mut self, signature: ipb::SinglePartySignatures) {
if let Some(existing) = self
.signatures
.iter_mut()
.find(|entry| entry.party == signature.party)
{
existing.signatures.extend(signature.signatures);
} else {
self.signatures.push(signature);
}
}
pub fn with_signature(
mut self,
party: &str,
signature: &canton_signer::Signature,
) -> Result<Self> {
if !self.prepared.act_as.iter().any(|p| p == party) {
return Err(Error::InvalidRequest(format!(
"`{party}` is not among the parties this submission acts as ({})",
self.prepared.act_as.join(", ")
)));
}
self.add(ipb::SinglePartySignatures {
party: party.to_string(),
signatures: vec![pb::Signature::from(signature)],
});
Ok(self)
}
#[must_use]
pub fn with_deduplication_duration(mut self, duration: std::time::Duration) -> Self {
self.deduplication = Some(Deduplication::Duration(duration));
self
}
#[must_use]
pub fn with_deduplication_offset(mut self, offset: i64) -> Self {
self.deduplication = Some(Deduplication::Offset(offset));
self
}
#[must_use]
pub fn with_submission_id(mut self, submission_id: impl Into<String>) -> Self {
self.submission_id = Some(submission_id.into());
self
}
#[must_use]
pub fn with_transaction_shape(mut self, shape: crate::request::TransactionShape) -> Self {
self.transaction_shape = shape;
self
}
#[must_use]
pub fn change_id(&self) -> crate::command::ChangeId {
self.prepared.change_id()
}
#[must_use]
pub fn command_id(&self) -> &str {
&self.prepared.command_id
}
#[must_use]
pub fn signed_by(&self) -> Vec<&str> {
self.signatures.iter().map(|s| s.party.as_str()).collect()
}
#[must_use]
pub fn unsigned_parties(&self) -> Vec<&str> {
self.prepared
.act_as
.iter()
.filter(|party| !self.signatures.iter().any(|s| &&s.party == party))
.map(String::as_str)
.collect()
}
fn parts(
self,
) -> (
Option<ipb::PreparedTransaction>,
Option<ipb::PartySignatures>,
String,
String,
i32,
crate::request::TransactionShape,
Option<Deduplication>,
) {
let submission_id = self.submission_id.unwrap_or_default();
(
self.prepared.transaction,
Some(ipb::PartySignatures {
signatures: self.signatures,
}),
submission_id,
self.prepared.user_id.unwrap_or_default(),
self.prepared.hashing_scheme_version,
self.transaction_shape,
self.deduplication,
)
}
pub(crate) fn into_execute_request(self) -> ipb::ExecuteSubmissionRequest {
use ipb::execute_submission_request::DeduplicationPeriod as P;
let (transaction, party_signatures, submission_id, user_id, hashing, _, dedup) =
self.parts();
ipb::ExecuteSubmissionRequest {
prepared_transaction: transaction,
party_signatures,
deduplication_period: dedup.map(|d| match d {
Deduplication::Duration(d) => P::DeduplicationDuration(duration(d)),
Deduplication::Offset(o) => P::DeduplicationOffset(o),
}),
submission_id,
user_id,
hashing_scheme_version: hashing,
min_ledger_time: None,
}
}
pub(crate) fn into_execute_and_wait_request(self) -> ipb::ExecuteSubmissionAndWaitRequest {
use ipb::execute_submission_and_wait_request::DeduplicationPeriod as P;
let (transaction, party_signatures, submission_id, user_id, hashing, _, dedup) =
self.parts();
ipb::ExecuteSubmissionAndWaitRequest {
prepared_transaction: transaction,
party_signatures,
deduplication_period: dedup.map(|d| match d {
Deduplication::Duration(d) => P::DeduplicationDuration(duration(d)),
Deduplication::Offset(o) => P::DeduplicationOffset(o),
}),
submission_id,
user_id,
hashing_scheme_version: hashing,
min_ledger_time: None,
}
}
pub(crate) fn into_execute_and_wait_for_transaction_request(
self,
event_format: pb::EventFormat,
) -> ipb::ExecuteSubmissionAndWaitForTransactionRequest {
use ipb::execute_submission_and_wait_for_transaction_request::DeduplicationPeriod as P;
let (transaction, party_signatures, submission_id, user_id, hashing, shape, dedup) =
self.parts();
ipb::ExecuteSubmissionAndWaitForTransactionRequest {
prepared_transaction: transaction,
party_signatures,
deduplication_period: dedup.map(|d| match d {
Deduplication::Duration(d) => P::DeduplicationDuration(duration(d)),
Deduplication::Offset(o) => P::DeduplicationOffset(o),
}),
submission_id,
user_id,
hashing_scheme_version: hashing,
min_ledger_time: None,
transaction_format: Some(pb::TransactionFormat {
event_format: Some(event_format),
transaction_shape: shape.as_grpc() as i32,
}),
}
}
pub(crate) fn witnesses(&self) -> Vec<String> {
let mut parties = self.prepared.act_as.clone();
parties.extend(self.prepared.read_as.iter().cloned());
parties.sort_unstable();
parties.dedup();
parties
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use canton_signer::Ed25519Key;
fn signer(fingerprint: &str) -> canton_signer::Ed25519Signer {
Ed25519Key::from_seed(&[4; 32])
.expect("a valid seed")
.into_signer(fingerprint)
}
fn hashable(act_as: &[String], command_id: &str) -> (ipb::PreparedTransaction, Vec<u8>) {
let transaction = ipb::PreparedTransaction {
transaction: Some(ipb::DamlTransaction {
version: "2.1".to_string(),
..Default::default()
}),
metadata: Some(ipb::Metadata {
submitter_info: Some(ipb::metadata::SubmitterInfo {
act_as: act_as.to_vec(),
command_id: command_id.to_string(),
}),
..Default::default()
}),
};
let hash = crate::hashing::hash_prepared_transaction_v2(&transaction)
.expect("hashable")
.to_vec();
(transaction, hash)
}
fn prepared(act_as: &[&str]) -> Prepared {
let act_as: Vec<String> = act_as.iter().map(ToString::to_string).collect();
let (transaction, hash) = hashable(&act_as, "command-1");
Prepared::from_response(
ipb::PrepareSubmissionResponse {
prepared_transaction: Some(transaction),
prepared_transaction_hash: hash,
hashing_scheme_version: ipb::HashingSchemeVersion::V2 as i32,
hashing_details: None,
cost_estimation: None,
},
Some(ipb::HashingSchemeVersion::V2),
false,
act_as,
Vec::new(),
"command-1".to_string(),
Some("user-1".to_string()),
)
.expect("a response carrying a transaction and a hash")
}
#[test]
fn a_prepare_response_without_a_hash_is_refused() {
let err = Prepared::from_response(
ipb::PrepareSubmissionResponse {
prepared_transaction: Some(ipb::PreparedTransaction::default()),
prepared_transaction_hash: Vec::new(),
hashing_scheme_version: 0,
hashing_details: None,
cost_estimation: None,
},
None,
false,
vec!["alice".to_string()],
Vec::new(),
"c".to_string(),
None,
)
.expect_err("no hash");
assert!(err.to_string().contains("no transaction hash"), "{err}");
}
#[test]
fn a_prepare_response_without_a_transaction_is_refused() {
let err = Prepared::from_response(
ipb::PrepareSubmissionResponse {
prepared_transaction: None,
prepared_transaction_hash: vec![1, 2, 3],
hashing_scheme_version: 0,
hashing_details: None,
cost_estimation: None,
},
None,
false,
vec!["alice".to_string()],
Vec::new(),
"c".to_string(),
None,
)
.expect_err("no transaction");
assert!(err.to_string().contains("nothing to execute"), "{err}");
}
fn response_with(hash: Vec<u8>, scheme: i32) -> ipb::PrepareSubmissionResponse {
let (transaction, _) = hashable(&["alice".to_string()], "command-1");
ipb::PrepareSubmissionResponse {
prepared_transaction: Some(transaction),
prepared_transaction_hash: hash,
hashing_scheme_version: scheme,
hashing_details: None,
cost_estimation: None,
}
}
fn refused(
response: ipb::PrepareSubmissionResponse,
requested: Option<ipb::HashingSchemeVersion>,
) -> String {
let err = Prepared::from_response(
response,
requested,
false,
vec!["alice".to_string()],
Vec::new(),
"command-1".to_string(),
None,
)
.expect_err("refused");
assert!(matches!(err, Error::UnexpectedResponse(_)), "{err:?}");
err.to_string()
}
#[test]
fn a_hash_under_an_unknown_scheme_is_refused() {
let message = refused(response_with(vec![1; 32], 0), None);
assert!(message.contains("unknown hashing scheme"), "{message}");
let message = refused(response_with(vec![1; 32], 99), None);
assert!(message.contains("unknown hashing scheme (99)"), "{message}");
}
#[test]
fn a_hash_under_a_scheme_that_was_not_requested_is_refused() {
let message = refused(
response_with(vec![1; 32], ipb::HashingSchemeVersion::V3 as i32),
Some(ipb::HashingSchemeVersion::V2),
);
assert!(
message.contains("V3") && message.contains("V2 was requested"),
"{message}"
);
}
#[test]
fn a_hash_of_the_wrong_length_is_refused() {
for len in [1usize, 31, 33, 64] {
let message = refused(
response_with(vec![7; len], ipb::HashingSchemeVersion::V2 as i32),
Some(ipb::HashingSchemeVersion::V2),
);
assert!(
message.contains(&format!("{len}-byte transaction hash")),
"{message}"
);
}
}
#[test]
fn a_hash_that_is_not_the_transactions_own_is_refused() {
let (_, mut hash) = hashable(&["alice".to_string()], "command-1");
hash[0] ^= 0xff;
let message = refused(
response_with(hash, ipb::HashingSchemeVersion::V2 as i32),
Some(ipb::HashingSchemeVersion::V2),
);
assert!(
message.contains("not the hash of the transaction"),
"{message}"
);
}
fn accepted(
response: ipb::PrepareSubmissionResponse,
requested: ipb::HashingSchemeVersion,
trust_participant_hash: bool,
) -> Result<Prepared> {
Prepared::from_response(
response,
Some(requested),
trust_participant_hash,
vec!["alice".to_string()],
Vec::new(),
"command-1".to_string(),
None,
)
}
#[test]
fn v3_is_recomputed_and_held_to_the_transaction() {
let (transaction, _) = hashable(&["alice".to_string()], "command-1");
let hash = crate::hashing::hash_prepared_transaction_v3(&transaction)
.unwrap()
.to_vec();
let ok = accepted(
response_with(hash.clone(), ipb::HashingSchemeVersion::V3 as i32),
ipb::HashingSchemeVersion::V3,
false,
);
assert!(ok.is_ok(), "{ok:?}");
let mut wrong = hash;
wrong[0] ^= 0xff;
let message = refused(
response_with(wrong, ipb::HashingSchemeVersion::V3 as i32),
Some(ipb::HashingSchemeVersion::V3),
);
assert!(
message.contains("not the hash of the transaction"),
"{message}"
);
}
#[test]
fn an_unverifiable_scheme_needs_the_caller_to_trust_the_participant() {
let message = refused(
response_with(vec![7; 32], ipb::HashingSchemeVersion::V4 as i32),
Some(ipb::HashingSchemeVersion::V4),
);
assert!(message.contains("trust_participant_hash"), "{message}");
let trusted = accepted(
response_with(vec![7; 32], ipb::HashingSchemeVersion::V4 as i32),
ipb::HashingSchemeVersion::V4,
true,
)
.expect("the caller accepted an unchecked hash");
assert_eq!(trusted.hash(), [7; 32]);
let (_, mut hash) = hashable(&["alice".to_string()], "command-1");
hash[0] ^= 0xff;
let err = accepted(
response_with(hash, ipb::HashingSchemeVersion::V2 as i32),
ipb::HashingSchemeVersion::V2,
true,
)
.expect_err("a wrong V2 hash is refused even when trusting");
assert!(err.to_string().contains("not the hash"), "{err}");
assert!(
Prepare::new("alice")
.trust_participant_hash()
.trusts_participant_hash()
);
assert!(!Prepare::new("alice").trusts_participant_hash());
}
#[test]
fn the_scheme_asked_for_defaults_to_v2() {
let prepare = Prepare::new("alice");
assert_eq!(
prepare.requested_hashing_scheme(),
Some(ipb::HashingSchemeVersion::V2)
);
let prepare = prepare.with_hashing_scheme_version(ipb::HashingSchemeVersion::V3);
assert_eq!(
prepare.requested_hashing_scheme(),
Some(ipb::HashingSchemeVersion::V3)
);
}
#[tokio::test]
async fn signing_a_multi_party_submission_must_name_the_party() {
let err = prepared(&["alice", "bob"])
.sign_with(&signer("1220aa"))
.await
.expect_err("ambiguous");
assert!(err.to_string().contains("sign_as"), "{err}");
}
#[tokio::test]
async fn signing_as_a_party_that_is_not_acting_is_refused() {
let err = prepared(&["alice"])
.sign_as("mallory", &signer("1220aa"))
.await
.expect_err("not an acting party");
assert!(err.to_string().contains("mallory"), "{err}");
assert!(err.to_string().contains("alice"), "{err}");
}
#[tokio::test]
async fn a_multi_party_submission_reports_who_has_not_signed() {
let executable = prepared(&["alice", "bob"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("alice signs");
assert_eq!(executable.signed_by(), vec!["alice"]);
assert_eq!(executable.unsigned_parties(), vec!["bob"]);
let executable = executable
.and_sign_as("bob", &signer("1220bb"))
.await
.expect("bob signs");
assert_eq!(executable.unsigned_parties(), Vec::<&str>::new());
assert_eq!(executable.signed_by(), vec!["alice", "bob"]);
}
#[tokio::test]
async fn several_signatures_for_one_party_share_its_entry() {
let executable = prepared(&["alice"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("first key")
.and_sign_as("alice", &signer("1220bb"))
.await
.expect("second key");
assert_eq!(executable.signed_by(), vec!["alice"], "one entry");
let wire = executable.into_execute_request();
let signatures = wire.party_signatures.expect("signatures").signatures;
assert_eq!(signatures.len(), 1, "one entry per party");
assert_eq!(signatures[0].signatures.len(), 2, "both keys inside it");
assert_eq!(signatures[0].signatures[0].signed_by, "1220aa");
assert_eq!(signatures[0].signatures[1].signed_by, "1220bb");
}
#[test]
fn the_later_min_ledger_time_wins_in_either_order() {
use ipb::min_ledger_time::Time;
let abs_then_rel = Prepare::new("alice")
.with_min_ledger_time_abs(prost_types::Timestamp {
seconds: 42,
nanos: 0,
})
.with_min_ledger_time_rel(std::time::Duration::from_secs(30))
.into_request()
.2
.min_ledger_time
.and_then(|t| t.time);
assert!(
matches!(abs_then_rel, Some(Time::MinLedgerTimeRel(_))),
"rel was set last: {abs_then_rel:?}"
);
}
#[tokio::test]
async fn the_change_id_survives_from_preparation_into_execution() {
let (command_id, user_id, request) = Prepare::new("alice")
.with_command_id("stable-id")
.with_user_id("user-1")
.into_request();
assert_eq!(command_id, "stable-id");
assert_eq!(request.command_id, "stable-id");
assert_eq!(user_id.as_deref(), Some("user-1"));
let executable = prepared(&["alice"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("sign");
let wire = executable.into_execute_request();
assert_eq!(
wire.user_id, "user-1",
"the user id is part of the change ID"
);
let submitter = wire
.prepared_transaction
.expect("the transaction is required")
.metadata
.expect("metadata")
.submitter_info
.expect("submitter info");
assert_eq!(submitter.command_id, "command-1");
assert_eq!(submitter.act_as, ["alice"]);
}
#[test]
fn an_unset_command_id_is_generated_once() {
let (id, _, request) = Prepare::new("alice").into_request();
assert_ne!(id, "");
assert_eq!(id, request.command_id);
}
#[test]
fn the_two_min_ledger_times_are_mutually_exclusive() {
use ipb::min_ledger_time::Time;
let (_, _, request) = Prepare::new("alice")
.with_min_ledger_time_rel(std::time::Duration::from_secs(30))
.into_request();
assert!(matches!(
request.min_ledger_time.and_then(|t| t.time),
Some(Time::MinLedgerTimeRel(_))
));
let (_, _, request) = Prepare::new("alice")
.with_min_ledger_time_rel(std::time::Duration::from_secs(30))
.with_min_ledger_time_abs(prost_types::Timestamp {
seconds: 42,
nanos: 0,
})
.into_request();
let time = request.min_ledger_time.and_then(|t| t.time);
assert!(
matches!(time, Some(Time::MinLedgerTimeAbs(_))),
"one of the two, never both: {time:?}"
);
}
#[test]
fn traffic_cost_estimation_is_only_sent_when_turned_off() {
let (_, _, request) = Prepare::new("alice").into_request();
assert!(request.estimate_traffic_cost.is_none());
let (_, _, request) = Prepare::new("alice")
.without_traffic_cost_estimation()
.into_request();
assert_eq!(
request.estimate_traffic_cost.map(|h| h.disabled),
Some(true)
);
}
#[tokio::test]
async fn a_signature_made_elsewhere_reaches_the_wire() {
let external = canton_signer::Signature::new(
canton_signer::SignatureFormat::Concat,
vec![8; 64],
"1220cc",
canton_signer::SigningAlgorithm::Ed25519,
)
.expect("a well-formed signature");
let executable = prepared(&["alice", "bob"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("alice signs")
.with_signature("bob", &external)
.expect("bob's signature arrives from elsewhere");
let wire = executable.into_execute_and_wait_request();
let signatures = wire.party_signatures.expect("signatures").signatures;
let bob = signatures
.iter()
.find(|s| s.party == "bob")
.expect("bob's entry");
assert_eq!(bob.signatures[0].signature, vec![8; 64]);
assert_eq!(bob.signatures[0].signed_by, "1220cc");
}
#[tokio::test]
async fn a_signature_for_a_party_that_is_not_acting_is_refused() {
let external = canton_signer::Signature::new(
canton_signer::SignatureFormat::Concat,
vec![8; 64],
"1220cc",
canton_signer::SigningAlgorithm::Ed25519,
)
.expect("a well-formed signature");
let err = prepared(&["alice"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("sign")
.with_signature("mallory", &external)
.expect_err("not an acting party");
assert!(err.to_string().contains("mallory"), "{err}");
}
#[tokio::test]
async fn the_hashing_scheme_is_echoed_back_to_the_participant() {
let prepared = prepared(&["alice"]);
assert_eq!(
prepared.hashing_scheme_version(),
ipb::HashingSchemeVersion::V2
);
let wire = prepared
.sign_as("alice", &signer("1220aa"))
.await
.expect("sign")
.into_execute_request();
assert_eq!(
wire.hashing_scheme_version,
ipb::HashingSchemeVersion::V2 as i32
);
}
#[tokio::test]
async fn the_signer_is_handed_the_participants_hash() {
let prepared = prepared(&["alice"]);
let (_, hash) = hashable(&["alice".to_string()], "command-1");
assert_eq!(prepared.hash(), &hash[..]);
let key = Ed25519Key::from_seed(&[4; 32]).expect("seed");
let expected = key.sign_raw(&hash);
let wire = prepared
.sign_as("alice", &signer("1220aa"))
.await
.expect("sign")
.into_execute_request();
let signatures = wire.party_signatures.expect("signatures").signatures;
assert_eq!(signatures[0].signatures[0].signature, expected);
}
#[tokio::test]
async fn the_deduplication_period_reaches_every_execute_variant() {
use ipb::execute_submission_request::DeduplicationPeriod;
let executable = prepared(&["alice"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("sign")
.with_deduplication_duration(std::time::Duration::from_secs(60));
let wire = executable.clone().into_execute_request();
assert!(matches!(
wire.deduplication_period,
Some(DeduplicationPeriod::DeduplicationDuration(d)) if d.seconds == 60
));
let wire = executable
.with_deduplication_offset(99)
.into_execute_and_wait_request();
assert!(matches!(
wire.deduplication_period,
Some(
ipb::execute_submission_and_wait_request::DeduplicationPeriod::DeduplicationOffset(
99
)
)
));
}
#[test]
fn prepare_reports_what_it_was_given() {
let disclosed = pb::DisclosedContract {
contract_id: "00d".to_string(),
..Default::default()
};
let prepare = Prepare::new_multi(vec!["alice".to_string(), "bob".to_string()])
.with_read_as(vec!["carol".to_string()])
.with_disclosed_contracts(vec![disclosed]);
assert_eq!(prepare.act_as(), ["alice".to_string(), "bob".to_string()]);
assert_eq!(prepare.read_as(), ["carol".to_string()]);
assert_eq!(prepare.disclosed_contracts().len(), 1);
assert_eq!(prepare.disclosed_contracts()[0].contract_id, "00d");
}
#[tokio::test]
async fn prepared_and_executable_report_what_the_participant_returned() {
let prepared = Prepared::from_response(
ipb::PrepareSubmissionResponse {
prepared_transaction: Some(hashable(&["alice".to_string()], "command-7").0),
prepared_transaction_hash: hashable(&["alice".to_string()], "command-7").1,
hashing_scheme_version: ipb::HashingSchemeVersion::V2 as i32,
hashing_details: Some("hashed thus".to_string()),
cost_estimation: Some(ipb::CostEstimation {
confirmation_request_traffic_cost_estimation: 1234,
..Default::default()
}),
},
Some(ipb::HashingSchemeVersion::V2),
false,
vec!["alice".to_string()],
vec!["carol".to_string()],
"command-7".to_string(),
Some("user-1".to_string()),
)
.expect("a full response");
assert_eq!(prepared.command_id(), "command-7");
assert_eq!(prepared.act_as(), ["alice".to_string()]);
assert_eq!(prepared.read_as(), ["carol".to_string()]);
assert_eq!(prepared.hashing_details(), Some("hashed thus"));
assert_eq!(
prepared
.cost_estimation()
.map(|c| c.confirmation_request_traffic_cost_estimation),
Some(1234)
);
let transaction = prepared.transaction().expect("the transaction is kept");
let info = transaction
.metadata
.as_ref()
.and_then(|m| m.submitter_info.as_ref())
.expect("its submitter info");
assert_eq!(info.command_id, "command-7");
assert_eq!(
prepared.change_id(),
crate::command::ChangeId::new(
"user-1".to_string(),
vec!["alice".to_string()],
"command-7".to_string()
)
);
let executable = prepared
.sign_with(&signer("1220aa"))
.await
.expect("one acting party signs without naming itself");
assert_eq!(executable.command_id(), "command-7");
assert_eq!(
executable.change_id(),
crate::command::ChangeId::new(
"user-1".to_string(),
vec!["alice".to_string()],
"command-7".to_string()
)
);
assert_eq!(executable.signed_by(), ["alice"]);
let mut witnesses = executable.witnesses();
witnesses.sort();
assert_eq!(witnesses, ["alice".to_string(), "carol".to_string()]);
}
#[tokio::test]
async fn the_wait_for_transaction_request_carries_the_signature_and_the_format() {
let executable = prepared(&["alice"])
.sign_as("alice", &signer("1220aa"))
.await
.expect("sign")
.with_submission_id("sub-1")
.with_transaction_shape(crate::request::TransactionShape::LedgerEffects);
let wire = executable.into_execute_and_wait_for_transaction_request(pb::EventFormat {
verbose: true,
..Default::default()
});
assert!(
wire.prepared_transaction.is_some(),
"the transaction travels"
);
let signatures = wire.party_signatures.expect("signatures");
assert_eq!(signatures.signatures.len(), 1);
assert_eq!(signatures.signatures[0].party, "alice");
assert_eq!(wire.submission_id, "sub-1");
assert_eq!(wire.user_id, "user-1");
assert_eq!(
wire.hashing_scheme_version,
ipb::HashingSchemeVersion::V2 as i32
);
let format = wire.transaction_format.expect("a transaction format");
assert!(format.event_format.expect("event format").verbose);
assert_eq!(
format.transaction_shape,
pb::TransactionShape::LedgerEffects as i32
);
}
}