Skip to main content

canton_token/
transfer.rs

1//! CIP-56 transfers: resolve the factory, take its context, exercise the
2//! choice.
3//!
4//! A transfer is not a command a client can build on its own. The factory that
5//! implements it is the registry's contract, and the choice needs reference
6//! data only the registry has. So it takes two steps, and the first one is an
7//! HTTP call:
8//!
9//! 1. Ask the registry for the transfer factory, passing the transfer as the
10//!    choice arguments it *will* be exercised with. The registry answers with
11//!    the factory's contract id, how the transfer will be carried out, and the
12//!    choice context.
13//! 2. Exercise `TransferFactory_Transfer` on that factory with the context
14//!    filled in, disclosing the contracts the registry named.
15//!
16//! The transfer passed in step 1 carries empty `extraArgs` — the standard says
17//! so — because the context does not exist yet. It is filled in for step 2.
18
19use canton_core::Result;
20use canton_daml as rt;
21use canton_proto::com::daml::ledger::api::v2 as pb;
22use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1 as md;
23use canton_splice_api_token_transfer_instruction_v1::splice_api_token_transfer_instruction_v1::Splice_Api_Token_TransferInstructionV1 as ti;
24
25use crate::registry::{RegistryClient, TransferInstructionChoice, TransferKind};
26
27/// A command that carries the contracts it needs disclosed.
28///
29/// A token-standard choice is meaningless without them: the participant cannot
30/// resolve the factory's reference data otherwise, and the submission fails
31/// during interpretation. Keeping the two together means they cannot be
32/// separated by accident.
33#[derive(Clone, Debug)]
34pub struct TokenCommand {
35    command: pb::Command,
36    disclosed_contracts: Vec<pb::DisclosedContract>,
37    transfer_kind: Option<TransferKind>,
38}
39
40impl TokenCommand {
41    pub(crate) fn new(
42        command: pb::Command,
43        disclosed_contracts: Vec<pb::DisclosedContract>,
44        transfer_kind: Option<TransferKind>,
45    ) -> Self {
46        Self {
47            command,
48            disclosed_contracts,
49            transfer_kind,
50        }
51    }
52
53    /// The command itself.
54    #[must_use]
55    pub fn command(&self) -> &pb::Command {
56        &self.command
57    }
58
59    /// The contracts the registry says must be disclosed.
60    #[must_use]
61    pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
62        &self.disclosed_contracts
63    }
64
65    /// How the registry decided to carry the transfer out.
66    ///
67    /// Worth branching on: [`TransferKind::Offer`] means the transfer is
68    /// pending the receiver's acceptance rather than done, so a caller that
69    /// treats every transfer as settled is wrong about that one.
70    #[must_use]
71    pub fn transfer_kind(&self) -> Option<TransferKind> {
72        self.transfer_kind
73    }
74
75    /// An ordinary submission, with the disclosures attached.
76    #[must_use]
77    pub fn into_submit(self, act_as: impl Into<String>) -> canton_ledger::Submit {
78        self.disclosed_contracts.into_iter().fold(
79            canton_ledger::Submit::new(act_as).add_command(self.command),
80            canton_ledger::Submit::add_disclosed_contract,
81        )
82    }
83
84    /// An *interactive* submission, with the disclosures attached — for a party
85    /// whose key the participant does not hold.
86    #[must_use]
87    pub fn into_prepare(self, act_as: impl Into<String>) -> canton_ledger::Prepare {
88        canton_ledger::Prepare::new(act_as)
89            .add_command(self.command)
90            .with_disclosed_contracts(self.disclosed_contracts)
91    }
92
93    /// The parts, for a caller assembling a multi-command submission.
94    #[must_use]
95    pub fn into_parts(self) -> (pb::Command, Vec<pb::DisclosedContract>) {
96        (self.command, self.disclosed_contracts)
97    }
98}
99
100/// `ExtraArgs` with nothing in it — what the standard says to send when asking
101/// for a factory, since the context being asked for does not exist yet.
102#[must_use]
103pub fn empty_extra_args() -> md::ExtraArgs {
104    md::ExtraArgs {
105        context: md::ChoiceContext {
106            values: rt::TextMap::new(),
107        },
108        meta: md::Metadata {
109            values: rt::TextMap::new(),
110        },
111    }
112}
113
114/// Resolve the transfer factory and build the exercise that performs
115/// `transfer`.
116///
117/// `expected_admin` is the administrator the caller expects — see
118/// [`RegistryClient::info`] for what a registry claims. Naming it in the choice
119/// is what stops a *substituted* administrator from being accepted, so it is a
120/// required argument: passing a value the same registry just returned proves
121/// only that it did not change its answer between two calls. A caller who wants
122/// the protection takes it from configuration.
123///
124/// # Errors
125/// As any registry call, plus [`canton_core::Error::UnexpectedResponse`] if the
126/// context does not decode.
127pub async fn transfer(
128    registry: &RegistryClient,
129    expected_admin: &rt::Party,
130    transfer: ti::Transfer,
131) -> Result<TokenCommand> {
132    // Step one: the choice as it will be exercised, but with empty extraArgs —
133    // the registry uses the arguments to decide which context to return, so
134    // they have to be the real ones.
135    let probe = ti::TransferFactory_Transfer {
136        expected_admin: expected_admin.clone(),
137        transfer,
138        extra_args: empty_extra_args(),
139    };
140    let arguments = serde_json::to_value(&probe).map_err(|e| {
141        canton_core::Error::InvalidRequest(format!("the transfer does not serialize: {e}"))
142    })?;
143
144    let factory = registry.transfer_factory(&arguments).await?;
145    let context: md::ChoiceContext = factory.context.decode()?;
146
147    // Step two: the same choice, now with the context the registry supplied.
148    let choice = ti::TransferFactory_Transfer {
149        extra_args: md::ExtraArgs {
150            context,
151            meta: probe.extra_args.meta,
152        },
153        ..probe
154    };
155    let factory_id = rt::ContractId::<ti::TransferFactory>::new(factory.factory_id);
156
157    Ok(TokenCommand::new(
158        rt::exercise_command(&factory_id, &choice),
159        factory.context.into_disclosed_contracts(),
160        factory.transfer_kind,
161    ))
162}
163
164/// Accept an offered transfer — the receiver's move.
165///
166/// A [`TransferKind::Offer`] transfer does not settle on submission: it creates
167/// a `TransferInstruction` that waits for this. Without it the crate could tell
168/// a caller their transfer was pending and give them no way to finish it.
169///
170/// # Errors
171/// As any registry call.
172pub async fn accept(
173    registry: &RegistryClient,
174    instruction_id: &rt::ContractId<ti::TransferInstruction>,
175) -> Result<TokenCommand> {
176    on_instruction(
177        registry,
178        instruction_id,
179        TransferInstructionChoice::Accept,
180        |extra_args| ti::TransferInstruction_Accept { extra_args },
181    )
182    .await
183}
184
185/// Reject an offered transfer — the receiver's move.
186///
187/// # Errors
188/// As any registry call.
189pub async fn reject(
190    registry: &RegistryClient,
191    instruction_id: &rt::ContractId<ti::TransferInstruction>,
192) -> Result<TokenCommand> {
193    on_instruction(
194        registry,
195        instruction_id,
196        TransferInstructionChoice::Reject,
197        |extra_args| ti::TransferInstruction_Reject { extra_args },
198    )
199    .await
200}
201
202/// Withdraw an offered transfer — the sender's move, before it is accepted.
203///
204/// # Errors
205/// As any registry call.
206pub async fn withdraw(
207    registry: &RegistryClient,
208    instruction_id: &rt::ContractId<ti::TransferInstruction>,
209) -> Result<TokenCommand> {
210    on_instruction(
211        registry,
212        instruction_id,
213        TransferInstructionChoice::Withdraw,
214        |extra_args| ti::TransferInstruction_Withdraw { extra_args },
215    )
216    .await
217}
218
219/// The shape the three choices on an existing instruction share.
220///
221/// One context per choice, never shared: the standard says a context may be
222/// specific to the choice being exercised.
223async fn on_instruction<C>(
224    registry: &RegistryClient,
225    instruction_id: &rt::ContractId<ti::TransferInstruction>,
226    choice: TransferInstructionChoice,
227    build: impl FnOnce(md::ExtraArgs) -> C,
228) -> Result<TokenCommand>
229where
230    C: rt::Choice<ti::TransferInstruction> + rt::ToValue,
231{
232    let context = registry
233        .transfer_instruction_context(
234            instruction_id.as_str(),
235            choice,
236            &crate::ChoiceContextRequest::default(),
237        )
238        .await?;
239    let argument = build(md::ExtraArgs {
240        context: context.decode()?,
241        meta: md::Metadata {
242            values: rt::TextMap::new(),
243        },
244    });
245    Ok(TokenCommand::new(
246        rt::exercise_command(instruction_id, &argument),
247        context.into_disclosed_contracts(),
248        None,
249    ))
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used, clippy::expect_used)]
254mod tests {
255    use super::*;
256
257    fn a_transfer() -> ti::Transfer {
258        use canton_splice_api_token_holding_v1::splice_api_token_holding_v1::Splice_Api_Token_HoldingV1 as h;
259        ti::Transfer {
260            sender: rt::Party::parse("alice::1220ab").expect("a party"),
261            receiver: rt::Party::parse("bob::1220cd").expect("a party"),
262            amount: "10.0".parse().expect("a numeric"),
263            instrument_id: h::InstrumentId {
264                admin: rt::Party::parse("dso::1220ef").expect("a party"),
265                id: "Amulet".to_string(),
266            },
267            requested_at: rt::Timestamp(0),
268            execute_before: rt::Timestamp(1_000_000),
269            input_holding_cids: Vec::new(),
270            meta: md::Metadata {
271                values: rt::TextMap::new(),
272            },
273        }
274    }
275
276    /// The standard is explicit that the probe carries empty `extraArgs`. A
277    /// registry uses the arguments to pick a context, so sending a context in
278    /// the request would be asking it to answer a question with the answer.
279    #[test]
280    fn the_factory_probe_carries_the_real_transfer_and_empty_extra_args() {
281        let probe = ti::TransferFactory_Transfer {
282            expected_admin: rt::Party::parse("dso::1220ef").expect("a party"),
283            transfer: a_transfer(),
284            extra_args: empty_extra_args(),
285        };
286        let json = serde_json::to_value(&probe).unwrap();
287
288        assert_eq!(
289            json["extraArgs"]["context"]["values"],
290            serde_json::json!({})
291        );
292        assert_eq!(json["extraArgs"]["meta"]["values"], serde_json::json!({}));
293        assert_eq!(json["transfer"]["sender"], "alice::1220ab");
294        assert_eq!(json["transfer"]["receiver"], "bob::1220cd");
295        assert_eq!(json["expectedAdmin"], "dso::1220ef");
296    }
297
298    /// LF-JSON carries a `Numeric` as a string, and the registry parses it with
299    /// the Daml JSON API's rules. A bare number would be a different encoding.
300    #[test]
301    fn the_amount_is_encoded_as_the_daml_json_api_encodes_a_numeric() {
302        let json = serde_json::to_value(a_transfer()).unwrap();
303        assert_eq!(json["amount"], "10.0");
304    }
305
306    /// The context the registry returns must reach the choice — that is the
307    /// entire point of the round trip. If it were dropped, the submission would
308    /// fail during interpretation with a message about a missing contract
309    /// rather than about a missing context.
310    #[test]
311    fn the_returned_context_is_what_gets_exercised() {
312        let wire = crate::WireChoiceContext {
313            choice_context_data: serde_json::json!({
314                "values": { "amulet-rules": { "tag": "AV_ContractId", "value": "00rules" } }
315            }),
316            disclosed_contracts: vec![crate::WireDisclosedContract {
317                template_id: "pkg:Splice.AmuletRules:AmuletRules".to_string(),
318                contract_id: "00rules".to_string(),
319                created_event_blob: "AQI=".to_string(),
320                synchronizer_id: "sync::1220ab".to_string(),
321            }],
322        };
323        let context = crate::ChoiceContext::from_wire(wire).unwrap();
324        let decoded: md::ChoiceContext = context.decode().unwrap();
325
326        let choice = ti::TransferFactory_Transfer {
327            expected_admin: rt::Party::parse("dso::1220ef").expect("a party"),
328            transfer: a_transfer(),
329            extra_args: md::ExtraArgs {
330                context: decoded,
331                meta: md::Metadata {
332                    values: rt::TextMap::new(),
333                },
334            },
335        };
336        let json = serde_json::to_value(&choice).unwrap();
337        assert_eq!(
338            json["extraArgs"]["context"]["values"]["amulet-rules"],
339            serde_json::json!({ "tag": "AV_ContractId", "value": "00rules" })
340        );
341    }
342
343    /// A command and the contracts it needs disclosed travel together, and both
344    /// reach either kind of submission. Attaching one without the other is a
345    /// submission that fails at interpretation.
346    #[test]
347    fn a_token_command_carries_its_disclosures_into_both_submission_paths() {
348        let disclosed = vec![pb::DisclosedContract {
349            template_id: None,
350            contract_id: "00rules".to_string(),
351            created_event_blob: vec![1, 2],
352            synchronizer_id: "sync".to_string(),
353        }];
354        let command = TokenCommand::new(
355            pb::Command { command: None },
356            disclosed.clone(),
357            Some(TransferKind::Direct),
358        );
359
360        assert_eq!(command.transfer_kind(), Some(TransferKind::Direct));
361        assert_eq!(command.disclosed_contracts().len(), 1);
362
363        let (_, taken) = command.clone().into_parts();
364        assert_eq!(taken, disclosed);
365
366        // Both paths keep them: the interactive one is what an externally
367        // signed party uses, and it would be easy to wire only the ordinary
368        // one.
369        let prepared = command.clone().into_prepare("alice::1220ab");
370        assert_eq!(prepared.disclosed_contracts().len(), 1);
371    }
372}