use canton_core::Result;
use canton_daml as rt;
use canton_proto::com::daml::ledger::api::v2 as pb;
use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1 as md;
use canton_splice_api_token_transfer_instruction_v1::splice_api_token_transfer_instruction_v1::Splice_Api_Token_TransferInstructionV1 as ti;
use crate::registry::{RegistryClient, TransferInstructionChoice, TransferKind};
#[derive(Clone, Debug)]
pub struct TokenCommand {
command: pb::Command,
disclosed_contracts: Vec<pb::DisclosedContract>,
transfer_kind: Option<TransferKind>,
}
impl TokenCommand {
pub(crate) fn new(
command: pb::Command,
disclosed_contracts: Vec<pb::DisclosedContract>,
transfer_kind: Option<TransferKind>,
) -> Self {
Self {
command,
disclosed_contracts,
transfer_kind,
}
}
#[must_use]
pub fn command(&self) -> &pb::Command {
&self.command
}
#[must_use]
pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
&self.disclosed_contracts
}
#[must_use]
pub fn transfer_kind(&self) -> Option<TransferKind> {
self.transfer_kind
}
#[must_use]
pub fn into_submit(self, act_as: impl Into<String>) -> canton_ledger::Submit {
self.disclosed_contracts.into_iter().fold(
canton_ledger::Submit::new(act_as).add_command(self.command),
canton_ledger::Submit::add_disclosed_contract,
)
}
#[must_use]
pub fn into_prepare(self, act_as: impl Into<String>) -> canton_ledger::Prepare {
canton_ledger::Prepare::new(act_as)
.add_command(self.command)
.with_disclosed_contracts(self.disclosed_contracts)
}
#[must_use]
pub fn into_parts(self) -> (pb::Command, Vec<pb::DisclosedContract>) {
(self.command, self.disclosed_contracts)
}
}
#[must_use]
pub fn empty_extra_args() -> md::ExtraArgs {
md::ExtraArgs {
context: md::ChoiceContext {
values: rt::TextMap::new(),
},
meta: md::Metadata {
values: rt::TextMap::new(),
},
}
}
pub async fn transfer(
registry: &RegistryClient,
expected_admin: &rt::Party,
transfer: ti::Transfer,
) -> Result<TokenCommand> {
let probe = ti::TransferFactory_Transfer {
expected_admin: expected_admin.clone(),
transfer,
extra_args: empty_extra_args(),
};
let arguments = serde_json::to_value(&probe).map_err(|e| {
canton_core::Error::InvalidRequest(format!("the transfer does not serialize: {e}"))
})?;
let factory = registry.transfer_factory(&arguments).await?;
let context: md::ChoiceContext = factory.context.decode()?;
let choice = ti::TransferFactory_Transfer {
extra_args: md::ExtraArgs {
context,
meta: probe.extra_args.meta,
},
..probe
};
let factory_id = rt::ContractId::<ti::TransferFactory>::new(factory.factory_id);
Ok(TokenCommand::new(
rt::exercise_command(&factory_id, &choice),
factory.context.into_disclosed_contracts(),
factory.transfer_kind,
))
}
pub async fn accept(
registry: &RegistryClient,
instruction_id: &rt::ContractId<ti::TransferInstruction>,
) -> Result<TokenCommand> {
on_instruction(
registry,
instruction_id,
TransferInstructionChoice::Accept,
|extra_args| ti::TransferInstruction_Accept { extra_args },
)
.await
}
pub async fn reject(
registry: &RegistryClient,
instruction_id: &rt::ContractId<ti::TransferInstruction>,
) -> Result<TokenCommand> {
on_instruction(
registry,
instruction_id,
TransferInstructionChoice::Reject,
|extra_args| ti::TransferInstruction_Reject { extra_args },
)
.await
}
pub async fn withdraw(
registry: &RegistryClient,
instruction_id: &rt::ContractId<ti::TransferInstruction>,
) -> Result<TokenCommand> {
on_instruction(
registry,
instruction_id,
TransferInstructionChoice::Withdraw,
|extra_args| ti::TransferInstruction_Withdraw { extra_args },
)
.await
}
async fn on_instruction<C>(
registry: &RegistryClient,
instruction_id: &rt::ContractId<ti::TransferInstruction>,
choice: TransferInstructionChoice,
build: impl FnOnce(md::ExtraArgs) -> C,
) -> Result<TokenCommand>
where
C: rt::Choice<ti::TransferInstruction> + rt::ToValue,
{
let context = registry
.transfer_instruction_context(
instruction_id.as_str(),
choice,
&crate::ChoiceContextRequest::default(),
)
.await?;
let argument = build(md::ExtraArgs {
context: context.decode()?,
meta: md::Metadata {
values: rt::TextMap::new(),
},
});
Ok(TokenCommand::new(
rt::exercise_command(instruction_id, &argument),
context.into_disclosed_contracts(),
None,
))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn a_transfer() -> ti::Transfer {
use canton_splice_api_token_holding_v1::splice_api_token_holding_v1::Splice_Api_Token_HoldingV1 as h;
ti::Transfer {
sender: rt::Party::parse("alice::1220ab").expect("a party"),
receiver: rt::Party::parse("bob::1220cd").expect("a party"),
amount: "10.0".parse().expect("a numeric"),
instrument_id: h::InstrumentId {
admin: rt::Party::parse("dso::1220ef").expect("a party"),
id: "Amulet".to_string(),
},
requested_at: rt::Timestamp(0),
execute_before: rt::Timestamp(1_000_000),
input_holding_cids: Vec::new(),
meta: md::Metadata {
values: rt::TextMap::new(),
},
}
}
#[test]
fn the_factory_probe_carries_the_real_transfer_and_empty_extra_args() {
let probe = ti::TransferFactory_Transfer {
expected_admin: rt::Party::parse("dso::1220ef").expect("a party"),
transfer: a_transfer(),
extra_args: empty_extra_args(),
};
let json = serde_json::to_value(&probe).unwrap();
assert_eq!(
json["extraArgs"]["context"]["values"],
serde_json::json!({})
);
assert_eq!(json["extraArgs"]["meta"]["values"], serde_json::json!({}));
assert_eq!(json["transfer"]["sender"], "alice::1220ab");
assert_eq!(json["transfer"]["receiver"], "bob::1220cd");
assert_eq!(json["expectedAdmin"], "dso::1220ef");
}
#[test]
fn the_amount_is_encoded_as_the_daml_json_api_encodes_a_numeric() {
let json = serde_json::to_value(a_transfer()).unwrap();
assert_eq!(json["amount"], "10.0");
}
#[test]
fn the_returned_context_is_what_gets_exercised() {
let wire = crate::WireChoiceContext {
choice_context_data: serde_json::json!({
"values": { "amulet-rules": { "tag": "AV_ContractId", "value": "00rules" } }
}),
disclosed_contracts: vec![crate::WireDisclosedContract {
template_id: "pkg:Splice.AmuletRules:AmuletRules".to_string(),
contract_id: "00rules".to_string(),
created_event_blob: "AQI=".to_string(),
synchronizer_id: "sync::1220ab".to_string(),
}],
};
let context = crate::ChoiceContext::from_wire(wire).unwrap();
let decoded: md::ChoiceContext = context.decode().unwrap();
let choice = ti::TransferFactory_Transfer {
expected_admin: rt::Party::parse("dso::1220ef").expect("a party"),
transfer: a_transfer(),
extra_args: md::ExtraArgs {
context: decoded,
meta: md::Metadata {
values: rt::TextMap::new(),
},
},
};
let json = serde_json::to_value(&choice).unwrap();
assert_eq!(
json["extraArgs"]["context"]["values"]["amulet-rules"],
serde_json::json!({ "tag": "AV_ContractId", "value": "00rules" })
);
}
#[test]
fn a_token_command_carries_its_disclosures_into_both_submission_paths() {
let disclosed = vec![pb::DisclosedContract {
template_id: None,
contract_id: "00rules".to_string(),
created_event_blob: vec![1, 2],
synchronizer_id: "sync".to_string(),
}];
let command = TokenCommand::new(
pb::Command { command: None },
disclosed.clone(),
Some(TransferKind::Direct),
);
assert_eq!(command.transfer_kind(), Some(TransferKind::Direct));
assert_eq!(command.disclosed_contracts().len(), 1);
let (_, taken) = command.clone().into_parts();
assert_eq!(taken, disclosed);
let prepared = command.clone().into_prepare("alice::1220ab");
assert_eq!(prepared.disclosed_contracts().len(), 1);
}
}