partner_chains_cardano_offchain/reserve/
release.rs

1#![allow(rustdoc::private_intra_doc_links)]
2// This transaction releases some funds to the illiquid supply.
3//! Inputs:
4//!     - previous utxo at the *reserve validator*, containing the reserve tokens and the
5//!       [ReserveData] plutus data with reserve configuration and release stats
6//! Reference inputs:
7//!     - utxo with V-Function reference script matching the hash saved in the input [ReserveData].
8//!       IMPORTANT: The V-Function script will evaluate against the total number of tokens that
9//!                  should have been released up to now.
10//!                  The number of tokens released in a single transaction equals
11//!                  `<current v-function value> - <number of previously released tokens>`.
12//!     - utxo with authentication policy reference script
13//!     - utxo with validator version policy reference script
14//!     - utxo with illiquid supply validator reference script
15//! Outputs:
16//!     - utxo at the *reserve validator* containing the rest of unreleased tokens and the
17//!       updated [ReserveData] plutus data
18//!     - utxo at the *illiquid supply validator* containing the newly released tokens
19//! Mints:
20//!     - V-Function tokens in the number equal to *the total number of reserve tokens released
21//!       including the ones released in this transaction*. Ie. if N tokens were already released
22//!       and M tokens are being released, the transaction should mint N+M V-Function tokens.
23//!       These tokens are worthless and don't serve any purpose after the transaction is done.
24use super::{ReserveData, add_reserve_utxo_input_with_validator_script_reference};
25use crate::{
26	await_tx::AwaitTx,
27	bridge::{ICSData, add_ics_utxo_input_with_validator_script_reference, select_utxo_to_spend},
28	cardano_keys::CardanoPaymentSigningKey,
29	csl::*,
30	plutus_script::PlutusScript,
31	reserve::ReserveUtxo,
32};
33use anyhow::anyhow;
34use cardano_serialization_lib::{
35	Assets, Int, MultiAsset, PlutusData, Transaction, TransactionBuilder, TransactionOutputBuilder,
36	TxInputsBuilder,
37};
38use ogmios_client::{
39	query_ledger_state::*, query_network::QueryNetwork, transactions::Transactions,
40	types::OgmiosUtxo,
41};
42use partner_chains_plutus_data::{bridge::TokenTransferDatumV1, reserve::ReserveRedeemer};
43use sidechain_domain::{McTxHash, UtxoId};
44use std::num::NonZero;
45
46/// Releases funds from reserve.
47pub async fn release_reserve_funds<
48	T: QueryLedgerState + Transactions + QueryNetwork + QueryUtxoByUtxoId,
49	A: AwaitTx,
50>(
51	amount: NonZero<u64>,
52	genesis_utxo: UtxoId,
53	reference_utxo: UtxoId,
54	payment_key: &CardanoPaymentSigningKey,
55	client: &T,
56	await_tx: &A,
57) -> anyhow::Result<McTxHash> {
58	let ctx = TransactionContext::for_payment_key(payment_key, client).await?;
59	let tip = client.get_tip().await?;
60	let reserve_data = ReserveData::get(genesis_utxo, &ctx, client).await?;
61	let Some(reference_utxo) = client.query_utxo_by_id(reference_utxo).await? else {
62		return Err(anyhow!("Reference utxo {reference_utxo:?} not found on chain"));
63	};
64	let ics_data = ICSData::get(genesis_utxo, &ctx, client).await?;
65
66	let reserve_utxo = reserve_data.get_reserve_utxo(&ctx, client).await?;
67	let ics_utxos = ics_data.get_validator_utxos_with_auth_token(&ctx, client).await?;
68	let ics_utxo = select_utxo_to_spend(&ics_utxos, &ctx).ok_or(anyhow::anyhow!(
69		"Cannot find UTXOs with an 'auth token' at ICS Validator! Is the Bridge initialized?"
70	))?;
71
72	let tx = Costs::calculate_costs(
73		|costs| {
74			reserve_release_tx(
75				&ctx,
76				&reserve_data,
77				&ics_data,
78				&reserve_utxo,
79				&ics_utxo,
80				&reference_utxo,
81				amount.get(),
82				tip.slot,
83				costs,
84			)
85		},
86		client,
87	)
88	.await?;
89
90	let signed_tx = ctx.sign(&tx).to_bytes();
91
92	let res = client.submit_transaction(&signed_tx).await.map_err(|e| {
93		anyhow::anyhow!(
94			"Reserve release transaction request failed: {}, tx bytes: {}",
95			e,
96			hex::encode(signed_tx)
97		)
98	})?;
99	let tx_id = res.transaction.id;
100	log::info!("Reserve release transaction submitted: {}", hex::encode(tx_id));
101	await_tx.await_tx_output(client, McTxHash(tx_id)).await?;
102
103	Ok(McTxHash(tx_id))
104}
105
106fn reserve_release_tx(
107	ctx: &TransactionContext,
108	reserve_data: &ReserveData,
109	ics_data: &ICSData,
110	previous_reserve: &ReserveUtxo,
111	ics_utxo: &OgmiosUtxo,
112	reference_utxo: &OgmiosUtxo,
113	amount_to_transfer: u64,
114	latest_slot: u64,
115	costs: Costs,
116) -> anyhow::Result<Transaction> {
117	let token = &previous_reserve.datum.immutable_settings.token;
118	let stats = &previous_reserve.datum.stats;
119
120	let mut tx_builder = TransactionBuilder::new(&get_builder_config(ctx)?);
121
122	let reserve_balance = previous_reserve.utxo.get_asset_amount(token);
123	let ics_balance = ics_utxo.get_asset_amount(token);
124	let token_total_amount_transferred = stats.token_total_amount_transferred;
125	let cumulative_total_transfer: u64 = token_total_amount_transferred
126		.checked_add(amount_to_transfer)
127		.expect("cumulative_total_transfer can't overflow u64");
128
129	let left_in_reserve = reserve_balance.checked_sub(amount_to_transfer)
130		.ok_or_else(||anyhow!("Not enough funds in the reserve to transfer {amount_to_transfer} tokens (reserve balance: {reserve_balance})"))?;
131
132	// Additional reference scripts
133	tx_builder.add_script_reference_input(
134		&reserve_data.auth_policy_version_utxo.to_csl_tx_input(),
135		reserve_data.scripts.auth_policy.bytes.len(),
136	);
137	tx_builder.add_script_reference_input(
138		&ics_data.validator_version_utxo.to_csl_tx_input(),
139		ics_data.scripts.validator.bytes.len(),
140	);
141	tx_builder.add_script_reference_input(
142		&ics_data.auth_policy_version_utxo.to_csl_tx_input(),
143		reserve_data.scripts.auth_policy.bytes.len(),
144	);
145
146	// Mint v-function tokens in the number equal to the *total* number of tokens transferred.
147	// This serves as a validation of the v-function value.
148	let v_function = v_function_from_utxo(reference_utxo)?;
149	tx_builder.add_mint_script_token_using_reference_script(
150		&Script::Plutus(v_function),
151		&reference_utxo.to_csl_tx_input(),
152		&Int::new(&cumulative_total_transfer.into()),
153		&costs,
154	)?;
155
156	let mut tx_inputs = TxInputsBuilder::new();
157
158	let spend_indices = costs.get_spend_indices();
159
160	let mut spend_costs = vec![
161		costs.get_spend(*spend_indices.get(1).unwrap_or(&0)),
162		costs.get_spend(*spend_indices.get(0).unwrap_or(&0)),
163	];
164
165	spend_costs.sort();
166
167	// Remove tokens from the reserve
168	add_reserve_utxo_input_with_validator_script_reference(
169		&mut tx_inputs,
170		&previous_reserve.utxo,
171		reserve_data,
172		ReserveRedeemer::ReleaseFromReserve,
173		spend_costs.get(1).unwrap(),
174	)?;
175
176	add_ics_utxo_input_with_validator_script_reference(
177		&mut tx_inputs,
178		ics_utxo,
179		ics_data,
180		spend_costs.get(0).unwrap(),
181	)?;
182
183	tx_builder.set_inputs(&tx_inputs);
184
185	let mut ics_tokens = ics_utxo
186		.value
187		.to_csl()?
188		.multiasset()
189		.expect("ICS UTXO should have native tokens");
190
191	let mut assets = Assets::new();
192
193	assets.insert(
194		&token.asset_name.to_csl()?,
195		&amount_to_transfer.saturating_add(ics_balance).into(),
196	);
197
198	ics_tokens.insert(&token.policy_id.0.into(), &assets);
199
200	// Transfer released tokens to the illiquid supply
201	tx_builder.add_output(&{
202		TransactionOutputBuilder::new()
203			.with_address(&ics_data.scripts.validator.address(ctx.network))
204			.with_plutus_data(&TokenTransferDatumV1::ReserveTransfer.into())
205			.next()?
206			.with_minimum_ada_and_asset(&ics_tokens, ctx)?
207			.build()?
208	})?;
209
210	// Return the rest of the tokens back to the reserve
211	tx_builder.add_output(&{
212		TransactionOutputBuilder::new()
213			.with_address(&reserve_data.scripts.validator.address(ctx.network))
214			.with_plutus_data(&PlutusData::from(
215				previous_reserve.datum.clone().after_withdrawal(amount_to_transfer),
216			))
217			.next()?
218			.with_minimum_ada_and_asset(
219				&MultiAsset::from_ogmios_utxo(&previous_reserve.utxo)?
220					.with_asset_amount(token, left_in_reserve)?,
221				ctx,
222			)?
223			.build()?
224	})?;
225
226	tx_builder.set_validity_start_interval_bignum(latest_slot.into());
227
228	Ok(tx_builder.balance_update_and_build(ctx)?.remove_native_script_witnesses())
229}
230
231fn v_function_from_utxo(utxo: &OgmiosUtxo) -> anyhow::Result<PlutusScript> {
232	let Some(v_function_script) = utxo.script.clone() else {
233		return Err(anyhow!("V-Function reference script missing from the reference UTXO",));
234	};
235	PlutusScript::try_from(v_function_script)
236		.map_err(|val| anyhow!("{val:?} is not a valid Plutus Script"))
237}
238
239#[cfg(test)]
240mod tests {
241	use super::{AssetNameExt, Costs, TransactionContext, empty_asset_name, reserve_release_tx};
242	use crate::plutus_script;
243	use crate::{
244		bridge::ICSData,
245		cardano_keys::CardanoPaymentSigningKey,
246		plutus_script::PlutusScript,
247		reserve::{ReserveData, ReserveUtxo, release::OgmiosUtxoExt},
248		scripts_data::{ICSScripts, ReserveScripts},
249		test_values::{payment_addr, protocol_parameters},
250	};
251	use cardano_serialization_lib::{Int, NetworkIdKind, PolicyID, Transaction};
252	use hex_literal::hex;
253	use ogmios_client::types::{Asset, OgmiosTx, OgmiosUtxo, OgmiosValue};
254	use partner_chains_plutus_data::reserve::{
255		ReserveDatum, ReserveImmutableSettings, ReserveMutableSettings, ReserveStats,
256	};
257	use pretty_assertions::assert_eq;
258	use raw_scripts::{
259		EXAMPLE_V_FUNCTION_POLICY, ILLIQUID_CIRCULATION_SUPPLY_AUTHORITY_TOKEN_POLICY,
260		ILLIQUID_CIRCULATION_SUPPLY_VALIDATOR, RESERVE_AUTH_POLICY, RESERVE_VALIDATOR,
261	};
262	use sidechain_domain::{AssetName, PolicyId};
263
264	fn payment_key() -> CardanoPaymentSigningKey {
265		CardanoPaymentSigningKey::from_normal_bytes(hex!(
266			"94f7531c9639654b77fa7e10650702b6937e05cd868f419f54bcb8368e413f04"
267		))
268		.unwrap()
269	}
270
271	fn test_address_bech32() -> String {
272		"addr_test1vpmd59ajuvm34d723r8q2qzyz9ylq0x9pygqn7vun8qgpkgs7y5hw".into()
273	}
274
275	fn payment_utxo() -> OgmiosUtxo {
276		OgmiosUtxo {
277			transaction: OgmiosTx {
278				id: hex!("f5e751f474e909419c714bb5666a8f810e7ed61fadad236c29f67dafc1ff398b"),
279			},
280			index: 1,
281			value: OgmiosValue {
282				lovelace: 994916563,
283				native_tokens: [(
284					// random native token
285					hex!("08b95138e16a062fa8d623a2b1beebd59c06210f3d33690580733e73"),
286					vec![Asset { name: vec![], amount: 1 }],
287				)]
288				.into(),
289			},
290			address: test_address_bech32(),
291
292			..OgmiosUtxo::default()
293		}
294	}
295
296	fn tx_context() -> TransactionContext {
297		TransactionContext {
298			payment_key: payment_key(),
299			payment_key_utxos: vec![payment_utxo()],
300			network: NetworkIdKind::Testnet,
301			protocol_parameters: protocol_parameters(),
302			change_address: payment_addr(),
303		}
304	}
305
306	fn reserve_validator_script() -> PlutusScript {
307		RESERVE_VALIDATOR.into()
308	}
309
310	fn auth_policy_script() -> PlutusScript {
311		RESERVE_AUTH_POLICY.into()
312	}
313
314	fn illiquid_supply_validator_script() -> PlutusScript {
315		ILLIQUID_CIRCULATION_SUPPLY_VALIDATOR.into()
316	}
317
318	fn illiquid_supply_auth_token_policy_script() -> PlutusScript {
319		ILLIQUID_CIRCULATION_SUPPLY_AUTHORITY_TOKEN_POLICY.into()
320	}
321
322	const UNIX_T0: u64 = 1736504093000u64;
323
324	fn applied_v_function() -> PlutusScript {
325		plutus_script![EXAMPLE_V_FUNCTION_POLICY, UNIX_T0].unwrap()
326	}
327
328	fn version_oracle_address() -> String {
329		"addr_test1wqskkgpmsyf0yr2renk0spgsvea75rkq4yvalrpwwudwr5ga3relp".to_string()
330	}
331
332	fn ics_validator_address() -> String {
333		"addr_test1wz7s8uzkldpv7rvyu6v8wg4vk3ulxy23kzqyngk2yc76xucestz8f".to_string()
334	}
335
336	fn reserve_data() -> ReserveData {
337		ReserveData {
338			scripts: ReserveScripts {
339				validator: reserve_validator_script(),
340				auth_policy: auth_policy_script(),
341			},
342			auth_policy_version_utxo: OgmiosUtxo {
343				transaction: OgmiosTx {
344					id: hex!("d1030b0ce5cf33d97a6e8aafa1cfe150e7a8b3a5584bd7a345743938e78ec44b"),
345				},
346				index: 0,
347				address: version_oracle_address(),
348				script: Some(auth_policy_script().into()),
349				..Default::default()
350			},
351			validator_version_utxo: OgmiosUtxo {
352				transaction: OgmiosTx {
353					id: hex!("fcb5d7877e6ce7cfaef579c4f4b5fdbbdb807e4fe613752671742f1a5191c850"),
354				},
355				index: 0,
356				address: version_oracle_address(),
357				script: Some(reserve_validator_script().into()),
358				..Default::default()
359			},
360		}
361	}
362
363	fn ics_data() -> ICSData {
364		ICSData {
365			scripts: ICSScripts {
366				validator: illiquid_supply_validator_script(),
367				auth_policy: illiquid_supply_auth_token_policy_script(),
368			},
369			auth_policy_version_utxo: OgmiosUtxo {
370				transaction: OgmiosTx {
371					id: hex!("f5890475177fcc7cf40679974751f66331c7b25fcf2f1a148c53cf7e0e147114"),
372				},
373				index: 1,
374				address: version_oracle_address(),
375				script: Some(illiquid_supply_auth_token_policy_script().into()),
376				..Default::default()
377			},
378			validator_version_utxo: OgmiosUtxo {
379				transaction: OgmiosTx {
380					id: hex!("f5890475177fcc7cf40679974751f66331c7b25fcf2f1a148c53cf7e0e147114"),
381				},
382				index: 0,
383				address: version_oracle_address(),
384				script: Some(illiquid_supply_validator_script().into()),
385				..Default::default()
386			},
387		}
388	}
389
390	fn reference_utxo() -> OgmiosUtxo {
391		OgmiosUtxo {
392			transaction: OgmiosTx {
393				id: hex!("45882cfd2de9381f34ae68ad073452e2a57a7ad11095dae49f365266637e9d04"),
394			},
395			index: 0,
396			script: Some(applied_v_function().into()),
397			..Default::default()
398		}
399	}
400
401	fn previous_reserve_ogmios_utxo() -> OgmiosUtxo {
402		OgmiosUtxo {
403			transaction: OgmiosTx {
404				id: hex!("23de508bbfeb6af651da305a2de022463f71e47d58365eba36d98fa6c4aed731"),
405			},
406			value: OgmiosValue {
407				lovelace: 1672280,
408				native_tokens: [
409					(
410						// reserve token
411						token_policy().0,
412						vec![Asset { name: token_name().0.to_vec(), amount: 990 }],
413					),
414					(
415						// leftover governance token - should be returned to the validator
416						hex!("75b8875ff8958c66fecbd93740ac5ffd7370d299e729a46bb5632066"),
417						vec![Asset { name: vec![], amount: 1 }],
418					),
419				]
420				.into(),
421			},
422
423			index: 1,
424			..Default::default()
425		}
426	}
427
428	fn previous_reserve_utxo() -> ReserveUtxo {
429		ReserveUtxo { utxo: previous_reserve_ogmios_utxo(), datum: previous_reserve_datum() }
430	}
431
432	fn ics_utxo() -> OgmiosUtxo {
433		OgmiosUtxo {
434			transaction: OgmiosTx {
435				id: hex!("23de508bbfeb6af651da305a2de022463f71e47d58365eba36d98fa6c4aed731"),
436			},
437			value: OgmiosValue {
438				lovelace: 1672280,
439				native_tokens: [(
440					// reserve token
441					ics_auth_token_policy().0,
442					vec![Asset { name: vec![], amount: 1 }],
443				)]
444				.into(),
445			},
446
447			index: 2,
448			address: ics_validator_address(),
449			..Default::default()
450		}
451	}
452
453	fn token_policy() -> PolicyId {
454		PolicyId(hex!("1fab25f376bc49a181d03a869ee8eaa3157a3a3d242a619ca7995b2b"))
455	}
456
457	fn ics_auth_token_policy() -> PolicyId {
458		PolicyId(hex!("d4a1d93484fa63847f8c4c271dc0c46a55b6e5916f46e14ee849a381"))
459	}
460
461	fn token_name() -> AssetName {
462		AssetName::from_hex_unsafe("52657761726420746f6b656e")
463	}
464
465	fn token_id() -> sidechain_domain::AssetId {
466		sidechain_domain::AssetId { policy_id: token_policy(), asset_name: token_name() }
467	}
468
469	fn previous_reserve_datum() -> ReserveDatum {
470		ReserveDatum {
471			immutable_settings: ReserveImmutableSettings { token: token_id() },
472			mutable_settings: ReserveMutableSettings {
473				total_accrued_function_asset_name: applied_v_function().policy_id(),
474				initial_incentive: 0,
475			},
476			stats: ReserveStats { token_total_amount_transferred: 10 },
477		}
478	}
479
480	fn reserve_release_test_tx() -> Transaction {
481		reserve_release_tx(
482			&tx_context(),
483			&reserve_data(),
484			&ics_data(),
485			&previous_reserve_utxo(),
486			&ics_utxo(),
487			&reference_utxo(),
488			5,
489			0,
490			Costs::ZeroCosts,
491		)
492		.unwrap()
493	}
494
495	#[test]
496	fn should_have_correct_reference_utxos() {
497		let ref_inputs: Vec<_> = reserve_release_test_tx()
498			.body()
499			.reference_inputs()
500			.expect("Should have reference inputs")
501			.into_iter()
502			.cloned()
503			.collect();
504
505		assert!(ref_inputs.contains(&reference_utxo().to_csl_tx_input()));
506		assert!(ref_inputs.contains(&reserve_data().auth_policy_version_utxo.to_csl_tx_input()));
507		assert!(ref_inputs.contains(&reserve_data().validator_version_utxo.to_csl_tx_input()));
508		assert!(ref_inputs.contains(&ics_data().auth_policy_version_utxo.to_csl_tx_input()));
509		assert!(ref_inputs.contains(&ics_data().validator_version_utxo.to_csl_tx_input()));
510		assert_eq!(ref_inputs.len(), 5)
511	}
512
513	#[test]
514	fn should_mint_v_function_scripts() {
515		let v_function_token_mint_amount = reserve_release_test_tx()
516			.body()
517			.mint()
518			.expect("Should mint a token")
519			.get(&applied_v_function().csl_script_hash())
520			.and_then(|policy| policy.get(0))
521			.expect("Should mint a v-function policy token")
522			.get(&empty_asset_name())
523			.expect("The minted token should have an empty asset name");
524
525		assert_eq!(v_function_token_mint_amount, Int::new_i32(15))
526	}
527
528	#[test]
529	fn should_burn_previoius_reserve_utxo() {
530		let inputs: Vec<_> =
531			reserve_release_test_tx().body().inputs().into_iter().cloned().collect();
532
533		assert!(inputs.contains(&previous_reserve_utxo().utxo.to_csl_tx_input()))
534	}
535
536	#[test]
537	fn should_add_correct_number_of_tokens_to_illiquid_supply() {
538		let illiquid_supply_output = (reserve_release_test_tx().body().outputs().into_iter())
539			.find(|output| {
540				output.address()
541					== illiquid_supply_validator_script().address(NetworkIdKind::Testnet)
542			})
543			.expect("Should output a UTXO to illiquid supply validator")
544			.amount()
545			.multiasset()
546			.expect("Should output native tokens to illiquid supply")
547			.get(&PolicyID::from(token_policy().0))
548			.expect("Should transfer reserve token policy token to illiquid supply")
549			.get(&token_name().to_csl().unwrap())
550			.expect("Should transfer reserve token to illiquid supply");
551
552		assert_eq!(illiquid_supply_output, 5u64.into())
553	}
554
555	#[test]
556	fn should_leave_unreleased_tokens_at_reserve_validator() {
557		let validator_output = (reserve_release_test_tx().body().outputs().into_iter())
558			.find(|output| {
559				output.address() == reserve_validator_script().address(NetworkIdKind::Testnet)
560			})
561			.expect("Should output a UTXO to illiquid supply validator")
562			.amount()
563			.multiasset()
564			.expect("Should output native tokens to illiquid supply")
565			.get(&PolicyID::from(token_policy().0))
566			.expect("Should transfer reserve token policy token to illiquid supply")
567			.get(&token_name().to_csl().unwrap())
568			.expect("Should transfer reserve token to illiquid supply");
569
570		assert_eq!(validator_output, 985u64.into())
571	}
572}