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, reserve_utxo_input_with_validator_script_reference};
25use crate::{
26	await_tx::AwaitTx, cardano_keys::CardanoPaymentSigningKey, csl::*, plutus_script::PlutusScript,
27	reserve::ReserveUtxo,
28};
29use anyhow::anyhow;
30use cardano_serialization_lib::{
31	Int, MultiAsset, PlutusData, Transaction, TransactionBuilder, TransactionOutputBuilder,
32};
33use ogmios_client::{
34	query_ledger_state::*, query_network::QueryNetwork, transactions::Transactions,
35	types::OgmiosUtxo,
36};
37use partner_chains_plutus_data::reserve::ReserveRedeemer;
38use sidechain_domain::{McTxHash, UtxoId};
39use std::num::NonZero;
40
41/// Releases funds from reserve.
42pub async fn release_reserve_funds<
43	T: QueryLedgerState + Transactions + QueryNetwork + QueryUtxoByUtxoId,
44	A: AwaitTx,
45>(
46	amount: NonZero<u64>,
47	genesis_utxo: UtxoId,
48	reference_utxo: UtxoId,
49	payment_key: &CardanoPaymentSigningKey,
50	client: &T,
51	await_tx: &A,
52) -> anyhow::Result<McTxHash> {
53	let ctx = TransactionContext::for_payment_key(payment_key, client).await?;
54	let tip = client.get_tip().await?;
55	let reserve_data = ReserveData::get(genesis_utxo, &ctx, client).await?;
56	let Some(reference_utxo) = client.query_utxo_by_id(reference_utxo).await? else {
57		return Err(anyhow!("Reference utxo {reference_utxo:?} not found on chain"));
58	};
59
60	let reserve_utxo = reserve_data.get_reserve_utxo(&ctx, client).await?;
61
62	let tx = Costs::calculate_costs(
63		|costs| {
64			reserve_release_tx(
65				&ctx,
66				&reserve_data,
67				&reserve_utxo,
68				&reference_utxo,
69				amount.into(),
70				tip.slot,
71				costs,
72			)
73		},
74		client,
75	)
76	.await?;
77
78	let signed_tx = ctx.sign(&tx).to_bytes();
79
80	let res = client.submit_transaction(&signed_tx).await.map_err(|e| {
81		anyhow::anyhow!(
82			"Reserve release transaction request failed: {}, tx bytes: {}",
83			e,
84			hex::encode(signed_tx)
85		)
86	})?;
87	let tx_id = res.transaction.id;
88	log::info!("Reserve release transaction submitted: {}", hex::encode(tx_id));
89	await_tx.await_tx_output(client, UtxoId::new(tx_id, 0)).await?;
90
91	Ok(McTxHash(tx_id))
92}
93
94fn reserve_release_tx(
95	ctx: &TransactionContext,
96	reserve_data: &ReserveData,
97	previous_reserve: &ReserveUtxo,
98	reference_utxo: &OgmiosUtxo,
99	amount_to_transfer: u64,
100	latest_slot: u64,
101	costs: Costs,
102) -> anyhow::Result<Transaction> {
103	let token = &previous_reserve.datum.immutable_settings.token;
104	let stats = &previous_reserve.datum.stats;
105
106	let mut tx_builder = TransactionBuilder::new(&get_builder_config(ctx)?);
107
108	let reserve_balance = previous_reserve.utxo.get_asset_amount(token);
109	let token_total_amount_transferred = stats.token_total_amount_transferred;
110	let cumulative_total_transfer: u64 = token_total_amount_transferred
111		.checked_add(amount_to_transfer)
112		.expect("cumulative_total_transfer can't overflow u64");
113
114	let left_in_reserve = reserve_balance.checked_sub(amount_to_transfer)
115		.ok_or_else(||anyhow!("Not enough funds in the reserve to transfer {amount_to_transfer} tokens (reserve balance: {reserve_balance})"))?;
116
117	// Additional reference scripts
118	tx_builder.add_script_reference_input(
119		&reserve_data.auth_policy_version_utxo.to_csl_tx_input(),
120		reserve_data.scripts.auth_policy.bytes.len(),
121	);
122	tx_builder.add_script_reference_input(
123		&reserve_data
124			.illiquid_circulation_supply_validator_version_utxo
125			.to_csl_tx_input(),
126		reserve_data.scripts.illiquid_circulation_supply_validator.bytes.len(),
127	);
128
129	// Mint v-function tokens in the number equal to the *total* number of tokens transfered.
130	// This serves as a validation of the v-function value.
131	let v_function = v_function_from_utxo(reference_utxo)?;
132	tx_builder.add_mint_script_token_using_reference_script(
133		&Script::Plutus(v_function),
134		&reference_utxo.to_csl_tx_input(),
135		&Int::new(&cumulative_total_transfer.into()),
136		&costs,
137	)?;
138
139	// Remove tokens from the reserve
140	tx_builder.set_inputs(&reserve_utxo_input_with_validator_script_reference(
141		&previous_reserve.utxo,
142		reserve_data,
143		ReserveRedeemer::ReleaseFromReserve,
144		&costs.get_one_spend(),
145	)?);
146
147	// Transfer released tokens to the illiquid supply
148	tx_builder.add_output(&{
149		TransactionOutputBuilder::new()
150			.with_address(
151				&reserve_data.scripts.illiquid_circulation_supply_validator.address(ctx.network),
152			)
153			.with_plutus_data(&PlutusData::new_empty_constr_plutus_data(&0u64.into()))
154			.next()?
155			.with_minimum_ada_and_asset(&token.to_multi_asset(amount_to_transfer)?, ctx)?
156			.build()?
157	})?;
158
159	// Return the rest of the tokens back to the reserve
160	tx_builder.add_output(&{
161		TransactionOutputBuilder::new()
162			.with_address(&reserve_data.scripts.validator.address(ctx.network))
163			.with_plutus_data(&PlutusData::from(
164				previous_reserve.datum.clone().after_withdrawal(amount_to_transfer),
165			))
166			.next()?
167			.with_minimum_ada_and_asset(
168				&MultiAsset::from_ogmios_utxo(&previous_reserve.utxo)?
169					.with_asset_amount(token, left_in_reserve)?,
170				ctx,
171			)?
172			.build()?
173	})?;
174
175	tx_builder.set_validity_start_interval_bignum(latest_slot.into());
176	Ok(tx_builder.balance_update_and_build(ctx)?.remove_native_script_witnesses())
177}
178
179fn v_function_from_utxo(utxo: &OgmiosUtxo) -> anyhow::Result<PlutusScript> {
180	let Some(v_function_script) = utxo.script.clone() else {
181		return Err(anyhow!("V-Function reference script missing from the reference UTXO",));
182	};
183	PlutusScript::try_from(v_function_script)
184		.map_err(|val| anyhow!("{val:?} is not a valid Plutus Script"))
185}
186
187#[cfg(test)]
188mod tests {
189	use super::{AssetNameExt, Costs, TransactionContext, empty_asset_name, reserve_release_tx};
190	use crate::{
191		cardano_keys::CardanoPaymentSigningKey,
192		plutus_script,
193		plutus_script::PlutusScript,
194		reserve::{ReserveData, ReserveUtxo, release::OgmiosUtxoExt},
195		scripts_data::ReserveScripts,
196		test_values::{payment_addr, protocol_parameters},
197	};
198	use cardano_serialization_lib::{Int, NetworkIdKind, PolicyID, Transaction};
199	use hex_literal::hex;
200	use ogmios_client::types::{Asset, OgmiosTx, OgmiosUtxo, OgmiosValue};
201	use partner_chains_plutus_data::reserve::{
202		ReserveDatum, ReserveImmutableSettings, ReserveMutableSettings, ReserveStats,
203	};
204	use pretty_assertions::assert_eq;
205	use raw_scripts::{
206		EXAMPLE_V_FUNCTION_POLICY, ILLIQUID_CIRCULATION_SUPPLY_VALIDATOR, RESERVE_AUTH_POLICY,
207		RESERVE_VALIDATOR,
208	};
209	use sidechain_domain::{AssetName, PolicyId};
210
211	fn payment_key() -> CardanoPaymentSigningKey {
212		CardanoPaymentSigningKey::from_normal_bytes(hex!(
213			"94f7531c9639654b77fa7e10650702b6937e05cd868f419f54bcb8368e413f04"
214		))
215		.unwrap()
216	}
217
218	fn test_address_bech32() -> String {
219		"addr_test1vpmd59ajuvm34d723r8q2qzyz9ylq0x9pygqn7vun8qgpkgs7y5hw".into()
220	}
221
222	fn payment_utxo() -> OgmiosUtxo {
223		OgmiosUtxo {
224			transaction: OgmiosTx {
225				id: hex!("f5e751f474e909419c714bb5666a8f810e7ed61fadad236c29f67dafc1ff398b"),
226			},
227			index: 1,
228			value: OgmiosValue {
229				lovelace: 994916563,
230				native_tokens: [(
231					// random native token
232					hex!("08b95138e16a062fa8d623a2b1beebd59c06210f3d33690580733e73"),
233					vec![Asset { name: vec![], amount: 1 }],
234				)]
235				.into(),
236			},
237			address: test_address_bech32(),
238
239			..OgmiosUtxo::default()
240		}
241	}
242
243	fn tx_context() -> TransactionContext {
244		TransactionContext {
245			payment_key: payment_key(),
246			payment_key_utxos: vec![payment_utxo()],
247			network: NetworkIdKind::Testnet,
248			protocol_parameters: protocol_parameters(),
249			change_address: payment_addr(),
250		}
251	}
252
253	fn reserve_validator_script() -> PlutusScript {
254		RESERVE_VALIDATOR.into()
255	}
256
257	fn auth_policy_script() -> PlutusScript {
258		RESERVE_AUTH_POLICY.into()
259	}
260
261	fn illiquid_supply_validator_script() -> PlutusScript {
262		ILLIQUID_CIRCULATION_SUPPLY_VALIDATOR.into()
263	}
264
265	const UNIX_T0: u64 = 1736504093000u64;
266
267	fn applied_v_function() -> PlutusScript {
268		plutus_script![EXAMPLE_V_FUNCTION_POLICY, UNIX_T0].unwrap()
269	}
270
271	fn version_oracle_address() -> String {
272		"addr_test1wqskkgpmsyf0yr2renk0spgsvea75rkq4yvalrpwwudwr5ga3relp".to_string()
273	}
274
275	fn reserve_data() -> ReserveData {
276		ReserveData {
277			scripts: ReserveScripts {
278				validator: reserve_validator_script(),
279				auth_policy: auth_policy_script(),
280				illiquid_circulation_supply_validator: illiquid_supply_validator_script(),
281			},
282			auth_policy_version_utxo: OgmiosUtxo {
283				transaction: OgmiosTx {
284					id: hex!("d1030b0ce5cf33d97a6e8aafa1cfe150e7a8b3a5584bd7a345743938e78ec44b"),
285				},
286				index: 0,
287				address: version_oracle_address(),
288				script: Some(auth_policy_script().into()),
289				..Default::default()
290			},
291			validator_version_utxo: OgmiosUtxo {
292				transaction: OgmiosTx {
293					id: hex!("fcb5d7877e6ce7cfaef579c4f4b5fdbbdb807e4fe613752671742f1a5191c850"),
294				},
295				index: 0,
296				address: version_oracle_address(),
297				script: Some(reserve_validator_script().into()),
298				..Default::default()
299			},
300			illiquid_circulation_supply_validator_version_utxo: OgmiosUtxo {
301				transaction: OgmiosTx {
302					id: hex!("f5890475177fcc7cf40679974751f66331c7b25fcf2f1a148c53cf7e0e147114"),
303				},
304				index: 0,
305				address: version_oracle_address(),
306				script: Some(illiquid_supply_validator_script().into()),
307				..Default::default()
308			},
309		}
310	}
311
312	fn reference_utxo() -> OgmiosUtxo {
313		OgmiosUtxo {
314			transaction: OgmiosTx {
315				id: hex!("45882cfd2de9381f34ae68ad073452e2a57a7ad11095dae49f365266637e9d04"),
316			},
317			index: 0,
318			script: Some(applied_v_function().into()),
319			..Default::default()
320		}
321	}
322
323	fn previous_reserve_ogmios_utxo() -> OgmiosUtxo {
324		OgmiosUtxo {
325			transaction: OgmiosTx {
326				id: hex!("23de508bbfeb6af651da305a2de022463f71e47d58365eba36d98fa6c4aed731"),
327			},
328			value: OgmiosValue {
329				lovelace: 1672280,
330				native_tokens: [
331					(
332						// reserve token
333						token_policy().0,
334						vec![Asset { name: token_name().0.to_vec(), amount: 990 }],
335					),
336					(
337						// leftover governance token - should be returned to the validator
338						hex!("75b8875ff8958c66fecbd93740ac5ffd7370d299e729a46bb5632066"),
339						vec![Asset { name: vec![], amount: 1 }],
340					),
341				]
342				.into(),
343			},
344
345			index: 1,
346			..Default::default()
347		}
348	}
349
350	fn previous_reserve_utxo() -> ReserveUtxo {
351		ReserveUtxo { utxo: previous_reserve_ogmios_utxo(), datum: previous_reserve_datum() }
352	}
353
354	fn token_policy() -> PolicyId {
355		PolicyId(hex!("1fab25f376bc49a181d03a869ee8eaa3157a3a3d242a619ca7995b2b"))
356	}
357
358	fn token_name() -> AssetName {
359		AssetName::from_hex_unsafe("52657761726420746f6b656e")
360	}
361
362	fn token_id() -> sidechain_domain::AssetId {
363		sidechain_domain::AssetId { policy_id: token_policy(), asset_name: token_name() }
364	}
365
366	fn previous_reserve_datum() -> ReserveDatum {
367		ReserveDatum {
368			immutable_settings: ReserveImmutableSettings { t0: 0, token: token_id() },
369			mutable_settings: ReserveMutableSettings {
370				total_accrued_function_asset_name: applied_v_function().policy_id(),
371				initial_incentive: 0,
372			},
373			stats: ReserveStats { token_total_amount_transferred: 10 },
374		}
375	}
376
377	fn reserve_release_test_tx() -> Transaction {
378		reserve_release_tx(
379			&tx_context(),
380			&reserve_data(),
381			&previous_reserve_utxo(),
382			&reference_utxo(),
383			5,
384			0,
385			Costs::ZeroCosts,
386		)
387		.unwrap()
388	}
389
390	#[test]
391	fn should_have_correct_reference_utxos() {
392		let ref_inputs: Vec<_> = reserve_release_test_tx()
393			.body()
394			.reference_inputs()
395			.expect("Should have reference inputs")
396			.into_iter()
397			.cloned()
398			.collect();
399
400		assert!(ref_inputs.contains(&reference_utxo().to_csl_tx_input()));
401		assert!(ref_inputs.contains(&reserve_data().auth_policy_version_utxo.to_csl_tx_input()));
402		assert!(ref_inputs.contains(&reserve_data().validator_version_utxo.to_csl_tx_input()));
403		assert!(
404			ref_inputs.contains(
405				&reserve_data()
406					.illiquid_circulation_supply_validator_version_utxo
407					.to_csl_tx_input()
408			)
409		);
410		assert_eq!(ref_inputs.len(), 4)
411	}
412
413	#[test]
414	fn should_mint_v_function_scripts() {
415		let v_function_token_mint_amount = reserve_release_test_tx()
416			.body()
417			.mint()
418			.expect("Should mint a token")
419			.get(&applied_v_function().csl_script_hash())
420			.and_then(|policy| policy.get(0))
421			.expect("Should mint a v-function policy token")
422			.get(&empty_asset_name())
423			.expect("The minted token should have an empty asset name");
424
425		assert_eq!(v_function_token_mint_amount, Int::new_i32(15))
426	}
427
428	#[test]
429	fn should_burn_previoius_reserve_utxo() {
430		let inputs: Vec<_> =
431			reserve_release_test_tx().body().inputs().into_iter().cloned().collect();
432
433		assert!(inputs.contains(&previous_reserve_utxo().utxo.to_csl_tx_input()))
434	}
435
436	#[test]
437	fn should_add_correct_number_of_tokens_to_illiquid_supply() {
438		let illiquid_supply_output = (reserve_release_test_tx().body().outputs().into_iter())
439			.find(|output| {
440				output.address()
441					== illiquid_supply_validator_script().address(NetworkIdKind::Testnet)
442			})
443			.expect("Should output a UTXO to illiquid supply validator")
444			.amount()
445			.multiasset()
446			.expect("Should output native tokens to illiquid supply")
447			.get(&PolicyID::from(token_policy().0))
448			.expect("Should transfer reserve token policy token to illiquid supply")
449			.get(&token_name().to_csl().unwrap())
450			.expect("Should transfer reserve token to illiquid supply");
451
452		assert_eq!(illiquid_supply_output, 5u64.into())
453	}
454
455	#[test]
456	fn should_leave_unreleased_tokens_at_reserve_validator() {
457		let validator_output = (reserve_release_test_tx().body().outputs().into_iter())
458			.find(|output| {
459				output.address() == reserve_validator_script().address(NetworkIdKind::Testnet)
460			})
461			.expect("Should output a UTXO to illiquid supply validator")
462			.amount()
463			.multiasset()
464			.expect("Should output native tokens to illiquid supply")
465			.get(&PolicyID::from(token_policy().0))
466			.expect("Should transfer reserve token policy token to illiquid supply")
467			.get(&token_name().to_csl().unwrap())
468			.expect("Should transfer reserve token to illiquid supply");
469
470		assert_eq!(validator_output, 985u64.into())
471	}
472}