Skip to main content

canton_token/
v2.rs

1//! CIP-0112 — the V2 token standard.
2//!
3//! The shape is the one V1 established: ask the registry for a factory, take
4//! the choice context it returns, exercise the choice with the contracts it
5//! said to disclose. What changed is worth stating, because a caller porting
6//! from V1 meets all of it at once.
7//!
8//! * **Accounts, not parties.** A V2 [`Transfer`](ti::Transfer) moves between
9//!   two [`Account`](h::Account)s. A party can hold several, so "who owns this"
10//!   and "where does it live" stopped being the same question. Both the owner
11//!   and the provider of an account are *optional* — an account held at a
12//!   provider can be named by its `id` alone, which is what lets a registry
13//!   route to an account whose owning party it does not need to state.
14//! * **Every choice names its actors.** V2 choices carry `actors: Vec<Party>` —
15//!   who is exercising, which V1 left implicit in the submitting party.
16//! * **Settlement moved to a factory.** A V1 allocation is executed through a
17//!   choice on itself; a V2 one is settled as part of a *batch*, through
18//!   [`settle_batch`]. That is what lets both legs of a delivery-versus-payment
19//!   settle together instead of one at a time.
20//! * **Allocation instructions have their own choices.** V1 drove them entirely
21//!   through the factory.
22//!
23//! Every path and payload here comes from the V2 OpenAPI documents in the
24//! Splice repository, not from V1 with a digit changed — the two differ in ways
25//! a search-and-replace would get wrong.
26
27pub mod events;
28
29use canton_core::Result;
30use canton_daml as rt;
31use canton_splice_api_token_allocation_instruction_v2::splice_api_token_allocation_instruction_v2::Splice_Api_Token_AllocationInstructionV2 as ai;
32use canton_splice_api_token_allocation_v2::splice_api_token_allocation_v2::Splice_Api_Token_AllocationV2 as al;
33use canton_splice_api_token_holding_v2::splice_api_token_holding_v2::Splice_Api_Token_HoldingV2 as h;
34use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1 as md;
35use canton_splice_api_token_transfer_instruction_v2::splice_api_token_transfer_instruction_v2::Splice_Api_Token_TransferInstructionV2 as ti;
36
37use crate::registry::{
38    AllocationChoice, AllocationInstructionChoice, RegistryClient, TransferInstructionChoice,
39};
40use crate::transfer::{TokenCommand, empty_extra_args};
41
42/// Resolve the V2 transfer factory and build the exercise that performs
43/// `transfer`.
44///
45/// `actors` are the parties exercising the choice — usually just the sender.
46/// V1 left this implicit in the submitting party; V2 states it.
47///
48/// # Errors
49/// As any registry call, plus [`canton_core::Error::UnexpectedResponse`] if the
50/// context does not decode.
51pub async fn transfer(
52    registry: &RegistryClient,
53    transfer: ti::Transfer,
54    actors: Vec<rt::Party>,
55) -> Result<TokenCommand> {
56    let probe = ti::TransferFactory_Transfer {
57        transfer,
58        actors,
59        extra_args: empty_extra_args(),
60    };
61    let arguments = serde_json::to_value(&probe).map_err(|e| {
62        canton_core::Error::InvalidRequest(format!("the transfer does not serialize: {e}"))
63    })?;
64
65    let factory = registry.transfer_factory_v2(&arguments).await?;
66    let choice = ti::TransferFactory_Transfer {
67        extra_args: md::ExtraArgs {
68            context: factory.context.decode()?,
69            meta: probe.extra_args.meta,
70        },
71        ..probe
72    };
73    let factory_id = rt::ContractId::<ti::TransferFactory>::new(factory.factory_id);
74
75    Ok(TokenCommand::new(
76        rt::exercise_command(&factory_id, &choice),
77        factory.context.into_disclosed_contracts(),
78        factory.transfer_kind,
79    ))
80}
81
82/// Accept an offered V2 transfer — the receiver's move.
83///
84/// # Errors
85/// As any registry call.
86pub async fn accept(
87    registry: &RegistryClient,
88    instruction_id: &rt::ContractId<ti::TransferInstruction>,
89    actors: Vec<rt::Party>,
90) -> Result<TokenCommand> {
91    on_instruction(
92        registry,
93        instruction_id,
94        TransferInstructionChoice::Accept,
95        |extra_args| ti::TransferInstruction_Accept { actors, extra_args },
96    )
97    .await
98}
99
100/// Reject an offered V2 transfer.
101///
102/// # Errors
103/// As any registry call.
104pub async fn reject(
105    registry: &RegistryClient,
106    instruction_id: &rt::ContractId<ti::TransferInstruction>,
107    actors: Vec<rt::Party>,
108) -> Result<TokenCommand> {
109    on_instruction(
110        registry,
111        instruction_id,
112        TransferInstructionChoice::Reject,
113        |extra_args| ti::TransferInstruction_Reject { actors, extra_args },
114    )
115    .await
116}
117
118/// Withdraw an offered V2 transfer — the sender's move.
119///
120/// # Errors
121/// As any registry call.
122pub async fn withdraw(
123    registry: &RegistryClient,
124    instruction_id: &rt::ContractId<ti::TransferInstruction>,
125    actors: Vec<rt::Party>,
126) -> Result<TokenCommand> {
127    on_instruction(
128        registry,
129        instruction_id,
130        TransferInstructionChoice::Withdraw,
131        |extra_args| ti::TransferInstruction_Withdraw { actors, extra_args },
132    )
133    .await
134}
135
136/// Resolve the V2 allocation factory and build the exercise that allocates.
137///
138/// A V2 allocation names the `settlement` it belongs to at the moment it is
139/// created. V1 allocated first and settled against whatever came later; naming
140/// it up front is what lets the settlement factory later check that every leg
141/// of a batch was allocated for *this* settlement.
142///
143/// # Errors
144/// As any registry call.
145pub async fn allocate(
146    registry: &RegistryClient,
147    settlement: al::SettlementInfo,
148    allocation: al::AllocationSpecification,
149    requested_at: rt::Timestamp,
150    input_holding_cids: Vec<rt::ContractId<h::Holding>>,
151    actors: Vec<rt::Party>,
152) -> Result<TokenCommand> {
153    use canton_splice_api_token_allocation_instruction_v2::splice_api_token_allocation_instruction_v2::Splice_Api_Token_AllocationInstructionV2 as ai;
154
155    let probe = ai::AllocationFactory_Allocate {
156        settlement,
157        allocation,
158        requested_at,
159        input_holding_cids,
160        actors,
161        extra_args: empty_extra_args(),
162    };
163    let arguments = serde_json::to_value(&probe).map_err(|e| {
164        canton_core::Error::InvalidRequest(format!("the allocation does not serialize: {e}"))
165    })?;
166
167    let factory = registry.allocation_factory_v2(&arguments).await?;
168    let choice = ai::AllocationFactory_Allocate {
169        extra_args: md::ExtraArgs {
170            context: factory.context.decode()?,
171            meta: probe.extra_args.meta,
172        },
173        ..probe
174    };
175    let factory_id = rt::ContractId::<ai::AllocationFactory>::new(factory.factory_id);
176
177    Ok(TokenCommand::new(
178        rt::exercise_command(&factory_id, &choice),
179        factory.context.into_disclosed_contracts(),
180        None,
181    ))
182}
183
184/// Settle a batch of allocations through the settlement factory — the
185/// executor's move, and the V2 replacement for V1's execute-transfer.
186///
187/// Settling a *batch* is what makes delivery-versus-payment atomic: both legs
188/// move in one transaction, or neither does. V1 could only execute one
189/// allocation at a time.
190///
191/// # Errors
192/// As any registry call.
193pub async fn settle_batch(
194    registry: &RegistryClient,
195    settlement: al::SettlementInfo,
196    transfer_legs: Vec<al::TransferLeg>,
197    allocations: Vec<al::FinalizedAllocation>,
198    actors: Vec<rt::Party>,
199) -> Result<TokenCommand> {
200    let probe = al::SettlementFactory_SettleBatch {
201        settlement,
202        transfer_legs,
203        allocations,
204        actors,
205        extra_args: empty_extra_args(),
206    };
207    let arguments = serde_json::to_value(&probe).map_err(|e| {
208        canton_core::Error::InvalidRequest(format!("the settlement does not serialize: {e}"))
209    })?;
210
211    let factory = registry.settlement_factory_v2(&arguments).await?;
212    let choice = al::SettlementFactory_SettleBatch {
213        extra_args: md::ExtraArgs {
214            context: factory.context.decode()?,
215            meta: probe.extra_args.meta,
216        },
217        ..probe
218    };
219    let factory_id = rt::ContractId::<al::SettlementFactory>::new(factory.factory_id);
220
221    Ok(TokenCommand::new(
222        rt::exercise_command(&factory_id, &choice),
223        factory.context.into_disclosed_contracts(),
224        None,
225    ))
226}
227
228/// Withdraw a V2 allocation, releasing the holdings — the sender's move.
229///
230/// # Errors
231/// As any registry call.
232pub async fn withdraw_allocation(
233    registry: &RegistryClient,
234    allocation_id: &rt::ContractId<al::Allocation>,
235    actors: Vec<rt::Party>,
236) -> Result<TokenCommand> {
237    on_allocation(
238        registry,
239        allocation_id,
240        AllocationChoice::Withdraw,
241        |extra_args| al::Allocation_Withdraw { actors, extra_args },
242    )
243    .await
244}
245
246/// Cancel a V2 allocation — the executor's move.
247///
248/// # Errors
249/// As any registry call.
250pub async fn cancel(
251    registry: &RegistryClient,
252    allocation_id: &rt::ContractId<al::Allocation>,
253    actors: Vec<rt::Party>,
254) -> Result<TokenCommand> {
255    on_allocation(
256        registry,
257        allocation_id,
258        AllocationChoice::Cancel,
259        |extra_args| al::Allocation_Cancel { actors, extra_args },
260    )
261    .await
262}
263
264/// Accept a pending V2 allocation instruction — the registry asked the
265/// allocating party to confirm, and this is the confirmation.
266///
267/// V1 has no equivalent: an allocation instruction there is driven entirely
268/// through the factory, so there is nothing to accept.
269///
270/// # Errors
271/// As any registry call.
272pub async fn accept_allocation_instruction(
273    registry: &RegistryClient,
274    instruction_id: &rt::ContractId<ai::AllocationInstruction>,
275    actors: Vec<rt::Party>,
276) -> Result<TokenCommand> {
277    on_allocation_instruction(
278        registry,
279        instruction_id,
280        AllocationInstructionChoice::Accept,
281        |extra_args| ai::AllocationInstruction_Accept { actors, extra_args },
282    )
283    .await
284}
285
286/// Withdraw a pending V2 allocation instruction.
287///
288/// # Errors
289/// As any registry call.
290pub async fn withdraw_allocation_instruction(
291    registry: &RegistryClient,
292    instruction_id: &rt::ContractId<ai::AllocationInstruction>,
293    actors: Vec<rt::Party>,
294) -> Result<TokenCommand> {
295    on_allocation_instruction(
296        registry,
297        instruction_id,
298        AllocationInstructionChoice::Withdraw,
299        |extra_args| ai::AllocationInstruction_Withdraw { actors, extra_args },
300    )
301    .await
302}
303
304/// The shape every choice on an existing V2 transfer instruction shares.
305async fn on_instruction<C>(
306    registry: &RegistryClient,
307    instruction_id: &rt::ContractId<ti::TransferInstruction>,
308    choice: TransferInstructionChoice,
309    build: impl FnOnce(md::ExtraArgs) -> C,
310) -> Result<TokenCommand>
311where
312    C: rt::Choice<ti::TransferInstruction> + rt::ToValue,
313{
314    let context = registry
315        .transfer_instruction_context_v2(
316            instruction_id.as_str(),
317            choice,
318            &crate::ChoiceContextRequest::default(),
319        )
320        .await?;
321    let argument = build(md::ExtraArgs {
322        context: context.decode()?,
323        meta: md::Metadata {
324            values: rt::TextMap::new(),
325        },
326    });
327    Ok(TokenCommand::new(
328        rt::exercise_command(instruction_id, &argument),
329        context.into_disclosed_contracts(),
330        None,
331    ))
332}
333
334/// And the shape the two choices on an existing V2 allocation instruction
335/// share.
336async fn on_allocation_instruction<C>(
337    registry: &RegistryClient,
338    instruction_id: &rt::ContractId<ai::AllocationInstruction>,
339    choice: AllocationInstructionChoice,
340    build: impl FnOnce(md::ExtraArgs) -> C,
341) -> Result<TokenCommand>
342where
343    C: rt::Choice<ai::AllocationInstruction> + rt::ToValue,
344{
345    let context = registry
346        .allocation_instruction_context_v2(
347            instruction_id.as_str(),
348            choice,
349            &crate::ChoiceContextRequest::default(),
350        )
351        .await?;
352    let argument = build(md::ExtraArgs {
353        context: context.decode()?,
354        meta: md::Metadata {
355            values: rt::TextMap::new(),
356        },
357    });
358    Ok(TokenCommand::new(
359        rt::exercise_command(instruction_id, &argument),
360        context.into_disclosed_contracts(),
361        None,
362    ))
363}
364
365/// And the shape every choice on an existing V2 allocation shares.
366///
367/// `execute-transfer` is deliberately absent: V2 settles through
368/// [`settle_batch`], and asking for that context is refused by the registry
369/// client with a message naming the replacement.
370async fn on_allocation<C>(
371    registry: &RegistryClient,
372    allocation_id: &rt::ContractId<al::Allocation>,
373    choice: AllocationChoice,
374    build: impl FnOnce(md::ExtraArgs) -> C,
375) -> Result<TokenCommand>
376where
377    C: rt::Choice<al::Allocation> + rt::ToValue,
378{
379    let context = registry
380        .allocation_context_v2(
381            allocation_id.as_str(),
382            choice,
383            &crate::ChoiceContextRequest::default(),
384        )
385        .await?;
386    let argument = build(md::ExtraArgs {
387        context: context.decode()?,
388        meta: md::Metadata {
389            values: rt::TextMap::new(),
390        },
391    });
392    Ok(TokenCommand::new(
393        rt::exercise_command(allocation_id, &argument),
394        context.into_disclosed_contracts(),
395        None,
396    ))
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used, clippy::expect_used)]
401mod tests {
402    use super::*;
403
404    fn party(s: &str) -> rt::Party {
405        rt::Party::parse(s).expect("a party")
406    }
407
408    fn account(owner: &str) -> h::Account {
409        h::Account {
410            owner: Some(party(owner)),
411            provider: None,
412            id: "main".to_string(),
413        }
414    }
415
416    /// The Account model is the visible difference from V1: a transfer moves
417    /// between accounts, and a party can hold more than one.
418    #[test]
419    fn a_v2_transfer_moves_between_accounts_rather_than_parties() {
420        let transfer = ti::Transfer {
421            sender: account("alice::1220ab"),
422            receiver: account("bob::1220cd"),
423            amount: "10.0".parse().expect("a numeric"),
424            instrument_id: h::InstrumentId {
425                admin: party("dso::1220ef"),
426                id: "Amulet".to_string(),
427            },
428            requested_at: rt::Timestamp(0),
429            execute_before: rt::Timestamp(1_000_000),
430            input_holding_cids: Vec::new(),
431            meta: md::Metadata {
432                values: rt::TextMap::new(),
433            },
434        };
435        let json = serde_json::to_value(&transfer).unwrap();
436
437        // Not a bare party string, as V1 encodes it — an object with an owner
438        // and an account id.
439        assert_eq!(json["sender"]["owner"], "alice::1220ab");
440        assert_eq!(json["sender"]["id"], "main");
441        assert_eq!(json["receiver"]["owner"], "bob::1220cd");
442        assert_eq!(json["amount"], "10.0");
443    }
444
445    /// Every V2 choice names who is exercising it. V1 left that implicit in the
446    /// submitting party, so a caller porting across has to supply it.
447    #[test]
448    fn every_v2_choice_names_its_actors() {
449        let probe = ti::TransferFactory_Transfer {
450            transfer: ti::Transfer {
451                sender: account("alice::1220ab"),
452                receiver: account("bob::1220cd"),
453                amount: "1.0".parse().expect("a numeric"),
454                instrument_id: h::InstrumentId {
455                    admin: party("dso::1220ef"),
456                    id: "Amulet".to_string(),
457                },
458                requested_at: rt::Timestamp(0),
459                execute_before: rt::Timestamp(1),
460                input_holding_cids: Vec::new(),
461                meta: md::Metadata {
462                    values: rt::TextMap::new(),
463                },
464            },
465            actors: vec![party("alice::1220ab")],
466            extra_args: empty_extra_args(),
467        };
468        let json = serde_json::to_value(&probe).unwrap();
469        assert_eq!(json["actors"], serde_json::json!(["alice::1220ab"]));
470        // And the probe still carries empty extraArgs, as the standard says.
471        assert_eq!(
472            json["extraArgs"]["context"]["values"],
473            serde_json::json!({})
474        );
475    }
476
477    /// The V2 choices are the ones the standard names, and they are not V1's.
478    #[test]
479    fn the_v2_choices_are_named_as_the_ledger_knows_them() {
480        assert_eq!(
481            <al::Allocation_Settle as rt::Choice<al::Allocation>>::NAME,
482            "Allocation_Settle"
483        );
484        assert_eq!(
485            <al::SettlementFactory_SettleBatch as rt::Choice<al::SettlementFactory>>::NAME,
486            "SettlementFactory_SettleBatch"
487        );
488        assert_eq!(
489            <ti::TransferInstruction_Accept as rt::Choice<ti::TransferInstruction>>::NAME,
490            "TransferInstruction_Accept"
491        );
492    }
493}