From de550d9d100f2f6e61a541bdcf266a9d6e2e272d Mon Sep 17 00:00:00 2001 From: Thomas Dinsdale-Young Date: Wed, 27 May 2026 15:02:33 +0200 Subject: [PATCH 01/15] Supporting functions for transfer of lock funds. --- plt/plt-scheduler-types/src/types/tokens.rs | 10 + .../src/scheduler/plt_scheduler.rs | 20 +- plt/plt-scheduler/src/token_context.rs | 230 +++++++++++++++++- .../src/token_module/key_value_state.rs | 25 ++ 4 files changed, 281 insertions(+), 4 deletions(-) diff --git a/plt/plt-scheduler-types/src/types/tokens.rs b/plt/plt-scheduler-types/src/types/tokens.rs index 763ff02eb3..e28312148d 100644 --- a/plt/plt-scheduler-types/src/types/tokens.rs +++ b/plt/plt-scheduler-types/src/types/tokens.rs @@ -15,6 +15,16 @@ pub struct RawTokenAmount(pub u64); impl RawTokenAmount { /// Maximum representable raw token amount. pub const MAX: Self = Self(u64::MAX); + + /// Checked addition of raw token amounts. Returns `None` if the result would overflow. + pub fn checked_add(self, other: RawTokenAmount) -> Option { + self.0.checked_add(other.0).map(RawTokenAmount) + } + + /// Checked subtraction of raw token amounts. Returns `None` if the result would overflow. + pub fn checked_sub(self, other: RawTokenAmount) -> Option { + self.0.checked_sub(other.0).map(RawTokenAmount) + } } /// Serialization of 'TokenRawAmount' is as a variable length quantity (VLQ). diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index 181b0b2ecc..4beaf3750c 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -287,7 +287,25 @@ fn execute_lock_operation( events: &mut Vec, ) -> Result<(), TransactionFailure> { match lock_operation { - LockOperation::Fund(_meta_lock_fund_details) => todo!(), + LockOperation::Fund(meta_lock_fund_details) => { + // TODO: (COR-2306) charge. + let lock = block_state + .lock_by_id(&meta_lock_fund_details.lock) + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = block_state.lock_configuration(&lock); + if lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); + } + let memo: Option = meta_lock_fund_details + .memo + .clone() + .map(transactions::Memo::from); + Ok(()) + } LockOperation::Send(_meta_lock_send_details) => todo!(), LockOperation::Return(_meta_lock_return_details) => todo!(), LockOperation::Create(meta_lock_create_details) => { diff --git a/plt/plt-scheduler/src/token_context.rs b/plt/plt-scheduler/src/token_context.rs index 353cb6fb34..e994eee531 100644 --- a/plt/plt-scheduler/src/token_context.rs +++ b/plt/plt-scheduler/src/token_context.rs @@ -20,6 +20,51 @@ use plt_scheduler_types::types::events::{ }; use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenAmount, TokenHolder}; +/// The total, locked and available balance of an account (for a particular token). +struct AccountBalances { + /// The total balance (sum of locked and available). + pub total: RawTokenAmount, + /// The balance held under the control of locks. + pub locked: RawTokenAmount, + /// The balance that is unencumbered by locks. + pub available: RawTokenAmount, +} + +/// Get the total, locked and available balances for an account. +/// This can throw a `TokenStateInvariantError` if the computed locked balance +/// exceeds the total balance. +fn get_account_balances( + block_state: &BSQ, + token: &BSQ::Token, + token_module_state: &BSQ::MutableTokenKeyValueState, + account: &BSQ::Account, +) -> Result { + let total = block_state.account_token_balance(account, token); + let context = TokenQueryContext { + block_state, + token_module_state, + }; + let account_index = block_state.account_index(account); + let locked_balances = + key_value_state::get_locked_balances_for_account(&context, account_index)?; + let mut locked = RawTokenAmount(0); + let on_overflow = || { + let token_name = block_state.token_configuration(token).token_id; + TokenStateInvariantError(format!( + "locked balance exceeds total balance for token {token_name} on account index {account_index}" + )) + }; + for (_, amount) in locked_balances { + locked = locked.checked_add(amount).ok_or_else(on_overflow)?; + } + let available = total.checked_sub(locked).ok_or_else(on_overflow)?; + Ok(AccountBalances { + total, + locked, + available, + }) +} + /// Context for running token queries with a specific token in context. pub struct TokenQueryContext<'a, BSQ: BlockStateQuery> { /// The block state @@ -161,7 +206,7 @@ impl TokenOperationContext<'_, BSO> { /// /// # Errors /// - /// - [`TokenTransferError::InsufficientBalance`] The sender has insufficient balance. + /// - [`TokenTransferError::InsufficientBalance`] The sender has insufficient available balance. /// - [`TokenTransferError::StateInvariantViolation`] If an internal token state invariant is broken. pub fn transfer( &mut self, @@ -172,11 +217,21 @@ impl TokenOperationContext<'_, BSO> { amount: RawTokenAmount, memo: Option, ) -> Result<(), TokenTransferError> { + // Check that the available balance is sufficient. + let balances = + get_account_balances(self.block_state, self.token, self.token_module_state, from)?; + if amount > balances.available { + return Err(InsufficientBalanceError { + available: balances.available, + required: amount, + } + .into()); + } // Update sender balance self.block_state .update_token_account_balance(self.token, from, RawTokenAmountDelta::Subtract(amount)) .map_err(|_err: OverflowError| InsufficientBalanceError { - available: self.block_state.account_token_balance(from, self.token), + available: balances.available, required: amount, })?; @@ -243,9 +298,178 @@ impl TokenOperationContext<'_, BSO> { self.block_state.protocol_version() >= ProtocolVersion::P11 } + /// Fund a lock with a specified amount of the token. This generates a + /// `TokenTransferEvent` to reflect the change in the locked balance. + /// + /// Note: this does not update the lock itself, which should also record + /// that this account has a balance associated with the lock. + /// + /// Returns `true` if the account previously had no balance controlled by + /// the lock but now has a (positive) balance controlled by it. + /// + /// # Preconditions + /// + /// - The protocol version MUST support protocol-level locks. + /// - The lock MUST exist in the block state. + /// + /// # Errors + /// + /// - [`TokenTransferError::InsufficientBalance`] The sender has insufficient balance. + /// - [`TokenStateInvariantError`] If an internal token state invariant is broken, e.g. locked balance overflow. + pub fn transfer_into_lock( + &mut self, + from: &BSO::Account, + from_address: AccountAddress, + lock_id: &LockId, + amount: RawTokenAmount, + memo: Option, + ) -> Result { + let balances = + get_account_balances(self.block_state, self.token, self.token_module_state, from)?; + if amount > balances.available { + return Err(InsufficientBalanceError { + available: balances.available, + required: amount, + } + .into()); + } + + // Update locked balance of the lock + let from_index = self.block_state.account_index(from); + let locked_balance = key_value_state::get_locked_balance_for(self, from_index, lock_id)?; + let new_locked_balance = locked_balance.checked_add(amount).ok_or_else(|| { + // We should never overflow locked balance at fund, since the total circulating supply of the token + // is always less that what is representable as a token amount. + TokenStateInvariantError("Locked balance overflow at fund".to_string()) + })?; + key_value_state::set_locked_balance_for(self, from_index, lock_id, new_locked_balance); + + // Issue event + let event = BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: self.token_configuration.token_id.clone(), + from: TokenHolder::Account(from_address), + to: TokenHolder::Account(from_address), // Locked balance is still associated with the same account + amount: TokenAmount { + amount, + decimals: self.block_state.token_configuration(self.token).decimals, + }, + memo, + from_lock: None, + to_lock: Some(lock_id.clone()), + }); + + self.events.push(event); + + Ok(locked_balance.0 == 0 && new_locked_balance.0 > 0) + } + + /// Send locked funds from one account to another. The funds arrive on the + /// available balance of the receiving account. If the destination is + /// `None`, the funds are returned to the sender's available balance. This + /// generates a `TokenTransferEvent` to reflect the transfer. The event is + /// generated even if the transfer amount is 0. + /// + /// Note: this does not update the locks themselves, which should also be + /// updated if the sending account's locked funds are reduced to 0. + /// + /// Returns `true` if the sending account had a positive balance controlled by + /// the lock before the transfer but has no balance controlled by it after the transfer. + /// + /// # Preconditions + /// + /// - The protocol version MUST support protocol-level locks. + /// - The lock MUST exist in the block state. + /// + /// # Errors + /// + /// - [`TokenTransferError::InsufficientBalance`] The sender has insufficient balance. + /// - [`TokenStateInvariantError`] If an internal token state invariant is broken. + pub fn transfer_from_lock( + &mut self, + from: &BSO::Account, + from_address: AccountAddress, + destination: Option<(&BSO::Account, AccountAddress)>, + lock_id: &LockId, + amount: RawTokenAmount, + memo: Option, + ) -> Result { + let old_balance = key_value_state::get_locked_balance_for( + self, + self.block_state.account_index(from), + lock_id, + )?; + let new_balance = + old_balance + .checked_sub(amount) + .ok_or_else(|| InsufficientBalanceError { + available: old_balance, + required: amount, + })?; + key_value_state::set_locked_balance_for( + self, + self.block_state.account_index(from), + lock_id, + new_balance, + ); + + let to_address = match destination { + None => { + // Returning to sender's available balance: no change in + // the account balance is required. + from_address + } + Some((to, to_addr)) => { + // Update sender balance + self.block_state + .update_token_account_balance( + self.token, + from, + RawTokenAmountDelta::Subtract(amount), + ) + .map_err(|_err: OverflowError| { + // An overflow can only occur here if the locked balance exceed the + // account balance, which is an invariant violation. + TokenStateInvariantError( + "Transfer source token amount overflow".to_string(), + ) + })?; + + // Update receiver balance + self.block_state + .update_token_account_balance(self.token, to, RawTokenAmountDelta::Add(amount)) + .map_err(|_err: OverflowError| { + // We should never overflow at transfer, since the total circulating supply + // of the token is always less that what is representable as a token amount. + TokenStateInvariantError( + "Transfer destination token amount overflow".to_string(), + ) + })?; + to_addr + } + }; + + // Issue event + let event = BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: self.token_configuration.token_id.clone(), + from: TokenHolder::Account(from_address), + to: TokenHolder::Account(to_address), + amount: TokenAmount { + amount, + decimals: self.block_state.token_configuration(self.token).decimals, + }, + memo, + from_lock: Some(lock_id.clone()), + to_lock: None, + }); + self.events.push(event); + + Ok(old_balance == amount && old_balance.0 > 0) + } + /// Unlock the balance of an account associated with a particular lock for /// this particular token. This generates a `TokenTransferEvent` to reflect - /// the change in the locked balance. + /// the change in the locked balance. No transfer occurs (and no event is + /// generated) if there is no locked balance to unlock. pub fn unlock_balance( &mut self, account_index: AccountIndex, diff --git a/plt/plt-scheduler/src/token_module/key_value_state.rs b/plt/plt-scheduler/src/token_module/key_value_state.rs index 0aa5e30d19..bb09fcaed8 100644 --- a/plt/plt-scheduler/src/token_module/key_value_state.rs +++ b/plt/plt-scheduler/src/token_module/key_value_state.rs @@ -462,6 +462,31 @@ pub fn get_locked_balance_for( else { return Ok(RawTokenAmount(0)); }; + decode_locked_balance(value) +} + +/// Get the locked balances recorded in token-module account state for the given +/// account. +pub fn get_locked_balances_for_account( + context: &impl ReadTokenKeyValueState, + account: AccountIndex, +) -> Result, TokenStateInvariantError> { + let prefix = account_state_key(account, ACCOUNT_STATE_KEY_QUANTA); + context + .iter_token_state_prefix(&prefix) + .map(|(key, value)| { + let lock = common::from_bytes_complete(&key.0[prefix.0.len()..]).map_err(|err| { + TokenStateInvariantError(format!("Stored lock id cannot be decoded: {}", err)) + })?; + let amount = decode_locked_balance(value)?; + Ok((lock, amount)) + }) + .collect() +} + +fn decode_locked_balance( + value: TokenStateValue, +) -> Result { common::from_bytes_complete(value.0).map_err(|err| { TokenStateInvariantError(format!("Stored locked balance cannot be decoded: {}", err)) }) From 32cc71e993e326e1a15861d2686f50bbe52ace5a Mon Sep 17 00:00:00 2001 From: Thomas Dinsdale-Young Date: Wed, 27 May 2026 17:27:12 +0200 Subject: [PATCH 02/15] Support fund/send/return operations. --- plt/plt-block-state/src/block_state.rs | 27 ++ .../src/block_state_interface.rs | 22 ++ .../src/entity/protocol_level_locks/p11.rs | 22 ++ plt/plt-scheduler/src/scheduler.rs | 48 +++- .../src/scheduler/plt_scheduler.rs | 243 +++++++++++++++++- plt/plt-scheduler/src/token_context.rs | 58 ++--- 6 files changed, 376 insertions(+), 44 deletions(-) diff --git a/plt/plt-block-state/src/block_state.rs b/plt/plt-block-state/src/block_state.rs index e6147f9250..f05ce2df87 100644 --- a/plt/plt-block-state/src/block_state.rs +++ b/plt/plt-block-state/src/block_state.rs @@ -251,6 +251,15 @@ impl BlockStateOperations for ExecutionTimeBlockStateP9 BlockStateOperations for ExecutionTimeBlockStateP11< lock.add_lock_balance_ref(account.account_index(), *token); self.block_state.update_lock(&self.context, lock).unwrap(); } + + fn remove_lock_balance_ref( + &mut self, + lock: &LockId, + account: &Self::Account, + token: &Self::Token, + ) { + let mut lock = self + .block_state + .lock_by_id(&self.context, lock) + .unwrap() + .unwrap(); + let removed = lock.remove_lock_balance_ref(account.account_index(), *token); + if removed { + // Only update on a change. + self.block_state.update_lock(&self.context, lock).unwrap(); + } + } } diff --git a/plt/plt-block-state/src/block_state_interface.rs b/plt/plt-block-state/src/block_state_interface.rs index 35e695f705..8c3e3197c7 100644 --- a/plt/plt-block-state/src/block_state_interface.rs +++ b/plt/plt-block-state/src/block_state_interface.rs @@ -373,6 +373,28 @@ pub trait BlockStateOperations: BlockStateQuery { /// - The `lock` MUST already exist in the block state, i.e. /// `s.lock_by_id(lock_id).expect("lock exists")`. fn add_lock_balance_ref(&mut self, lock: &LockId, account: &Self::Account, token: &Self::Token); + + /// Stop tracking that a lock holds a balance for the given account and token. + /// + /// This removes the account/token pair from the lock state, so it will no longer be + /// returned by [`BlockStateQuery::lock_balances`]. + /// + /// # Arguments + /// - `lock` The lock to update. + /// - `account` The account whose locked balance is no longer tracked. + /// - `token` The token whose locked balance is no longer tracked. + /// + /// The caller must ensure the following conditions are true, and failing to do so results in + /// undefined behavior. + /// + /// - The `lock` MUST already exist in the block state, i.e. + /// `s.lock_by_id(lock_id).expect("lock exists")`. + fn remove_lock_balance_ref( + &mut self, + lock: &LockId, + account: &Self::Account, + token: &Self::Token, + ); } /// The computation resulted in overflow (negative or above maximum value). diff --git a/plt/plt-block-state/src/entity/protocol_level_locks/p11.rs b/plt/plt-block-state/src/entity/protocol_level_locks/p11.rs index 9cca0e6251..daa21c6560 100644 --- a/plt/plt-block-state/src/entity/protocol_level_locks/p11.rs +++ b/plt/plt-block-state/src/entity/protocol_level_locks/p11.rs @@ -120,4 +120,26 @@ impl LockP11 { .locked_balances .insert((account_index, token_index)); } + + /// Stop tracking that the lock holds a balance for the given account and token. + /// This removes the account/token pair from the lock state, so it will no longer be + /// returned by [`Self::lock_balance_refs`]. + /// + /// # Arguments + /// + /// - `account_index` The index of the account whose locked balance is no longer tracked. + /// - `token_index` Index of the token whose locked balance is no longer tracked. + /// + /// # Returns + /// `true` if the account/token pair was previously tracked and has been removed, + /// `false` if the account/token pair was not previously tracked. + pub fn remove_lock_balance_ref( + &mut self, + account_index: AccountIndex, + token_index: TokenIndex, + ) -> bool { + self.persistent + .locked_balances + .remove(&(account_index, token_index)) + } } diff --git a/plt/plt-scheduler/src/scheduler.rs b/plt/plt-scheduler/src/scheduler.rs index f3a81d074a..cbd89f0a1e 100644 --- a/plt/plt-scheduler/src/scheduler.rs +++ b/plt/plt-scheduler/src/scheduler.rs @@ -1,14 +1,21 @@ //! Entry points to calling the scheduler. The scheduler is responsible for executing //! transaction and update instruction payloads. -use crate::token_module::errors::TokenStateInvariantError; +use crate::token_module::errors::{TokenStateInvariantError, TokenTransferError}; +use crate::token_module::util; use crate::transaction_execution::{TransactionContext, TransactionExecution}; use concordium_base::base::ProtocolVersion; +use concordium_base::protocol_level_tokens::{ + TokenBalanceInsufficientRejectReason, TokenModuleRejectReason, +}; use concordium_base::transactions::Payload; use concordium_base::updates::UpdatePayload; use plt_block_state::block_state_interface::BlockStateOperations; +use plt_block_state::persistent::protocol_level_tokens::p9::TokenConfiguration; use plt_scheduler_types::types::execution::{ChainUpdateOutcome, TransactionExecutionSummary}; -use plt_scheduler_types::types::reject_reasons::TransactionRejectReason; +use plt_scheduler_types::types::reject_reasons::{ + EncodedTokenModuleRejectReason, TransactionRejectReason, +}; pub mod helpers; mod plt_scheduler; @@ -53,6 +60,43 @@ impl From for TransactionFailure { } } +impl TransactionFailure { + fn from_token_transfer_error( + index: u64, + token_configuration: &TokenConfiguration, + error: TokenTransferError, + ) -> Self { + match error { + TokenTransferError::StateInvariantViolation(token_state_invariant_error) => { + token_state_invariant_error.into() + } + TokenTransferError::InsufficientBalance(insufficient_balance_error) => { + let (reason, details) = TokenModuleRejectReason::TokenBalanceInsufficient( + TokenBalanceInsufficientRejectReason { + index, + available_balance: util::to_token_amount( + token_configuration, + insufficient_balance_error.available, + ), + required_balance: util::to_token_amount( + token_configuration, + insufficient_balance_error.required, + ), + }, + ) + .encode_reject_reason(); + Self::Reject(TransactionRejectReason::TokenUpdateTransactionFailed( + EncodedTokenModuleRejectReason { + token_id: token_configuration.token_id.clone(), + reason_type: reason.to_type_discriminator(), + details: Some(details), + }, + )) + } + } + } +} + /// Execute a transaction payload modifying `block_state` accordingly. /// Returns the events produced if successful, otherwise a reject reason. Additionally, the /// amount of energy used by the execution is returned. The returned values are represented diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index 4beaf3750c..b5f7a43a38 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -5,11 +5,16 @@ use crate::locks::lock_controller::LockController; use crate::locks::{get_lock_config, lock_controller}; use crate::scheduler::{ChainUpdateExecutionError, TransactionExecutionError, TransactionFailure}; use crate::token_context::TokenOperationContext; -use crate::token_module::{self, TOKEN_MODULE_REF, TokenInitializationError, TokenUpdateError}; +use crate::token_module::{ + self, TOKEN_MODULE_REF, TokenInitializationError, TokenUpdateError, util, +}; use crate::transaction_execution::{OutOfEnergyError, TransactionExecution}; use concordium_base::common::cbor::{self}; use concordium_base::protocol_level_locks::LockId; -use concordium_base::protocol_level_tokens::{RawCbor, TokenId, TokenOperation}; +use concordium_base::protocol_level_tokens::{ + DeserializationFailureRejectReason, RawCbor, TokenAmount, TokenId, TokenModuleRejectReason, + TokenOperation, +}; use concordium_base::protocol_level_tokens::{ TokenOperationsPayload, meta_operations::{ @@ -29,6 +34,7 @@ use plt_scheduler_types::types::execution::{ChainUpdateOutcome, FailureKind, Tra use plt_scheduler_types::types::reject_reasons::{ EncodedTokenModuleRejectReason, TransactionRejectReason, }; +use plt_scheduler_types::types::tokens::RawTokenAmount; /// Execute a token update transaction payload modifying `block_state` accordingly. /// Returns the events produced if successful, otherwise a reject reason. @@ -279,10 +285,29 @@ where Ok(result) } +fn parse_raw_amount( + token_configuration: &TokenConfiguration, + amount: TokenAmount, +) -> Result { + util::to_raw_token_amount(token_configuration, amount).map_err(|err| { + let (reason, details) = + TokenModuleRejectReason::DeserializationFailure(DeserializationFailureRejectReason { + cause: Some(err.to_string()), + }) + .encode_reject_reason(); + TransactionRejectReason::TokenUpdateTransactionFailed(EncodedTokenModuleRejectReason { + token_id: token_configuration.token_id.clone(), + reason_type: reason.to_type_discriminator(), + details: Some(details), + }) + .into() + }) +} + fn execute_lock_operation( transaction_execution: &mut TransactionExecution, block_state: &mut BSO, - _index: usize, + index: usize, lock_operation: LockOperation, events: &mut Vec, ) -> Result<(), TransactionFailure> { @@ -294,20 +319,228 @@ fn execute_lock_operation( .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; let lock_configuration = block_state.lock_configuration(&lock); + // Check if the lock has expired. if lock_configuration .expiry() .is_expired(transaction_execution.timestamp()) { return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); } + // Check if the sender is authorized to fund the lock. + if !lock_configuration.controller().validate_operation( + block_state, + transaction_execution.sender_account(), + &lock_controller::LockOperation::Fund(meta_lock_fund_details.clone()), + ) { + return Err(TransactionRejectReason::LockFundNotAuthorized( + lock.lock_id().clone(), + transaction_execution.sender_account_address(), + ) + .into()); + } + + let token = block_state + .token_by_id(&meta_lock_fund_details.token) + .map_err(|TokenNotFoundByIdError(token_id)| { + TransactionRejectReason::NonExistentTokenId(token_id) + })?; + let memo: Option = meta_lock_fund_details .memo .clone() .map(transactions::Memo::from); + + // Fund the lock. + let is_new_lock_holder = with_token(block_state, &token, events, |kernel| { + let raw_amount = + parse_raw_amount(kernel.token_configuration, meta_lock_fund_details.amount)?; + kernel + .transfer_into_lock( + transaction_execution.sender_account(), + transaction_execution.sender_account_address(), + &meta_lock_fund_details.lock, + raw_amount, + memo, + ) + .map_err(|error| { + TransactionFailure::from_token_transfer_error( + index as u64, + kernel.token_configuration, + error, + ) + }) + })?; + + if is_new_lock_holder { + // The lock controller state needs to be updated to reflect + // that the lock controls tokens for the account. + block_state.add_lock_balance_ref( + lock.lock_id(), + transaction_execution.sender_account(), + &token, + ); + } + + Ok(()) + } + LockOperation::Send(meta_lock_send_details) => { + // TODO: (COR-2306) charge. + let lock = block_state + .lock_by_id(&meta_lock_send_details.lock) + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = block_state.lock_configuration(&lock); + // Check if the lock has expired. + if lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); + } + // Check if the sender is authorized to send from the lock. + if !lock_configuration.controller().validate_operation( + block_state, + transaction_execution.sender_account(), + &lock_controller::LockOperation::Send(meta_lock_send_details.clone()), + ) { + return Err(TransactionRejectReason::LockSendNotAuthorized( + lock.lock_id().clone(), + transaction_execution.sender_account_address(), + ) + .into()); + } + + let token = block_state + .token_by_id(&meta_lock_send_details.token) + .map_err(|TokenNotFoundByIdError(token_id)| { + TransactionRejectReason::NonExistentTokenId(token_id) + })?; + + let memo: Option = meta_lock_send_details + .memo + .clone() + .map(transactions::Memo::from); + + let source_address = meta_lock_send_details.source.address; + let source = block_state + .account_by_address(&source_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; + + let recipient_address = meta_lock_send_details.recipient.address; + let recipient = block_state + .account_by_address(&recipient_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(recipient_address))?; + let destination = Some((&recipient, recipient_address)); + + // Send from the lock. + let is_removed_lock_holder = with_token(block_state, &token, events, |kernel| { + let raw_amount = + parse_raw_amount(kernel.token_configuration, meta_lock_send_details.amount)?; + kernel + .transfer_from_lock( + &source, + source_address, + destination, + &meta_lock_send_details.lock, + raw_amount, + memo, + ) + .map_err(|error| { + TransactionFailure::from_token_transfer_error( + index as u64, + kernel.token_configuration, + error, + ) + }) + })?; + if is_removed_lock_holder { + // The lock controller state needs to be updated to reflect + // that the lock no longer controls any of this token for the account. + block_state.remove_lock_balance_ref( + lock.lock_id(), + transaction_execution.sender_account(), + &token, + ); + } + + Ok(()) + } + LockOperation::Return(meta_lock_return_details) => { + // TODO: (COR-2306) charge. + let lock = block_state + .lock_by_id(&meta_lock_return_details.lock) + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = block_state.lock_configuration(&lock); + // Check if the lock has expired. + if lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); + } + // Check if the sender is authorized to send from the lock. + if !lock_configuration.controller().validate_operation( + block_state, + transaction_execution.sender_account(), + &lock_controller::LockOperation::Return(meta_lock_return_details.clone()), + ) { + return Err(TransactionRejectReason::LockReturnNotAuthorized( + lock.lock_id().clone(), + transaction_execution.sender_account_address(), + ) + .into()); + } + + let token = block_state + .token_by_id(&meta_lock_return_details.token) + .map_err(|TokenNotFoundByIdError(token_id)| { + TransactionRejectReason::NonExistentTokenId(token_id) + })?; + + let memo: Option = meta_lock_return_details + .memo + .clone() + .map(transactions::Memo::from); + + let source_address = meta_lock_return_details.source.address; + let source = block_state + .account_by_address(&source_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; + + // Return from the lock. + let is_removed_lock_holder = with_token(block_state, &token, events, |kernel| { + let raw_amount = + parse_raw_amount(kernel.token_configuration, meta_lock_return_details.amount)?; + kernel + .transfer_from_lock( + &source, + source_address, + None, + &meta_lock_return_details.lock, + raw_amount, + memo, + ) + .map_err(|error| { + TransactionFailure::from_token_transfer_error( + index as u64, + kernel.token_configuration, + error, + ) + }) + })?; + if is_removed_lock_holder { + // The lock controller state needs to be updated to reflect + // that the lock no longer controls any of this token for the account. + block_state.remove_lock_balance_ref( + lock.lock_id(), + transaction_execution.sender_account(), + &token, + ); + } + Ok(()) } - LockOperation::Send(_meta_lock_send_details) => todo!(), - LockOperation::Return(_meta_lock_return_details) => todo!(), LockOperation::Create(meta_lock_create_details) => { let config = meta_lock_create_details.config; let account_index = block_state.account_index(transaction_execution.sender_account()); diff --git a/plt/plt-scheduler/src/token_context.rs b/plt/plt-scheduler/src/token_context.rs index e994eee531..b0405375a2 100644 --- a/plt/plt-scheduler/src/token_context.rs +++ b/plt/plt-scheduler/src/token_context.rs @@ -20,25 +20,15 @@ use plt_scheduler_types::types::events::{ }; use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenAmount, TokenHolder}; -/// The total, locked and available balance of an account (for a particular token). -struct AccountBalances { - /// The total balance (sum of locked and available). - pub total: RawTokenAmount, - /// The balance held under the control of locks. - pub locked: RawTokenAmount, - /// The balance that is unencumbered by locks. - pub available: RawTokenAmount, -} - -/// Get the total, locked and available balances for an account. +/// Get the available balance for an account. /// This can throw a `TokenStateInvariantError` if the computed locked balance /// exceeds the total balance. -fn get_account_balances( +fn get_available_balance( block_state: &BSQ, token: &BSQ::Token, token_module_state: &BSQ::MutableTokenKeyValueState, account: &BSQ::Account, -) -> Result { +) -> Result { let total = block_state.account_token_balance(account, token); let context = TokenQueryContext { block_state, @@ -47,7 +37,7 @@ fn get_account_balances( let account_index = block_state.account_index(account); let locked_balances = key_value_state::get_locked_balances_for_account(&context, account_index)?; - let mut locked = RawTokenAmount(0); + let mut available = total; let on_overflow = || { let token_name = block_state.token_configuration(token).token_id; TokenStateInvariantError(format!( @@ -55,14 +45,9 @@ fn get_account_balances( )) }; for (_, amount) in locked_balances { - locked = locked.checked_add(amount).ok_or_else(on_overflow)?; + available = available.checked_sub(amount).ok_or_else(on_overflow)?; } - let available = total.checked_sub(locked).ok_or_else(on_overflow)?; - Ok(AccountBalances { - total, - locked, - available, - }) + Ok(available) } /// Context for running token queries with a specific token in context. @@ -218,11 +203,11 @@ impl TokenOperationContext<'_, BSO> { memo: Option, ) -> Result<(), TokenTransferError> { // Check that the available balance is sufficient. - let balances = - get_account_balances(self.block_state, self.token, self.token_module_state, from)?; - if amount > balances.available { + let available = + get_available_balance(self.block_state, self.token, self.token_module_state, from)?; + if amount > available { return Err(InsufficientBalanceError { - available: balances.available, + available, required: amount, } .into()); @@ -231,7 +216,7 @@ impl TokenOperationContext<'_, BSO> { self.block_state .update_token_account_balance(self.token, from, RawTokenAmountDelta::Subtract(amount)) .map_err(|_err: OverflowError| InsufficientBalanceError { - available: balances.available, + available, required: amount, })?; @@ -324,11 +309,11 @@ impl TokenOperationContext<'_, BSO> { amount: RawTokenAmount, memo: Option, ) -> Result { - let balances = - get_account_balances(self.block_state, self.token, self.token_module_state, from)?; - if amount > balances.available { + let available = + get_available_balance(self.block_state, self.token, self.token_module_state, from)?; + if amount > available { return Err(InsufficientBalanceError { - available: balances.available, + available, required: amount, } .into()); @@ -398,13 +383,12 @@ impl TokenOperationContext<'_, BSO> { self.block_state.account_index(from), lock_id, )?; - let new_balance = - old_balance - .checked_sub(amount) - .ok_or_else(|| InsufficientBalanceError { - available: old_balance, - required: amount, - })?; + let new_balance = old_balance + .checked_sub(amount) + .ok_or(InsufficientBalanceError { + available: old_balance, + required: amount, + })?; key_value_state::set_locked_balance_for( self, self.block_state.account_index(from), From bdc7fea79b014e1c70e8aea2f22ddc02ae709918 Mon Sep 17 00:00:00 2001 From: Thomas Dinsdale-Young Date: Thu, 28 May 2026 11:33:06 +0200 Subject: [PATCH 03/15] Corrections to validation logic. --- .../src/locks/lock_controller.rs | 12 ++- .../src/locks/lock_controller_simple.rs | 52 ++++++++-- .../src/scheduler/plt_scheduler.rs | 96 ++++++++----------- .../src/token_module/key_value_state.rs | 7 +- 4 files changed, 98 insertions(+), 69 deletions(-) diff --git a/plt/plt-scheduler/src/locks/lock_controller.rs b/plt/plt-scheduler/src/locks/lock_controller.rs index 8cf1857fd9..0faf89bbeb 100644 --- a/plt/plt-scheduler/src/locks/lock_controller.rs +++ b/plt/plt-scheduler/src/locks/lock_controller.rs @@ -1,5 +1,6 @@ //! Runtime interface for protocol-level lock controllers. +use concordium_base::contracts_common::AccountAddress; use concordium_base::protocol_level_tokens::meta_operations::{ MetaLockCancelDetails, MetaLockFundDetails, MetaLockReturnDetails, MetaLockSendDetails, }; @@ -22,7 +23,8 @@ pub enum LockOperation { /// Runtime interface implemented by protocol-level locks. pub trait LockController { - /// Approve or reject a lock operation. Returns `true` if the operation is authorized. + /// Approve or reject a lock operation. Returns `Ok(())` if the operation is authorized, or + /// a `TransactionRejectReason` if it is not. /// /// * `bsq`: the block state to query on /// * `sender`: the transaction sender reference @@ -30,9 +32,10 @@ pub trait LockController { fn validate_operation( &self, bsq: &BSQ, + sender_address: AccountAddress, sender: &BSQ::Account, operation: &LockOperation, - ) -> bool; + ) -> Result<(), TransactionRejectReason>; /// Convert this controller configuration to its canonical CBOR /// [`concordium_base::protocol_level_locks::LockController`] representation, used by the @@ -66,12 +69,13 @@ impl LockController for LockControllerConfig { fn validate_operation( &self, bsq: &BSQ, + sender_address: AccountAddress, sender: &BSQ::Account, operation: &LockOperation, - ) -> bool { + ) -> Result<(), TransactionRejectReason> { match self { LockControllerConfig::SimpleV0(lock_controller_simple_v0) => { - lock_controller_simple_v0.validate_operation(bsq, sender, operation) + lock_controller_simple_v0.validate_operation(bsq, sender_address, sender, operation) } } } diff --git a/plt/plt-scheduler/src/locks/lock_controller_simple.rs b/plt/plt-scheduler/src/locks/lock_controller_simple.rs index 3735a88cac..da1aa9b0b6 100644 --- a/plt/plt-scheduler/src/locks/lock_controller_simple.rs +++ b/plt/plt-scheduler/src/locks/lock_controller_simple.rs @@ -1,3 +1,4 @@ +use concordium_base::contracts_common::AccountAddress; use concordium_base::protocol_level_locks::LockControllerSimpleV0Capability; use concordium_base::protocol_level_tokens::CborHolderAccount; use plt_block_state::block_state_interface::{AccountNotFoundByIndexError, BlockStateQuery}; @@ -13,17 +14,52 @@ impl LockController for LockControllerSimpleV0 { fn validate_operation( &self, bsq: &BSQ, + sender_address: AccountAddress, sender: &BSQ::Account, operation: &LockOperation, - ) -> bool { + ) -> Result<(), TransactionRejectReason> { let sender_index = bsq.account_index(sender); - let role = match operation { - LockOperation::Fund(_) => LockControllerSimpleV0Capability::Fund, - LockOperation::Send(_) => LockControllerSimpleV0Capability::Send, - LockOperation::Return(_) => LockControllerSimpleV0Capability::Return, - LockOperation::Cancel(_) => LockControllerSimpleV0Capability::Cancel, - }; - self.has_role(sender_index, role) + match operation { + LockOperation::Fund(fund_details) => { + if !self.has_role(sender_index, LockControllerSimpleV0Capability::Fund) { + return Err(TransactionRejectReason::LockFundNotAuthorized( + fund_details.lock.clone(), + sender_address, + )); + } + if !self.tokens.contains(&fund_details.token) { + return Err(TransactionRejectReason::LockTokenNotPermitted( + fund_details.lock.clone(), + fund_details.token.clone(), + )); + } + } + LockOperation::Send(send_details) => { + if !self.has_role(sender_index, LockControllerSimpleV0Capability::Send) { + return Err(TransactionRejectReason::LockSendNotAuthorized( + send_details.lock.clone(), + sender_address, + )); + } + }, + LockOperation::Return(return_details) => { + if !self.has_role(sender_index, LockControllerSimpleV0Capability::Return) { + return Err(TransactionRejectReason::LockReturnNotAuthorized( + return_details.lock.clone(), + sender_address, + )); + } + }, + LockOperation::Cancel(cancel_details) => { + if !self.has_role(sender_index, LockControllerSimpleV0Capability::Cancel) { + return Err(TransactionRejectReason::LockCancelNotAuthorized( + cancel_details.lock.clone(), + sender_address, + )); + } + }, + } + Ok(()) } fn to_cbor_controller( diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index b5f7a43a38..f796d56e2c 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -326,18 +326,6 @@ fn execute_lock_operation( { return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); } - // Check if the sender is authorized to fund the lock. - if !lock_configuration.controller().validate_operation( - block_state, - transaction_execution.sender_account(), - &lock_controller::LockOperation::Fund(meta_lock_fund_details.clone()), - ) { - return Err(TransactionRejectReason::LockFundNotAuthorized( - lock.lock_id().clone(), - transaction_execution.sender_account_address(), - ) - .into()); - } let token = block_state .token_by_id(&meta_lock_fund_details.token) @@ -345,6 +333,14 @@ fn execute_lock_operation( TransactionRejectReason::NonExistentTokenId(token_id) })?; + // Check that the operation is permitted by the lock controller. + lock_configuration.controller().validate_operation( + block_state, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Fund(meta_lock_fund_details.clone()), + )?; + let memo: Option = meta_lock_fund_details .memo .clone() @@ -397,18 +393,6 @@ fn execute_lock_operation( { return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); } - // Check if the sender is authorized to send from the lock. - if !lock_configuration.controller().validate_operation( - block_state, - transaction_execution.sender_account(), - &lock_controller::LockOperation::Send(meta_lock_send_details.clone()), - ) { - return Err(TransactionRejectReason::LockSendNotAuthorized( - lock.lock_id().clone(), - transaction_execution.sender_account_address(), - ) - .into()); - } let token = block_state .token_by_id(&meta_lock_send_details.token) @@ -432,6 +416,23 @@ fn execute_lock_operation( .map_err(|_| TransactionRejectReason::InvalidAccountReference(recipient_address))?; let destination = Some((&recipient, recipient_address)); + // Check if the recipient is authorized. + if !lock_configuration.is_recipient(&block_state.account_index(&recipient)) { + return Err(TransactionRejectReason::LockRecipientNotPermitted( + lock.lock_id().clone(), + recipient_address, + ) + .into()); + } + + // Check if the send is authorized by the lock controller. + lock_configuration.controller().validate_operation( + block_state, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Send(meta_lock_send_details.clone()), + )?; + // Send from the lock. let is_removed_lock_holder = with_token(block_state, &token, events, |kernel| { let raw_amount = @@ -456,11 +457,7 @@ fn execute_lock_operation( if is_removed_lock_holder { // The lock controller state needs to be updated to reflect // that the lock no longer controls any of this token for the account. - block_state.remove_lock_balance_ref( - lock.lock_id(), - transaction_execution.sender_account(), - &token, - ); + block_state.remove_lock_balance_ref(lock.lock_id(), &source, &token); } Ok(()) @@ -479,18 +476,6 @@ fn execute_lock_operation( { return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); } - // Check if the sender is authorized to send from the lock. - if !lock_configuration.controller().validate_operation( - block_state, - transaction_execution.sender_account(), - &lock_controller::LockOperation::Return(meta_lock_return_details.clone()), - ) { - return Err(TransactionRejectReason::LockReturnNotAuthorized( - lock.lock_id().clone(), - transaction_execution.sender_account_address(), - ) - .into()); - } let token = block_state .token_by_id(&meta_lock_return_details.token) @@ -508,6 +493,14 @@ fn execute_lock_operation( .account_by_address(&source_address) .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; + // Check if the sender is authorized to send from the lock. + lock_configuration.controller().validate_operation( + block_state, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Return(meta_lock_return_details.clone()), + )?; + // Return from the lock. let is_removed_lock_holder = with_token(block_state, &token, events, |kernel| { let raw_amount = @@ -532,11 +525,7 @@ fn execute_lock_operation( if is_removed_lock_holder { // The lock controller state needs to be updated to reflect // that the lock no longer controls any of this token for the account. - block_state.remove_lock_balance_ref( - lock.lock_id(), - transaction_execution.sender_account(), - &token, - ); + block_state.remove_lock_balance_ref(lock.lock_id(), &source, &token); } Ok(()) @@ -596,19 +585,14 @@ fn execute_lock_operation( if !lock_configuration .expiry() .is_expired(transaction_execution.timestamp()) - && !lock_configuration.controller().validate_operation( + { + // The lock is not expired, so check that the sender is authorized to cancel the lock. + lock_configuration.controller().validate_operation( block_state, + transaction_execution.sender_account_address(), transaction_execution.sender_account(), &lock_controller::LockOperation::Cancel(meta_lock_cancel_details), - ) - { - // The lock is neither expired, nor is the sender authorized to - // cancel the lock, so we reject the transaction. - return Err(TransactionRejectReason::LockCancelNotAuthorized( - lock.lock_id().clone(), - transaction_execution.sender_account_address(), - ) - .into()); + )?; } for (account_index, token) in block_state.lock_balances(&lock).collect::>() { with_token(block_state, &token, events, |kernel| { diff --git a/plt/plt-scheduler/src/token_module/key_value_state.rs b/plt/plt-scheduler/src/token_module/key_value_state.rs index bb09fcaed8..607479c29e 100644 --- a/plt/plt-scheduler/src/token_module/key_value_state.rs +++ b/plt/plt-scheduler/src/token_module/key_value_state.rs @@ -475,7 +475,12 @@ pub fn get_locked_balances_for_account( context .iter_token_state_prefix(&prefix) .map(|(key, value)| { - let lock = common::from_bytes_complete(&key.0[prefix.0.len()..]).map_err(|err| { + let lock_bytes = key.0.strip_prefix::<[u8]>(prefix.0.as_ref()).ok_or_else(|| { + TokenStateInvariantError( + "Iterator over account quanta state produced invalid key".to_string(), + ) + })?; + let lock = common::from_bytes_complete(&lock_bytes).map_err(|err| { TokenStateInvariantError(format!("Stored lock id cannot be decoded: {}", err)) })?; let amount = decode_locked_balance(value)?; From b5313868b253c753e7f26e125c7ba7fa3219d409 Mon Sep 17 00:00:00 2001 From: Thomas Dinsdale-Young Date: Fri, 29 May 2026 11:05:54 +0200 Subject: [PATCH 04/15] Formatting + clippy. --- .../src/locks/lock_controller_simple.rs | 6 +++--- .../src/token_module/key_value_state.rs | 15 +++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/plt/plt-scheduler/src/locks/lock_controller_simple.rs b/plt/plt-scheduler/src/locks/lock_controller_simple.rs index da1aa9b0b6..58a37baa83 100644 --- a/plt/plt-scheduler/src/locks/lock_controller_simple.rs +++ b/plt/plt-scheduler/src/locks/lock_controller_simple.rs @@ -41,7 +41,7 @@ impl LockController for LockControllerSimpleV0 { sender_address, )); } - }, + } LockOperation::Return(return_details) => { if !self.has_role(sender_index, LockControllerSimpleV0Capability::Return) { return Err(TransactionRejectReason::LockReturnNotAuthorized( @@ -49,7 +49,7 @@ impl LockController for LockControllerSimpleV0 { sender_address, )); } - }, + } LockOperation::Cancel(cancel_details) => { if !self.has_role(sender_index, LockControllerSimpleV0Capability::Cancel) { return Err(TransactionRejectReason::LockCancelNotAuthorized( @@ -57,7 +57,7 @@ impl LockController for LockControllerSimpleV0 { sender_address, )); } - }, + } } Ok(()) } diff --git a/plt/plt-scheduler/src/token_module/key_value_state.rs b/plt/plt-scheduler/src/token_module/key_value_state.rs index 607479c29e..e6734f865c 100644 --- a/plt/plt-scheduler/src/token_module/key_value_state.rs +++ b/plt/plt-scheduler/src/token_module/key_value_state.rs @@ -475,12 +475,15 @@ pub fn get_locked_balances_for_account( context .iter_token_state_prefix(&prefix) .map(|(key, value)| { - let lock_bytes = key.0.strip_prefix::<[u8]>(prefix.0.as_ref()).ok_or_else(|| { - TokenStateInvariantError( - "Iterator over account quanta state produced invalid key".to_string(), - ) - })?; - let lock = common::from_bytes_complete(&lock_bytes).map_err(|err| { + let lock_bytes = key + .0 + .strip_prefix::<[u8]>(prefix.0.as_ref()) + .ok_or_else(|| { + TokenStateInvariantError( + "Iterator over account quanta state produced invalid key".to_string(), + ) + })?; + let lock = common::from_bytes_complete(lock_bytes).map_err(|err| { TokenStateInvariantError(format!("Stored lock id cannot be decoded: {}", err)) })?; let amount = decode_locked_balance(value)?; From e547364ac37617b64cab8fc8b07c68de5d26ab25 Mon Sep 17 00:00:00 2001 From: Thomas Dinsdale-Young Date: Fri, 29 May 2026 15:41:42 +0200 Subject: [PATCH 05/15] Update base after merge. --- concordium-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/concordium-base b/concordium-base index 4834771420..24a8db13dc 160000 --- a/concordium-base +++ b/concordium-base @@ -1 +1 @@ -Subproject commit 483477142019d5c69db9be615c01758ab8813afc +Subproject commit 24a8db13dc67e7b9cf782f041fa62a87d891160b From 1b71df416053734e3165a6b027769d3f372088ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 2 Jun 2026 10:00:34 +0200 Subject: [PATCH 06/15] Add lock_transfer tests --- plt/plt-scheduler/tests/lock_transfer.rs | 711 +++++++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 plt/plt-scheduler/tests/lock_transfer.rs diff --git a/plt/plt-scheduler/tests/lock_transfer.rs b/plt/plt-scheduler/tests/lock_transfer.rs new file mode 100644 index 0000000000..9d74c0c745 --- /dev/null +++ b/plt/plt-scheduler/tests/lock_transfer.rs @@ -0,0 +1,711 @@ +//! Tests for funding, sending, and returning lock-controlled funds. + +use crate::utils::entity_traits::scheduler::SchedulerOperations; +use crate::utils::{BlockStateLatest, TokenInitTestParams}; +use assert_matches::assert_matches; +use concordium_base::base::Energy; +use concordium_base::common::cbor; +use concordium_base::protocol_level_locks::LockInfo; +use concordium_base::protocol_level_locks::{LockControllerSimpleV0Capability, LockId}; +use concordium_base::protocol_level_tokens::meta_operations::{ + MetaUpdateOperations, MetaUpdatePayload, lock_fund, lock_return, lock_send, +}; +use concordium_base::protocol_level_tokens::{ + RawCbor, TokenAmount, TokenId, TokenModuleAccountState, +}; +use concordium_base::transactions::Payload; +use plt_block_state::{ + entity::entity_test_stub, persistent::protocol_level_locks::p11::LockControllerSimpleV0Grant, +}; +use plt_scheduler_types::types::events::{BlockItemEvent, TokenTransferEvent}; +use plt_scheduler_types::types::execution::TransactionOutcome; +use plt_scheduler_types::types::reject_reasons::TransactionRejectReason; +use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenHolder}; + +mod utils; + +macro_rules! execute_meta_update { + ($context:expr, $block_state:expr, $sender:expr, $timestamp:expr, $operations:expr $(,)?) => {{ + let sender_addr = $context.external.account_canonical_address($sender); + let payload = Payload::MetaUpdate { + payload: MetaUpdatePayload { + operations: RawCbor::from(cbor::cbor_encode(&MetaUpdateOperations { + operations: $operations, + })), + }, + }; + + $block_state + .execute_transaction( + $context, + plt_scheduler::TransactionContext { + energy_limit: Energy::from(u64::MAX), + sender_account_address: sender_addr, + transaction_sequence_number: 1.into(), + block_timestamp: $timestamp.into(), + }, + $sender, + payload, + ) + .expect("meta-update transaction must execute") + .outcome + }}; +} + +macro_rules! token_account_info { + ($context:expr, $block_state:expr, $account:expr, $token_id:expr $(,)?) => {{ + $block_state + .query_token_account_infos($context, $account) + .expect("token account query must succeed") + .into_iter() + .find(|info| &info.token_id == $token_id) + .expect("token account info must exist") + }}; +} + +macro_rules! token_module_account_state { + ($info:expr $(,)?) => {{ + cbor::cbor_decode::( + $info + .account_state + .module_state + .as_ref() + .expect("token account state must contain token-module state"), + ) + .expect("token-module account state must decode") + }}; +} + +#[test] +fn test_lock_fund_updates_account_and_lock_state() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + utils::create_lock( + &mut context, + &mut block_state, + &lock_id, + vec![recipient.account_index()], + vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + vec![token_id.clone()], + 1_804_806_000, + ); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 1); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + assert_matches!(&events[0], BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: event_token_id, + from, + to, + amount, + from_lock, + to_lock, + .. + }) => { + assert_eq!(event_token_id, &token_id); + assert_eq!(from, &TokenHolder::Account(sender_addr)); + assert_eq!(to, &TokenHolder::Account(sender_addr)); + assert_eq!(amount.amount, RawTokenAmount(250)); + assert_eq!(amount.decimals, 4); + assert_eq!(from_lock, &None); + assert_eq!(to_lock, &Some(lock_id.clone())); + }); + + let sender_info = + token_account_info!(&context, &block_state, sender.account_index(), &token_id); + assert_eq!( + sender_info.account_state.balance.amount, + RawTokenAmount(1000) + ); + let sender_state = token_module_account_state!(&sender_info); + assert_eq!(sender_state.available.unwrap().value(), 750); + assert_eq!(sender_state.locks.len(), 1); + assert_eq!(sender_state.locks[0].lock, lock_id); + assert_eq!(sender_state.locks[0].amount.value(), 250); + + let lock_info: LockInfo = cbor::cbor_decode( + block_state + .query_lock_info(&context, &lock_id) + .expect("lock info query must succeed"), + ) + .expect("lock info must decode"); + assert_eq!(lock_info.funds.len(), 1); + assert_eq!(lock_info.funds[0].amounts.len(), 1); + assert_eq!(lock_info.funds[0].amounts[0].token, token_id); + assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 250); +} + +#[test] +fn test_lock_send_moves_locked_funds_to_recipient() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + utils::create_lock( + &mut context, + &mut block_state, + &lock_id, + vec![recipient.account_index()], + vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + vec![token_id.clone()], + 1_804_806_000, + ); + + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![ + lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + ), + lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + ) + ], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 2); + assert_matches!(&events[1], BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: event_token_id, + from, + to, + amount, + from_lock, + to_lock, + .. + }) => { + assert_eq!(event_token_id, &token_id); + assert_eq!(from, &TokenHolder::Account(sender_addr)); + assert_eq!(to, &TokenHolder::Account(recipient_addr)); + assert_eq!(amount.amount, RawTokenAmount(100)); + assert_eq!(amount.decimals, 4); + assert_eq!(from_lock, &Some(lock_id.clone())); + assert_eq!(to_lock, &None); + }); + + let sender_info = + token_account_info!(&context, &block_state, sender.account_index(), &token_id); + assert_eq!( + sender_info.account_state.balance.amount, + RawTokenAmount(900) + ); + let sender_state = token_module_account_state!(&sender_info); + assert_eq!(sender_state.available.unwrap().value(), 750); + assert_eq!(sender_state.locks.len(), 1); + assert_eq!(sender_state.locks[0].amount.value(), 150); + + let recipient_info = + token_account_info!(&context, &block_state, recipient.account_index(), &token_id); + assert_eq!( + recipient_info.account_state.balance.amount, + RawTokenAmount(100) + ); + let recipient_state = token_module_account_state!(&recipient_info); + assert!(recipient_state.available.is_none()); + assert!(recipient_state.locks.is_empty()); + + let lock_info: LockInfo = cbor::cbor_decode( + block_state + .query_lock_info(&context, &lock_id) + .expect("lock info query must succeed"), + ) + .expect("lock info must decode"); + assert_eq!(lock_info.funds.len(), 1); + assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 150); +} + +#[test] +fn test_lock_return_removes_empty_lock_balance_reference() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + utils::create_lock( + &mut context, + &mut block_state, + &lock_id, + vec![recipient.account_index()], + vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Return, + ], + }], + vec![token_id.clone()], + 1_804_806_000, + ); + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_return( + token_id.clone(), + lock_id.clone(), + sender_addr, + TokenAmount::from_raw(250, 4), + None, + )], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 1); + assert_matches!(&events[0], BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: event_token_id, + from, + to, + amount, + from_lock, + to_lock, + .. + }) => { + assert_eq!(event_token_id, &token_id); + assert_eq!(from, &TokenHolder::Account(sender_addr)); + assert_eq!(to, &TokenHolder::Account(sender_addr)); + assert_eq!(amount.amount, RawTokenAmount(250)); + assert_eq!(amount.decimals, 4); + assert_eq!(from_lock, &Some(lock_id.clone())); + assert_eq!(to_lock, &None); + }); + + let sender_info = + token_account_info!(&context, &block_state, sender.account_index(), &token_id); + assert_eq!( + sender_info.account_state.balance.amount, + RawTokenAmount(1000) + ); + let sender_state = token_module_account_state!(&sender_info); + assert!(sender_state.available.is_none()); + assert!(sender_state.locks.is_empty()); + + let lock_info: LockInfo = cbor::cbor_decode( + block_state + .query_lock_info(&context, &lock_id) + .expect("lock info query must succeed"), + ) + .expect("lock info must decode"); + assert!(lock_info.funds.is_empty()); +} + +#[test] +fn test_lock_transfer_rejects_unauthorized_operations() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let other = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + utils::create_lock( + &mut context, + &mut block_state, + &lock_id, + vec![recipient.account_index()], + vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + vec![token_id.clone()], + 1_804_806_000, + ); + + let other_addr = context + .external + .account_canonical_address(other.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + other.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockFundNotAuthorized(lock_id.clone(), other_addr)); + }); + + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + owner_addr, + recipient_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockSendNotAuthorized(lock_id.clone(), owner_addr)); + }); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_return( + token_id.clone(), + lock_id.clone(), + owner_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockReturnNotAuthorized(lock_id, owner_addr)); + }); +} + +#[test] +fn test_lock_send_rejects_non_recipient() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let non_recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + utils::create_lock( + &mut context, + &mut block_state, + &lock_id, + vec![recipient.account_index()], + vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + vec![token_id.clone()], + 1_804_806_000, + ); + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let non_recipient_addr = context + .external + .account_canonical_address(non_recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + owner_addr, + non_recipient_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockRecipientNotPermitted(lock_id, non_recipient_addr)); + }); +} + +#[test] +fn test_lock_operations_reject_after_expiry() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + utils::create_lock( + &mut context, + &mut block_state, + &lock_id, + vec![recipient.account_index()], + vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + LockControllerSimpleV0Capability::Return, + ], + }], + vec![token_id.clone()], + 10, + ); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 20_000, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id.clone())); + }); + + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 20_000, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + owner_addr, + recipient_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id.clone())); + }); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 20_000, + vec![lock_return( + token_id, + lock_id.clone(), + owner_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id)); + }); +} From 27cae2f421764027955675b6f33a2d7d668fa622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 2 Jun 2026 10:21:19 +0200 Subject: [PATCH 07/15] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db584500a8..5c5714aef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased changes - Add support for the following "meta update" operations: + - `lockFund`: move protocol-level tokens from an account's available balance into a PLT Lock. + - `lockSend`: move protocol-level tokens from a PLT Lock to a recipient account's available balance. + - `lockReturn`: release protocol-level tokens from a PLT Lock back to the owner's available balance. - `lockCancel`: cancel a PLT Lock, releasing all funds to their owners. - Populate the protocol-level token account `module_state` returned by `GetAccountInfo` with available balance and lock details. From 1fe66f97a76efcad518e94f538d94a192472b1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 2 Jun 2026 16:00:17 +0200 Subject: [PATCH 08/15] Ensure we enforce `keep_alive` --- .github/workflows/release.yaml | 2 +- .../src/scheduler/plt_scheduler.rs | 58 ++++-- plt/plt-scheduler/tests/lock_cancel.rs | 68 +++---- plt/plt-scheduler/tests/lock_transfer.rs | 191 ++++++++++++------ plt/plt-scheduler/tests/plt_lock_queries.rs | 17 +- plt/plt-scheduler/tests/plt_queries.rs | 68 +++---- plt/plt-scheduler/tests/utils/lock.rs | 31 +-- 7 files changed, 270 insertions(+), 165 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e600dc6ba9..fbb5f901e2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,7 +21,7 @@ on: env: UBUNTU_VERSION: '22.04' - STATIC_LIBRARIES_IMAGE_TAG: 'rust-1.94_ghc-9.10.2' + STATIC_LIBRARIES_IMAGE_TAG: 'rust-1.95.0_ghc-9.10.2' STACK_VERSION: '3.7.1' FLATBUFFERS_VERSION: '23.5.26' GHC_VERSION: '9.10.2' diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index 22c7da3fb4..3113594949 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -22,7 +22,9 @@ use plt_block_state::entity::protocol_level_tokens::p11::TokenP11; use plt_block_state::entity::{EntityContext, EntityContextTypes}; use plt_block_state::external::{OverflowError, RawTokenAmountDelta}; use plt_block_state::failure::{BlockStateFailure, BlockStateResult}; -use plt_block_state::persistent::protocol_level_locks::p11::LockConfiguration; +use plt_block_state::persistent::protocol_level_locks::p11::{ + LockConfiguration, LockControllerConfig, +}; use plt_block_state::persistent::protocol_level_tokens::p9::TokenConfiguration; use plt_scheduler_types::types::events::{self, BlockItemEvent, TokenTransferEvent}; use plt_scheduler_types::types::reject_reasons::{ @@ -230,11 +232,18 @@ where let token_index = token.token_p9_base.token_index(); block_state.update_token(context, token)?; - if old_locked == raw_amount && old_locked > RawTokenAmount(0) { - let removed = lock.remove_lock_balance_ref(source.account_index(), token_index); - if removed { - block_state.update_lock(context, lock)?; - } + let mut destroy_lock = false; + if old_locked != raw_amount || old_locked == RawTokenAmount(0) { + // No lock balance ref is removed unless the full remaining locked amount is sent. + } else if !lock.remove_lock_balance_ref(source.account_index(), token_index) { + // Nothing to update if there was no corresponding lock balance ref. + } else if lock.lock_balance_refs().is_empty() + && !lock_configuration_keeps_alive(&lock_configuration) + { + block_state.delete_lock(context, lock.lock_id())?; + destroy_lock = true; + } else { + block_state.update_lock(context, lock)?; } let memo = meta_lock_send_details.memo.map(transactions::Memo::from); @@ -244,9 +253,14 @@ where to: TokenHolder::Account(recipient_address), amount: TokenAmount::from_raw(raw_amount.0, token_configuration.decimals), memo, - from_lock: Some(meta_lock_send_details.lock), + from_lock: Some(meta_lock_send_details.lock.clone()), to_lock: None, })); + if destroy_lock { + events.push(BlockItemEvent::LockDestroyed(events::LockDestroyEvent { + lock_id: meta_lock_send_details.lock, + })); + } } LockOperation::Return(meta_lock_return_details) => { // TODO: (COR-2306) charge. @@ -317,11 +331,18 @@ where let token_index = token.token_p9_base.token_index(); block_state.update_token(context, token)?; - if old_locked == raw_amount && old_locked > RawTokenAmount(0) { - let removed = lock.remove_lock_balance_ref(source.account_index(), token_index); - if removed { - block_state.update_lock(context, lock)?; - } + let mut destroy_lock = false; + if old_locked != raw_amount || old_locked == RawTokenAmount(0) { + // No lock balance ref is removed unless the full remaining locked amount is sent. + } else if !lock.remove_lock_balance_ref(source.account_index(), token_index) { + // Nothing to update if there was no corresponding lock balance ref. + } else if lock.lock_balance_refs().is_empty() + && !lock_configuration_keeps_alive(&lock_configuration) + { + block_state.delete_lock(context, lock.lock_id())?; + destroy_lock = true; + } else { + block_state.update_lock(context, lock)?; } let memo = meta_lock_return_details.memo.map(transactions::Memo::from); @@ -331,9 +352,14 @@ where to: TokenHolder::Account(source_address), amount: TokenAmount::from_raw(raw_amount.0, token_configuration.decimals), memo, - from_lock: Some(meta_lock_return_details.lock), + from_lock: Some(meta_lock_return_details.lock.clone()), to_lock: None, })); + if destroy_lock { + events.push(BlockItemEvent::LockDestroyed(events::LockDestroyEvent { + lock_id: meta_lock_return_details.lock, + })); + } } LockOperation::Create(meta_lock_create_details) => { let bsq = ExecutionTimeBlockStateP11 { @@ -425,6 +451,12 @@ where Ok(()) } +fn lock_configuration_keeps_alive(configuration: &LockConfiguration) -> bool { + match configuration.controller() { + LockControllerConfig::SimpleV0(controller) => controller.keep_alive, + } +} + fn get_available_balance( context: &EntityContext, token: &TokenP11, diff --git a/plt/plt-scheduler/tests/lock_cancel.rs b/plt/plt-scheduler/tests/lock_cancel.rs index 951df724ee..f1f32e2a63 100644 --- a/plt/plt-scheduler/tests/lock_cancel.rs +++ b/plt/plt-scheduler/tests/lock_cancel.rs @@ -54,18 +54,17 @@ fn test_cancel_by_canceller() { sequence_number: 2, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![account_index_1], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![account_index_1], + grants: vec![LockControllerSimpleV0Grant { account: account_index_2, roles: vec![LockControllerSimpleV0Capability::Cancel], }], - vec![plt_x.clone()], - 1000, - ); + tokens: vec![plt_x.clone()], + expiry: 1000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let transaction_context = plt_scheduler::TransactionContext { energy_limit: Energy::from(u64::MAX), @@ -114,18 +113,17 @@ fn test_cancel_unauthorized() { sequence_number: 2, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![account_index_1], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![account_index_1], + grants: vec![LockControllerSimpleV0Grant { account: account_index_1, roles: vec![LockControllerSimpleV0Capability::Cancel], }], - vec![plt_x.clone()], - 1000, - ); + tokens: vec![plt_x.clone()], + expiry: 1000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let sender_addr = context.external.account_canonical_address(account_index_2); let transaction_context = plt_scheduler::TransactionContext { @@ -173,18 +171,17 @@ fn test_cancel_after_expiry() { sequence_number: 2, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![account_index_1], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![account_index_1], + grants: vec![LockControllerSimpleV0Grant { account: account_index_2, roles: vec![LockControllerSimpleV0Capability::Cancel], }], - vec![plt_x.clone()], - 1000, - ); + tokens: vec![plt_x.clone()], + expiry: 1000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let transaction_context = plt_scheduler::TransactionContext { energy_limit: Energy::from(u64::MAX), @@ -250,12 +247,9 @@ fn test_cancel_with_balances() { sequence_number: 2, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![account_index_1], - vec![ + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![account_index_1], + grants: vec![ LockControllerSimpleV0Grant { account: account_index_2, roles: vec![LockControllerSimpleV0Capability::Cancel], @@ -272,9 +266,11 @@ fn test_cancel_with_balances() { ], }, ], - vec![plt_x.clone()], - 1000, - ); + tokens: vec![plt_x.clone()], + expiry: 1000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); utils::lock_balance( &mut context, &mut block_state, diff --git a/plt/plt-scheduler/tests/lock_transfer.rs b/plt/plt-scheduler/tests/lock_transfer.rs index 9d74c0c745..19c4cbcb73 100644 --- a/plt/plt-scheduler/tests/lock_transfer.rs +++ b/plt/plt-scheduler/tests/lock_transfer.rs @@ -17,7 +17,8 @@ use concordium_base::transactions::Payload; use plt_block_state::{ entity::entity_test_stub, persistent::protocol_level_locks::p11::LockControllerSimpleV0Grant, }; -use plt_scheduler_types::types::events::{BlockItemEvent, TokenTransferEvent}; +use plt_scheduler::queries::QueryLockError; +use plt_scheduler_types::types::events::{BlockItemEvent, LockDestroyEvent, TokenTransferEvent}; use plt_scheduler_types::types::execution::TransactionOutcome; use plt_scheduler_types::types::reject_reasons::TransactionRejectReason; use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenHolder}; @@ -101,18 +102,17 @@ fn test_lock_fund_updates_account_and_lock_state() { ); let lock_id = LockId::new(sender.account_index(), 7u64, 0); - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: sender.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let outcome = execute_meta_update!( &mut context, @@ -199,21 +199,20 @@ fn test_lock_send_moves_locked_funds_to_recipient() { ); let lock_id = LockId::new(sender.account_index(), 7u64, 0); - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: sender.account_index(), roles: vec![ LockControllerSimpleV0Capability::Fund, LockControllerSimpleV0Capability::Send, ], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let sender_addr = context .external @@ -296,7 +295,7 @@ fn test_lock_send_moves_locked_funds_to_recipient() { } #[test] -fn test_lock_return_removes_empty_lock_balance_reference() { +fn test_lock_return_deletes_empty_lock_when_keep_alive_is_false() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); @@ -320,21 +319,20 @@ fn test_lock_return_removes_empty_lock_balance_reference() { ); let lock_id = LockId::new(sender.account_index(), 7u64, 0); - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: sender.account_index(), roles: vec![ LockControllerSimpleV0Capability::Fund, LockControllerSimpleV0Capability::Return, ], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); execute_meta_update!( &mut context, &mut block_state, @@ -366,7 +364,7 @@ fn test_lock_return_removes_empty_lock_balance_reference() { ); let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); - assert_eq!(events.len(), 1); + assert_eq!(events.len(), 2); assert_matches!(&events[0], BlockItemEvent::TokenTransfer(TokenTransferEvent { token_id: event_token_id, from, @@ -384,6 +382,9 @@ fn test_lock_return_removes_empty_lock_balance_reference() { assert_eq!(from_lock, &Some(lock_id.clone())); assert_eq!(to_lock, &None); }); + assert_matches!(&events[1], BlockItemEvent::LockDestroyed(LockDestroyEvent { lock_id: event_lock_id }) => { + assert_eq!(event_lock_id, &lock_id); + }); let sender_info = token_account_info!(&context, &block_state, sender.account_index(), &token_id); @@ -395,6 +396,85 @@ fn test_lock_return_removes_empty_lock_balance_reference() { assert!(sender_state.available.is_none()); assert!(sender_state.locks.is_empty()); + assert_matches!( + block_state.query_lock_info(&context, &lock_id), + Err(QueryLockError::LockDoesNotExist) + ); +} + +#[test] +fn test_lock_return_keeps_empty_lock_when_keep_alive_is_true() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Return, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: true, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_return( + token_id.clone(), + lock_id.clone(), + sender_addr, + TokenAmount::from_raw(250, 4), + None, + )], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 1); + assert_matches!(&events[0], BlockItemEvent::TokenTransfer(..)); + let lock_info: LockInfo = cbor::cbor_decode( block_state .query_lock_info(&context, &lock_id) @@ -430,18 +510,17 @@ fn test_lock_transfer_rejects_unauthorized_operations() { ); let lock_id = LockId::new(owner.account_index(), 7u64, 0); - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: owner.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let other_addr = context .external @@ -543,21 +622,20 @@ fn test_lock_send_rejects_non_recipient() { ); let lock_id = LockId::new(owner.account_index(), 7u64, 0); - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: owner.account_index(), roles: vec![ LockControllerSimpleV0Capability::Fund, LockControllerSimpleV0Capability::Send, ], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); execute_meta_update!( &mut context, &mut block_state, @@ -622,12 +700,9 @@ fn test_lock_operations_reject_after_expiry() { ); let lock_id = LockId::new(owner.account_index(), 7u64, 0); - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: owner.account_index(), roles: vec![ LockControllerSimpleV0Capability::Fund, @@ -635,9 +710,11 @@ fn test_lock_operations_reject_after_expiry() { LockControllerSimpleV0Capability::Return, ], }], - vec![token_id.clone()], - 10, - ); + tokens: vec![token_id.clone()], + expiry: 10, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); let outcome = execute_meta_update!( &mut context, diff --git a/plt/plt-scheduler/tests/plt_lock_queries.rs b/plt/plt-scheduler/tests/plt_lock_queries.rs index 65fb4365b5..85f6b727eb 100644 --- a/plt/plt-scheduler/tests/plt_lock_queries.rs +++ b/plt/plt-scheduler/tests/plt_lock_queries.rs @@ -57,18 +57,17 @@ fn test_query_lock_info_cbor_round_trip_with_funded_balances() { sequence_number: 1, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: funding_account.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); utils::lock_balance( &mut context, &mut block_state, diff --git a/plt/plt-scheduler/tests/plt_queries.rs b/plt/plt-scheduler/tests/plt_queries.rs index 97fdfb16a4..9b784e4f35 100644 --- a/plt/plt-scheduler/tests/plt_queries.rs +++ b/plt/plt-scheduler/tests/plt_queries.rs @@ -182,18 +182,17 @@ fn test_query_token_account_info_available_with_locked_balance() { sequence_number: 1, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: account.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); utils::lock_balance( &mut context, &mut block_state, @@ -255,35 +254,33 @@ fn test_query_token_account_info_available_with_multiple_locks() { sequence_number: 1, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id1, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config1 = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: account.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id1, lock_config1); let lock_id2 = LockId { account_index: account.account_index().into(), sequence_number: 2, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id2, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config2 = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: account.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id2, lock_config2); utils::lock_balance( &mut context, &mut block_state, @@ -354,18 +351,17 @@ fn test_query_token_account_info_available_zero_when_fully_locked() { sequence_number: 1, creation_order: 0, }; - utils::create_lock( - &mut context, - &mut block_state, - &lock_id, - vec![recipient.account_index()], - vec![LockControllerSimpleV0Grant { + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { account: account.account_index(), roles: vec![LockControllerSimpleV0Capability::Fund], }], - vec![token_id.clone()], - 1_804_806_000, - ); + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); utils::lock_balance( &mut context, &mut block_state, diff --git a/plt/plt-scheduler/tests/utils/lock.rs b/plt/plt-scheduler/tests/utils/lock.rs index 3a8d2ba593..b74b8af04b 100644 --- a/plt/plt-scheduler/tests/utils/lock.rs +++ b/plt/plt-scheduler/tests/utils/lock.rs @@ -12,18 +12,22 @@ use plt_block_state::entity::entity_test_stub::StubbedExternalBlockStateTypes; use plt_block_state::persistent::protocol_level_locks::p11::LockControllerSimpleV0Grant; use plt_scheduler_types::types::tokens::RawTokenAmount; -/// Create a lock in the block state. The lock controller is hard-coded to the -/// `SimpleV0` variant (the only one currently exposed) with `keep_alive = false` -/// and no memo — individual tests may extend this helper if other variants are -/// needed. +/// Simple configuration for creating a lock in tests. +#[derive(Debug, Clone)] +pub struct CreateLockSimpleConfig { + pub recipients: Vec, + pub grants: Vec, + pub tokens: Vec, + pub expiry: u64, + pub keep_alive: bool, +} + +/// Create a lock in the block state. pub fn create_lock( context: &mut EntityContext, block_state: &mut BlockStateP11, lock_id: &LockId, - recipients: Vec, - grants: Vec, - tokens: Vec, - expiry: u64, + config: CreateLockSimpleConfig, ) { use concordium_base::protocol_level_locks::*; use concordium_base::protocol_level_tokens::meta_operations::*; @@ -38,8 +42,9 @@ pub fn create_lock( .canonical_account_address, ) }; - let recipients = recipients.iter().map(resolve_account).collect(); - let grants = grants + let recipients = config.recipients.iter().map(resolve_account).collect(); + let grants = config + .grants .iter() .map(|grant| LockControllerSimpleV0Grant { account: resolve_account(&grant.account), @@ -49,11 +54,11 @@ pub fn create_lock( let operations = MetaUpdateOperations { operations: vec![lock_create(LockConfig { recipients, - expiry: TransactionTime::from(expiry), + expiry: TransactionTime::from(config.expiry), controller: LockController::SimpleV0(LockControllerSimpleV0 { grants, - tokens, - keep_alive: false, + tokens: config.tokens, + keep_alive: config.keep_alive, memo: None, }), })], From 02cb5c6f17970c385815dcefe9632cd60b1547f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Wed, 3 Jun 2026 12:00:06 +0200 Subject: [PATCH 09/15] Ensure lock transfer operations are subject to rules imposed by tokens --- .../balance_operations.rs | 199 ++++ .../src/scheduler/plt_scheduler.rs | 954 ++++++++++-------- plt/plt-scheduler/tests/lock_cancel.rs | 123 ++- plt/plt-scheduler/tests/lock_fund.rs | 523 ++++++++++ .../{lock_transfer.rs => lock_return.rs} | 559 +++++----- plt/plt-scheduler/tests/lock_send.rs | 944 +++++++++++++++++ plt/plt-scheduler/tests/utils/lock.rs | 4 + plt/plt-scheduler/tests/utils/token.rs | 28 + 8 files changed, 2603 insertions(+), 731 deletions(-) create mode 100644 plt/plt-scheduler/tests/lock_fund.rs rename plt/plt-scheduler/tests/{lock_transfer.rs => lock_return.rs} (66%) create mode 100644 plt/plt-scheduler/tests/lock_send.rs diff --git a/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs b/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs index a7a592035e..337ecc0472 100644 --- a/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs +++ b/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs @@ -214,6 +214,205 @@ pub fn transfer( Ok(Ok(())) } +/// Move `amount` of tokens from an account's available balance into the control of a lock. +/// The tokens remain on the account but become locked. +/// +/// Returns `true` if the account had no locked balance for this lock before — i.e. a new +/// `(account, lock)` balance relationship was created — and `false` if the account already +/// held a non-zero locked balance for the lock. +/// +/// # Events +/// +/// Produces a [`TokenTransferEvent`] with `to_lock` set to the lock id. +/// +/// # Errors +/// +/// - [`InsufficientBalanceError`] The account has insufficient available balance. +pub fn lock_amount( + context: &mut EntityContext, + events: &mut impl Extend, + token: &mut TokenP11, + account: &Account, + account_address: AccountAddress, + lock_id: &LockId, + amount: RawTokenAmount, + memo: Option, +) -> BlockStateResult> { + // Compute available = total - sum(all locked balances). + let total = account.account_token_balance(context, token.token_p9_base.token_index()); + let mut total_locked = RawTokenAmount(0); + for (_, locked_balance) in token + .get_locked_balances_for_account(context, account.account_index())? + .into_iter() + { + total_locked = total_locked.checked_add(locked_balance).ok_or_else(|| { + BlockStateFailure::Invariant("Total locked token balance overflow".to_string()) + })?; + } + let available = total.checked_sub(total_locked).ok_or_else(|| { + BlockStateFailure::Invariant( + "Total locked token balance exceeds account token balance".to_string(), + ) + })?; + if amount > available { + return Ok(Err(InsufficientBalanceError { + available, + required: amount, + })); + } + + let old_locked = + token.get_locked_balance_for_account(context, account.account_index(), lock_id)?; + let new_locked = old_locked.checked_add(amount).ok_or_else(|| { + BlockStateFailure::Invariant("Locked balance overflow at fund".to_string()) + })?; + token.set_locked_balance_for_account(context, account.account_index(), lock_id, new_locked)?; + + let token_configuration = token.token_p9_base.token_configuration(context)?; + events.extend(Some(BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: token_configuration.token_id, + from: TokenHolder::Account(account_address), + to: TokenHolder::Account(account_address), + amount: TokenAmount { + amount, + decimals: token_configuration.decimals, + }, + memo, + from_lock: None, + to_lock: Some(lock_id.clone()), + }))); + + Ok(Ok( + old_locked == RawTokenAmount(0) && new_locked > RawTokenAmount(0) + )) +} + +/// Move `amount` of tokens from a lock's control on `source` to `recipient`'s available balance. +/// +/// Returns the remaining locked balance for `source` after the operation. A return value of +/// zero indicates the `(source, lock)` balance relationship should be removed by the caller. +/// +/// # Events +/// +/// Produces a [`TokenTransferEvent`] with `from_lock` set to the lock id. +/// +/// # Errors +/// +/// - [`InsufficientBalanceError`] The source has insufficient locked balance. +#[allow(clippy::too_many_arguments)] +pub fn send_locked_amount( + context: &mut EntityContext, + events: &mut impl Extend, + token: &mut TokenP11, + source: &Account, + source_address: AccountAddress, + recipient: &Account, + recipient_address: AccountAddress, + lock_id: &LockId, + amount: RawTokenAmount, + memo: Option, +) -> BlockStateResult> { + let old_locked = + token.get_locked_balance_for_account(context, source.account_index(), lock_id)?; + let new_locked = match old_locked.checked_sub(amount) { + Some(new_locked) => new_locked, + None => { + return Ok(Err(InsufficientBalanceError { + available: old_locked, + required: amount, + })); + } + }; + token.set_locked_balance_for_account(context, source.account_index(), lock_id, new_locked)?; + + source + .update_token_account_balance( + context, + token.token_p9_base.token_index(), + RawTokenAmountDelta::Subtract(amount), + ) + .map_err(|_err: OverflowError| { + BlockStateFailure::Invariant("Transfer source token amount overflow".to_string()) + })?; + recipient + .update_token_account_balance( + context, + token.token_p9_base.token_index(), + RawTokenAmountDelta::Add(amount), + ) + .map_err(|_err: OverflowError| { + BlockStateFailure::Invariant("Transfer destination token amount overflow".to_string()) + })?; + + let token_configuration = token.token_p9_base.token_configuration(context)?; + events.extend(Some(BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: token_configuration.token_id, + from: TokenHolder::Account(source_address), + to: TokenHolder::Account(recipient_address), + amount: TokenAmount { + amount, + decimals: token_configuration.decimals, + }, + memo, + from_lock: Some(lock_id.clone()), + to_lock: None, + }))); + + Ok(Ok(new_locked)) +} + +/// Release `amount` from a lock's control back to the owner account's available balance. +/// The tokens remain on the account but are freed from lock control. +/// +/// Returns the remaining locked balance for `account` after the operation. A return value of +/// zero indicates the `(account, lock)` balance relationship should be removed by the caller. +/// +/// # Events +/// +/// Produces a [`TokenTransferEvent`] with `from_lock` set to the lock id. +/// +/// # Errors +/// +/// - [`InsufficientBalanceError`] The account has insufficient locked balance. +pub fn return_locked_amount( + context: &mut EntityContext, + events: &mut impl Extend, + token: &mut TokenP11, + account_index: AccountIndex, + account_address: AccountAddress, + lock_id: &LockId, + amount: RawTokenAmount, + memo: Option, +) -> BlockStateResult> { + let old_locked = token.get_locked_balance_for_account(context, account_index, lock_id)?; + let new_locked = match old_locked.checked_sub(amount) { + Some(new_locked) => new_locked, + None => { + return Ok(Err(InsufficientBalanceError { + available: old_locked, + required: amount, + })); + } + }; + token.set_locked_balance_for_account(context, account_index, lock_id, new_locked)?; + + let token_configuration = token.token_p9_base.token_configuration(context)?; + events.extend(Some(BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: token_configuration.token_id, + from: TokenHolder::Account(account_address), + to: TokenHolder::Account(account_address), + amount: TokenAmount { + amount, + decimals: token_configuration.decimals, + }, + memo, + from_lock: Some(lock_id.clone()), + to_lock: None, + }))); + + Ok(Ok(new_locked)) +} + /// Unlock the balance of an account associated with a particular lock for /// this particular token. This generates a `TokenTransferEvent` to reflect /// the change in the locked balance. diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index 3113594949..ce314d0344 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -6,12 +6,17 @@ use crate::protocol_level_tokens::balance_operations; use crate::protocol_level_tokens::token_module::errors::InsufficientBalanceError; use crate::scheduler::TransactionFailure; use crate::transaction_execution::TransactionExecution; +use concordium_base::base::AccountIndex; use concordium_base::common::cbor::{self}; +use concordium_base::contracts_common::AccountAddress; use concordium_base::protocol_level_locks::LockId; -use concordium_base::protocol_level_tokens::meta_operations::LockOperation; +use concordium_base::protocol_level_tokens::meta_operations::{ + LockOperation, MetaLockCancelDetails, MetaLockCreateDetails, MetaLockFundDetails, + MetaLockReturnDetails, MetaLockSendDetails, +}; use concordium_base::protocol_level_tokens::{ - DeserializationFailureRejectReason, RawCbor, TokenAmount as BaseTokenAmount, - TokenBalanceInsufficientRejectReason, TokenModuleRejectReason, + DeserializationFailureRejectReason, OperationNotPermittedRejectReason, RawCbor, + TokenAmount as BaseTokenAmount, TokenBalanceInsufficientRejectReason, TokenModuleRejectReason, }; use concordium_base::transactions; use plt_block_state::block_state::ExecutionTimeBlockStateP11; @@ -20,17 +25,16 @@ use plt_block_state::entity::block_state::TokenNotFoundByIdError; use plt_block_state::entity::block_state::p11::BlockStateP11; use plt_block_state::entity::protocol_level_tokens::p11::TokenP11; use plt_block_state::entity::{EntityContext, EntityContextTypes}; -use plt_block_state::external::{OverflowError, RawTokenAmountDelta}; -use plt_block_state::failure::{BlockStateFailure, BlockStateResult}; +use plt_block_state::failure::BlockStateFailure; use plt_block_state::persistent::protocol_level_locks::p11::{ LockConfiguration, LockControllerConfig, }; use plt_block_state::persistent::protocol_level_tokens::p9::TokenConfiguration; -use plt_scheduler_types::types::events::{self, BlockItemEvent, TokenTransferEvent}; +use plt_scheduler_types::types::events::{self, BlockItemEvent}; use plt_scheduler_types::types::reject_reasons::{ EncodedTokenModuleRejectReason, TransactionRejectReason, }; -use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenAmount, TokenHolder}; +use plt_scheduler_types::types::tokens::RawTokenAmount; /// Execute [`LockOperation`]. pub fn execute_lock_operation( @@ -45,408 +49,448 @@ where EntityContext: Clone, { match lock_operation { - LockOperation::Fund(meta_lock_fund_details) => { - // TODO: (COR-2306) charge. - let bsq = ExecutionTimeBlockStateP11 { - block_state: block_state.clone(), - context: context.clone(), - }; - let mut lock = block_state - .lock_by_id(context, &meta_lock_fund_details.lock)? - .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; - - let lock_configuration = lock.lock_configuration(context); - if lock_configuration - .expiry() - .is_expired(transaction_execution.timestamp()) - { - return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); - } + LockOperation::Fund(details) => execute_lock_fund( + context, + transaction_execution, + block_state, + operation_index, + details, + events, + ), + LockOperation::Send(details) => execute_lock_send( + context, + transaction_execution, + block_state, + operation_index, + details, + events, + ), + LockOperation::Return(details) => execute_lock_return( + context, + transaction_execution, + block_state, + operation_index, + details, + events, + ), + LockOperation::Create(details) => { + execute_lock_create(context, transaction_execution, block_state, details, events) + } + LockOperation::Cancel(details) => { + execute_lock_cancel(context, transaction_execution, block_state, details, events) + } + } +} - lock_configuration.controller().validate_operation( - &bsq, - transaction_execution.sender_account_address(), - transaction_execution.sender_account(), - &lock_controller::LockOperation::Fund(meta_lock_fund_details.clone()), - )?; - - let mut token = block_state - .token_by_id(context, &meta_lock_fund_details.token)? - .map_err(|TokenNotFoundByIdError(token_id)| { - TransactionRejectReason::NonExistentTokenId(token_id) - })?; - let token_configuration = token.token_p9_base.token_configuration(context)?; - let raw_amount = parse_raw_amount( - &token_configuration, - meta_lock_fund_details.amount, +fn execute_lock_fund( + context: &mut EntityContext, + transaction_execution: &TransactionExecution, + block_state: &mut BlockStateP11, + operation_index: usize, + details: MetaLockFundDetails, + events: &mut Vec, +) -> Result<(), TransactionFailure> +where + EntityContext: Clone, +{ + // TODO: (COR-2306) charge. + let bsq = ExecutionTimeBlockStateP11 { + block_state: block_state.clone(), + context: context.clone(), + }; + let mut lock = block_state + .lock_by_id(context, &details.lock)? + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = lock.lock_configuration(context); + if lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); + } + + lock_configuration.controller().validate_operation( + &bsq, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Fund(details.clone()), + )?; + + let mut token = block_state.token_by_id(context, &details.token)?.map_err( + |TokenNotFoundByIdError(token_id)| TransactionRejectReason::NonExistentTokenId(token_id), + )?; + let token_configuration = token.token_p9_base.token_configuration(context)?; + let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; + + let sender = ( + transaction_execution.sender_account(), + transaction_execution.sender_account_address(), + ); + check_token_transfer_restrictions( + context, + &token, + operation_index, + &token_configuration, + sender, + None, + )?; + + let memo = details.memo.map(transactions::Memo::from); + let is_new_holder = match balance_operations::lock_amount( + context, + events, + &mut token, + transaction_execution.sender_account(), + transaction_execution.sender_account_address(), + lock.lock_id(), + raw_amount, + memo, + )? { + Ok(is_new_holder) => is_new_holder, + Err(err) => { + return Err(token_balance_insufficient_reject_reason( operation_index, - )?; + &token_configuration, + err, + ) + .into()); + } + }; - let available = - get_available_balance(context, &token, transaction_execution.sender_account())?; - if raw_amount > available { - return Err(token_balance_insufficient_reject_reason( - operation_index, - &token_configuration, - InsufficientBalanceError { - available, - required: raw_amount, - }, - ) - .into()); - } + let token_index = token.token_p9_base.token_index(); + block_state.update_token(context, token)?; - let sender_index = transaction_execution.sender_account().account_index(); - let old_locked = - token.get_locked_balance_for_account(context, sender_index, lock.lock_id())?; - let new_locked = old_locked.checked_add(raw_amount).ok_or_else(|| { - BlockStateFailure::Invariant("Locked balance overflow at fund".to_string()) - })?; - token.set_locked_balance_for_account( - context, - sender_index, - lock.lock_id(), - new_locked, - )?; - let token_index = token.token_p9_base.token_index(); - block_state.update_token(context, token)?; - - if old_locked == RawTokenAmount(0) && new_locked > RawTokenAmount(0) { - lock.add_lock_balance_ref(sender_index, token_index); - block_state.update_lock(context, lock)?; - } + if is_new_holder { + lock.add_lock_balance_ref( + transaction_execution.sender_account().account_index(), + token_index, + ); + block_state.update_lock(context, lock)?; + } + Ok(()) +} - let memo = meta_lock_fund_details.memo.map(transactions::Memo::from); - events.push(BlockItemEvent::TokenTransfer(TokenTransferEvent { - token_id: token_configuration.token_id, - from: TokenHolder::Account(transaction_execution.sender_account_address()), - to: TokenHolder::Account(transaction_execution.sender_account_address()), - amount: TokenAmount::from_raw(raw_amount.0, token_configuration.decimals), - memo, - from_lock: None, - to_lock: Some(meta_lock_fund_details.lock), - })); - } - LockOperation::Send(meta_lock_send_details) => { - // TODO: (COR-2306) charge. - let bsq = ExecutionTimeBlockStateP11 { - block_state: block_state.clone(), - context: context.clone(), - }; - let mut lock = block_state - .lock_by_id(context, &meta_lock_send_details.lock)? - .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; - - let lock_configuration = lock.lock_configuration(context); - if lock_configuration - .expiry() - .is_expired(transaction_execution.timestamp()) - { - return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); - } +fn execute_lock_send( + context: &mut EntityContext, + transaction_execution: &TransactionExecution, + block_state: &mut BlockStateP11, + operation_index: usize, + details: MetaLockSendDetails, + events: &mut Vec, +) -> Result<(), TransactionFailure> +where + EntityContext: Clone, +{ + // TODO: (COR-2306) charge. + let bsq = ExecutionTimeBlockStateP11 { + block_state: block_state.clone(), + context: context.clone(), + }; + let lock = block_state + .lock_by_id(context, &details.lock)? + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = lock.lock_configuration(context); + if lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); + } - let source_address = meta_lock_send_details.source.address; - let source = context - .account_by_address(&source_address) - .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; - let recipient_address = meta_lock_send_details.recipient.address; - let recipient = context - .account_by_address(&recipient_address) - .map_err(|_| TransactionRejectReason::InvalidAccountReference(recipient_address))?; - - if !lock_configuration.is_recipient(&recipient.account_index()) { - return Err(TransactionRejectReason::LockRecipientNotPermitted( - lock.lock_id().clone(), - recipient_address, - ) - .into()); - } + let source_address = details.source.address; + let source = context + .account_by_address(&source_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; + let recipient_address = details.recipient.address; + let recipient = context + .account_by_address(&recipient_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(recipient_address))?; + + if !lock_configuration.is_recipient(&recipient.account_index()) { + return Err(TransactionRejectReason::LockRecipientNotPermitted( + lock.lock_id().clone(), + recipient_address, + ) + .into()); + } - lock_configuration.controller().validate_operation( - &bsq, - transaction_execution.sender_account_address(), - transaction_execution.sender_account(), - &lock_controller::LockOperation::Send(meta_lock_send_details.clone()), - )?; - - let mut token = block_state - .token_by_id(context, &meta_lock_send_details.token)? - .map_err(|TokenNotFoundByIdError(token_id)| { - TransactionRejectReason::NonExistentTokenId(token_id) - })?; - let token_configuration = token.token_p9_base.token_configuration(context)?; - let raw_amount = parse_raw_amount( - &token_configuration, - meta_lock_send_details.amount, - operation_index, - )?; - let old_locked = token.get_locked_balance_for_account( - context, - source.account_index(), - lock.lock_id(), - )?; - let new_locked = match old_locked.checked_sub(raw_amount) { - Some(new_locked) => new_locked, - None => { - return Err(token_balance_insufficient_reject_reason( - operation_index, - &token_configuration, - InsufficientBalanceError { - available: old_locked, - required: raw_amount, - }, - ) - .into()); - } - }; - token.set_locked_balance_for_account( - context, - source.account_index(), - lock.lock_id(), - new_locked, - )?; - - source - .update_token_account_balance( - context, - token.token_p9_base.token_index(), - RawTokenAmountDelta::Subtract(raw_amount), - ) - .map_err(|_err: OverflowError| { - BlockStateFailure::Invariant( - "Transfer source token amount overflow".to_string(), - ) - })?; - recipient - .update_token_account_balance( - context, - token.token_p9_base.token_index(), - RawTokenAmountDelta::Add(raw_amount), - ) - .map_err(|_err: OverflowError| { - BlockStateFailure::Invariant( - "Transfer destination token amount overflow".to_string(), - ) - })?; - - let token_index = token.token_p9_base.token_index(); - block_state.update_token(context, token)?; - - let mut destroy_lock = false; - if old_locked != raw_amount || old_locked == RawTokenAmount(0) { - // No lock balance ref is removed unless the full remaining locked amount is sent. - } else if !lock.remove_lock_balance_ref(source.account_index(), token_index) { - // Nothing to update if there was no corresponding lock balance ref. - } else if lock.lock_balance_refs().is_empty() - && !lock_configuration_keeps_alive(&lock_configuration) - { - block_state.delete_lock(context, lock.lock_id())?; - destroy_lock = true; - } else { - block_state.update_lock(context, lock)?; - } + lock_configuration.controller().validate_operation( + &bsq, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Send(details.clone()), + )?; + + let mut token = block_state.token_by_id(context, &details.token)?.map_err( + |TokenNotFoundByIdError(token_id)| TransactionRejectReason::NonExistentTokenId(token_id), + )?; + let token_configuration = token.token_p9_base.token_configuration(context)?; + let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; + + let sender = (&source, source_address); + check_token_transfer_restrictions( + context, + &token, + operation_index, + &token_configuration, + sender, + Some((&recipient, recipient_address)), + )?; + + let memo = details.memo.map(transactions::Memo::from); + let remaining_locked = balance_operations::send_locked_amount( + context, + events, + &mut token, + &source, + source_address, + &recipient, + recipient_address, + lock.lock_id(), + raw_amount, + memo, + )? + .map_err(|err| { + token_balance_insufficient_reject_reason(operation_index, &token_configuration, err) + })?; + + let token_index = token.token_p9_base.token_index(); + block_state.update_token(context, token)?; + + if remaining_locked == RawTokenAmount(0) { + remove_lock_balance_ref( + context, + block_state, + events, + lock, + &lock_configuration, + source.account_index(), + token_index, + details.lock, + )?; + } - let memo = meta_lock_send_details.memo.map(transactions::Memo::from); - events.push(BlockItemEvent::TokenTransfer(TokenTransferEvent { - token_id: token_configuration.token_id, - from: TokenHolder::Account(source_address), - to: TokenHolder::Account(recipient_address), - amount: TokenAmount::from_raw(raw_amount.0, token_configuration.decimals), - memo, - from_lock: Some(meta_lock_send_details.lock.clone()), - to_lock: None, - })); - if destroy_lock { - events.push(BlockItemEvent::LockDestroyed(events::LockDestroyEvent { - lock_id: meta_lock_send_details.lock, - })); - } - } - LockOperation::Return(meta_lock_return_details) => { - // TODO: (COR-2306) charge. - let bsq = ExecutionTimeBlockStateP11 { - block_state: block_state.clone(), - context: context.clone(), - }; - let mut lock = block_state - .lock_by_id(context, &meta_lock_return_details.lock)? - .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; - - let lock_configuration = lock.lock_configuration(context); - if lock_configuration - .expiry() - .is_expired(transaction_execution.timestamp()) - { - return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); - } + Ok(()) +} - let source_address = meta_lock_return_details.source.address; - let source = context - .account_by_address(&source_address) - .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; - - lock_configuration.controller().validate_operation( - &bsq, - transaction_execution.sender_account_address(), - transaction_execution.sender_account(), - &lock_controller::LockOperation::Return(meta_lock_return_details.clone()), - )?; - - let mut token = block_state - .token_by_id(context, &meta_lock_return_details.token)? - .map_err(|TokenNotFoundByIdError(token_id)| { - TransactionRejectReason::NonExistentTokenId(token_id) - })?; - let token_configuration = token.token_p9_base.token_configuration(context)?; - let raw_amount = parse_raw_amount( - &token_configuration, - meta_lock_return_details.amount, - operation_index, - )?; - let old_locked = token.get_locked_balance_for_account( - context, - source.account_index(), - lock.lock_id(), - )?; - let new_locked = match old_locked.checked_sub(raw_amount) { - Some(new_locked) => new_locked, - None => { - return Err(token_balance_insufficient_reject_reason( - operation_index, - &token_configuration, - InsufficientBalanceError { - available: old_locked, - required: raw_amount, - }, - ) - .into()); - } - }; - token.set_locked_balance_for_account( - context, - source.account_index(), - lock.lock_id(), - new_locked, - )?; - let token_index = token.token_p9_base.token_index(); - block_state.update_token(context, token)?; - - let mut destroy_lock = false; - if old_locked != raw_amount || old_locked == RawTokenAmount(0) { - // No lock balance ref is removed unless the full remaining locked amount is sent. - } else if !lock.remove_lock_balance_ref(source.account_index(), token_index) { - // Nothing to update if there was no corresponding lock balance ref. - } else if lock.lock_balance_refs().is_empty() - && !lock_configuration_keeps_alive(&lock_configuration) - { - block_state.delete_lock(context, lock.lock_id())?; - destroy_lock = true; - } else { - block_state.update_lock(context, lock)?; - } +fn execute_lock_return( + context: &mut EntityContext, + transaction_execution: &TransactionExecution, + block_state: &mut BlockStateP11, + operation_index: usize, + details: MetaLockReturnDetails, + events: &mut Vec, +) -> Result<(), TransactionFailure> +where + EntityContext: Clone, +{ + // TODO: (COR-2306) charge. + let bsq = ExecutionTimeBlockStateP11 { + block_state: block_state.clone(), + context: context.clone(), + }; + let lock = block_state + .lock_by_id(context, &details.lock)? + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = lock.lock_configuration(context); + if lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + return Err(TransactionRejectReason::LockExpired(lock.lock_id().clone()).into()); + } - let memo = meta_lock_return_details.memo.map(transactions::Memo::from); - events.push(BlockItemEvent::TokenTransfer(TokenTransferEvent { - token_id: token_configuration.token_id, - from: TokenHolder::Account(source_address), - to: TokenHolder::Account(source_address), - amount: TokenAmount::from_raw(raw_amount.0, token_configuration.decimals), - memo, - from_lock: Some(meta_lock_return_details.lock.clone()), - to_lock: None, - })); - if destroy_lock { - events.push(BlockItemEvent::LockDestroyed(events::LockDestroyEvent { - lock_id: meta_lock_return_details.lock, - })); - } - } - LockOperation::Create(meta_lock_create_details) => { - let bsq = ExecutionTimeBlockStateP11 { - block_state: block_state.clone(), - context: context.clone(), - }; - - let config = meta_lock_create_details.config; - let account_index = transaction_execution.sender_account().account_index(); - let sequence_number = transaction_execution.transaction_sequence_number(); - let creation_order = transaction_execution.next_lock_creation_order(); - let lock_id = LockId::new(account_index, sequence_number, creation_order); - let controller = LockController::new(&bsq, config.controller)?; - - let recipients = config - .recipients - .iter() - .map( - |recipient| match context.account_by_address(&recipient.address) { - Ok(account) => Ok(account.account_index()), - Err(_) => Err(TransactionRejectReason::InvalidAccountReference( - recipient.address, - )), - }, - ) - .collect::, TransactionRejectReason>>()?; - let configuration = LockConfiguration::new(recipients, config.expiry, controller); - - let config = get_lock_config(&bsq, &configuration).map_err(|err| { - BlockStateFailure::Invariant(format!( - "Failed to get lock config for created lock: {err}" - )) - })?; - let event = events::LockCreateEvent { - lock_id: lock_id.clone(), - lock_config: RawCbor::from(cbor::cbor_encode(&config)), - }; - events.push(BlockItemEvent::LockCreated(event)); - - block_state.create_lock(context, lock_id.clone(), configuration)?; - } - LockOperation::Cancel(meta_lock_cancel_details) => { - let bsq = ExecutionTimeBlockStateP11 { - block_state: block_state.clone(), - context: context.clone(), - }; - - // TODO: (COR-2306) charge. - let lock = block_state - .lock_by_id(context, &meta_lock_cancel_details.lock)? - .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; - - let lock_configuration = lock.lock_configuration(context); - let memo: Option = meta_lock_cancel_details - .memo - .clone() - .map(transactions::Memo::from); - - if !lock_configuration - .expiry() - .is_expired(transaction_execution.timestamp()) - { - lock_configuration.controller().validate_operation( - &bsq, - transaction_execution.sender_account_address(), - transaction_execution.sender_account(), - &lock_controller::LockOperation::Cancel(meta_lock_cancel_details), - )?; - } - for (account_index, token_index) in lock.lock_balance_refs() { - let mut token = block_state.token_by_index(context, token_index)?; - balance_operations::unlock_balance( - context, - events, - &mut token, - account_index, - lock.lock_id(), - &memo, - )?; - block_state.update_token(context, token)?; - } - block_state.delete_lock(context, lock.lock_id())?; - let event = events::LockDestroyEvent { - lock_id: lock.lock_id().clone(), - }; - events.push(BlockItemEvent::LockDestroyed(event)); - } + let source_address = details.source.address; + let source = context + .account_by_address(&source_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; + + lock_configuration.controller().validate_operation( + &bsq, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Return(details.clone()), + )?; + + let mut token = block_state.token_by_id(context, &details.token)?.map_err( + |TokenNotFoundByIdError(token_id)| TransactionRejectReason::NonExistentTokenId(token_id), + )?; + let token_configuration = token.token_p9_base.token_configuration(context)?; + let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; + + let sender = (&source, source_address); + check_token_transfer_restrictions( + context, + &token, + operation_index, + &token_configuration, + sender, + None, + )?; + + let memo = details.memo.map(transactions::Memo::from); + let remaining_locked = balance_operations::return_locked_amount( + context, + events, + &mut token, + source.account_index(), + source_address, + lock.lock_id(), + raw_amount, + memo, + )? + .map_err(|err| { + token_balance_insufficient_reject_reason(operation_index, &token_configuration, err) + })?; + + let token_index = token.token_p9_base.token_index(); + block_state.update_token(context, token)?; + + if remaining_locked == RawTokenAmount(0) { + remove_lock_balance_ref( + context, + block_state, + events, + lock, + &lock_configuration, + source.account_index(), + token_index, + details.lock, + )?; + } + + Ok(()) +} + +fn execute_lock_create( + context: &mut EntityContext, + transaction_execution: &mut TransactionExecution, + block_state: &mut BlockStateP11, + details: MetaLockCreateDetails, + events: &mut Vec, +) -> Result<(), TransactionFailure> +where + EntityContext: Clone, +{ + let bsq = ExecutionTimeBlockStateP11 { + block_state: block_state.clone(), + context: context.clone(), + }; + + let config = details.config; + let account_index = transaction_execution.sender_account().account_index(); + let sequence_number = transaction_execution.transaction_sequence_number(); + let creation_order = transaction_execution.next_lock_creation_order(); + let lock_id = LockId::new(account_index, sequence_number, creation_order); + let controller = LockController::new(&bsq, config.controller)?; + + let recipients = config + .recipients + .iter() + .map( + |recipient| match context.account_by_address(&recipient.address) { + Ok(account) => Ok(account.account_index()), + Err(_) => Err(TransactionRejectReason::InvalidAccountReference( + recipient.address, + )), + }, + ) + .collect::, TransactionRejectReason>>()?; + let configuration = LockConfiguration::new(recipients, config.expiry, controller); + + let config = get_lock_config(&bsq, &configuration).map_err(|err| { + BlockStateFailure::Invariant(format!("Failed to get lock config for created lock: {err}")) + })?; + let event = events::LockCreateEvent { + lock_id: lock_id.clone(), + lock_config: RawCbor::from(cbor::cbor_encode(&config)), + }; + events.push(BlockItemEvent::LockCreated(event)); + + block_state.create_lock(context, lock_id.clone(), configuration)?; + Ok(()) +} + +fn execute_lock_cancel( + context: &mut EntityContext, + transaction_execution: &TransactionExecution, + block_state: &mut BlockStateP11, + details: MetaLockCancelDetails, + events: &mut Vec, +) -> Result<(), TransactionFailure> +where + EntityContext: Clone, +{ + let bsq = ExecutionTimeBlockStateP11 { + block_state: block_state.clone(), + context: context.clone(), + }; + + // TODO: (COR-2306) charge. + let lock = block_state + .lock_by_id(context, &details.lock)? + .map_err(|err| TransactionRejectReason::NonExistentLockId(err.0))?; + + let lock_configuration = lock.lock_configuration(context); + let memo: Option = details.memo.clone().map(transactions::Memo::from); + + if !lock_configuration + .expiry() + .is_expired(transaction_execution.timestamp()) + { + lock_configuration.controller().validate_operation( + &bsq, + transaction_execution.sender_account_address(), + transaction_execution.sender_account(), + &lock_controller::LockOperation::Cancel(details), + )?; + } + for (account_index, token_index) in lock.lock_balance_refs() { + let mut token = block_state.token_by_index(context, token_index)?; + balance_operations::unlock_balance( + context, + events, + &mut token, + account_index, + lock.lock_id(), + &memo, + )?; + block_state.update_token(context, token)?; + } + block_state.delete_lock(context, lock.lock_id())?; + let event = events::LockDestroyEvent { + lock_id: lock.lock_id().clone(), + }; + events.push(BlockItemEvent::LockDestroyed(event)); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn remove_lock_balance_ref( + context: &EntityContext, + block_state: &mut BlockStateP11, + events: &mut Vec, + mut lock: plt_block_state::entity::protocol_level_locks::p11::LockP11, + lock_configuration: &LockConfiguration, + account_index: AccountIndex, + token_index: plt_block_state::persistent::protocol_level_tokens::p9::TokenIndex, + lock_id: concordium_base::protocol_level_locks::LockId, +) -> Result<(), TransactionFailure> { + if !lock.remove_lock_balance_ref(account_index, token_index) { + // No lock state change needed: either the account still holds a non-zero balance + // controlled by the lock, or there was no balance reference to remove. + return Ok(()); + } + if lock.lock_balance_refs().is_empty() && !lock_configuration_keeps_alive(lock_configuration) { + block_state.delete_lock(context, &lock_id)?; + events.push(BlockItemEvent::LockDestroyed(events::LockDestroyEvent { + lock_id, + })); + } else { + block_state.update_lock(context, lock)?; } Ok(()) } @@ -457,27 +501,84 @@ fn lock_configuration_keeps_alive(configuration: &LockConfiguration) -> bool { } } -fn get_available_balance( +/// Check token-level transfer restrictions (pause, allow list, deny list) for a lock transfer +/// operation. +/// +/// `sender` and `sender_address` are the source of the locked funds being moved. +/// `recipient` is `Some` only for `lockSend` where funds are delivered to a different account; +/// for `lockFund` and `lockReturn` pass `None`. +fn check_token_transfer_restrictions( context: &EntityContext, token: &TokenP11, - account: &Account, -) -> BlockStateResult { - let total = account.account_token_balance(context, token.token_p9_base.token_index()); - let mut total_locked = RawTokenAmount(0); - for (_, locked_balance) in token - .get_locked_balances_for_account(context, account.account_index())? - .into_iter() - { - total_locked = total_locked.checked_add(locked_balance).ok_or_else(|| { - BlockStateFailure::Invariant("Total locked token balance overflow".to_string()) - })?; + operation_index: usize, + token_configuration: &TokenConfiguration, + sender: (&Account, AccountAddress), + recipient: Option<(&Account, AccountAddress)>, +) -> Result<(), TransactionRejectReason> { + if token.token_p9_base.is_paused(context) { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + None, + "token operation transfer is paused", + )); } - total.checked_sub(total_locked).ok_or_else(|| { - BlockStateFailure::Invariant( - "Total locked token balance exceeds account token balance".to_string(), - ) - }) + if token.token_p9_base.has_allow_list(context) { + if !token + .token_p9_base + .get_allow_list_for(context, sender.0.account_index()) + { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + Some(sender.1), + "sender not in allow list", + )); + } + if let Some((recipient_account, recipient_addr)) = recipient { + if !token + .token_p9_base + .get_allow_list_for(context, recipient_account.account_index()) + { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + Some(recipient_addr), + "recipient not in allow list", + )); + } + } + } + + if token.token_p9_base.has_deny_list(context) { + if token + .token_p9_base + .get_deny_list_for(context, sender.0.account_index()) + { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + Some(sender.1), + "sender in deny list", + )); + } + if let Some((recipient_account, recipient_addr)) = recipient { + if token + .token_p9_base + .get_deny_list_for(context, recipient_account.account_index()) + { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + Some(recipient_addr), + "recipient in deny list", + )); + } + } + } + + Ok(()) } fn parse_raw_amount( @@ -519,6 +620,27 @@ fn token_deserialization_failure_reject_reason( }) } +fn token_operation_not_permitted_reject_reason( + operation_index: usize, + token_configuration: &TokenConfiguration, + address: Option, + reason: &'static str, +) -> TransactionRejectReason { + let (reason_type, details) = + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: operation_index as u64, + address: address.map(Into::into), + reason: reason.to_string().into(), + }) + .encode_reject_reason(); + + TransactionRejectReason::TokenUpdateTransactionFailed(EncodedTokenModuleRejectReason { + token_id: token_configuration.token_id.clone(), + reason_type: reason_type.to_type_discriminator(), + details: Some(details), + }) +} + fn token_balance_insufficient_reject_reason( operation_index: usize, token_configuration: &TokenConfiguration, diff --git a/plt/plt-scheduler/tests/lock_cancel.rs b/plt/plt-scheduler/tests/lock_cancel.rs index f1f32e2a63..40432bdb40 100644 --- a/plt/plt-scheduler/tests/lock_cancel.rs +++ b/plt/plt-scheduler/tests/lock_cancel.rs @@ -4,12 +4,13 @@ use crate::utils::entity_traits::scheduler::SchedulerOperations; use crate::utils::{BlockStateLatest, TokenInitTestParams}; use assert_matches::assert_matches; use concordium_base::protocol_level_tokens::CborMemo; +use concordium_base::protocol_level_tokens::meta_operations::lock_fund; use concordium_base::{ base::Energy, common::cbor, protocol_level_locks::{LockControllerSimpleV0Capability, LockId}, protocol_level_tokens::{ - RawCbor, TokenId, + CborHolderAccount, RawCbor, TokenId, TokenListUpdateDetails, TokenOperation, meta_operations::{MetaUpdatePayload, lock_cancel}, }, transactions::Payload, @@ -370,3 +371,123 @@ fn test_cancel_nonexistent() { assert_eq!(rejected_lock_id, lock_id); }); } + +/// Test that cancelling a lock is not blocked by token pause, allow-list, or deny-list restrictions. +#[test] +fn test_cancel_ignores_token_pause_allow_list_and_deny_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let canceller = context.external.create_account(); + + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _token_index) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default() + .mintable() + .burnable() + .allow_list() + .deny_list(), + 2, + Some(RawTokenAmount(10000)), + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let gov_addr = context + .external + .account_canonical_address(gov_account.account_index()); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![ + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(gov_addr), + }), + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + }), + ], + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(500), + ); + + let lock_id = LockId { + account_index: owner.account_index().into(), + sequence_number: 2, + creation_order: 0, + }; + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![owner.account_index()], + grants: vec![ + LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }, + LockControllerSimpleV0Grant { + account: canceller.account_index(), + roles: vec![LockControllerSimpleV0Capability::Cancel], + }, + ], + tokens: vec![token_id.clone()], + expiry: 1000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let fund_events = utils::execute_meta_operations( + &mut context, + &mut block_state, + owner.account_index(), + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + concordium_base::protocol_level_tokens::TokenAmount::from_raw(500, 2), + None, + )], + ); + assert_eq!(fund_events.len(), 1); + + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![ + TokenOperation::RemoveAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + }), + TokenOperation::AddDenyList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + }), + ], + ); + utils::pause_token( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + ); + + let events = utils::execute_meta_operations( + &mut context, + &mut block_state, + canceller.account_index(), + vec![lock_cancel(lock_id.clone(), None)], + ); + assert_eq!(events.len(), 2); + assert_matches!(&events[1], BlockItemEvent::LockDestroyed(LockDestroyEvent{lock_id: event_lock_id}) => { + assert_eq!(event_lock_id, &lock_id); + }); +} diff --git a/plt/plt-scheduler/tests/lock_fund.rs b/plt/plt-scheduler/tests/lock_fund.rs new file mode 100644 index 0000000000..0d385a24cd --- /dev/null +++ b/plt/plt-scheduler/tests/lock_fund.rs @@ -0,0 +1,523 @@ +//! Tests for funding protocol-level token locks. + +use crate::utils::entity_traits::scheduler::SchedulerOperations; +use crate::utils::{BlockStateLatest, TokenInitTestParams}; +use assert_matches::assert_matches; +use concordium_base::base::Energy; +use concordium_base::common::cbor; +use concordium_base::protocol_level_locks::LockInfo; +use concordium_base::protocol_level_locks::{LockControllerSimpleV0Capability, LockId}; +use concordium_base::protocol_level_tokens::meta_operations::{ + MetaUpdateOperations, MetaUpdatePayload, lock_fund, +}; +use concordium_base::protocol_level_tokens::{ + CborHolderAccount, OperationNotPermittedRejectReason, RawCbor, TokenAmount, TokenId, + TokenListUpdateDetails, TokenModuleAccountState, TokenModuleRejectReason, TokenOperation, +}; +use concordium_base::transactions::Payload; +use plt_block_state::{ + entity::entity_test_stub, persistent::protocol_level_locks::p11::LockControllerSimpleV0Grant, +}; +use plt_scheduler_types::types::events::{BlockItemEvent, TokenTransferEvent}; +use plt_scheduler_types::types::execution::TransactionOutcome; +use plt_scheduler_types::types::reject_reasons::TransactionRejectReason; +use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenHolder}; + +mod utils; + +macro_rules! execute_meta_update { + ($context:expr, $block_state:expr, $sender:expr, $timestamp:expr, $operations:expr $(,)?) => {{ + let sender_addr = $context.external.account_canonical_address($sender); + let payload = Payload::MetaUpdate { + payload: MetaUpdatePayload { + operations: RawCbor::from(cbor::cbor_encode(&MetaUpdateOperations { + operations: $operations, + })), + }, + }; + + $block_state + .execute_transaction( + $context, + plt_scheduler::TransactionContext { + energy_limit: Energy::from(u64::MAX), + sender_account_address: sender_addr, + transaction_sequence_number: 1.into(), + block_timestamp: $timestamp.into(), + }, + $sender, + payload, + ) + .expect("meta-update transaction must execute") + .outcome + }}; +} + +macro_rules! token_account_info { + ($context:expr, $block_state:expr, $account:expr, $token_id:expr $(,)?) => {{ + $block_state + .query_token_account_infos($context, $account) + .expect("token account query must succeed") + .into_iter() + .find(|info| &info.token_id == $token_id) + .expect("token account info must exist") + }}; +} + +macro_rules! token_module_account_state { + ($info:expr $(,)?) => {{ + cbor::cbor_decode::( + $info + .account_state + .module_state + .as_ref() + .expect("token account state must contain token-module state"), + ) + .expect("token-module account state must decode") + }}; +} + +#[test] +fn test_lock_fund_updates_account_and_lock_state() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 1); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + assert_matches!(&events[0], BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: event_token_id, + from, + to, + amount, + from_lock, + to_lock, + .. + }) => { + assert_eq!(event_token_id, &token_id); + assert_eq!(from, &TokenHolder::Account(sender_addr)); + assert_eq!(to, &TokenHolder::Account(sender_addr)); + assert_eq!(amount.amount, RawTokenAmount(250)); + assert_eq!(amount.decimals, 4); + assert_eq!(from_lock, &None); + assert_eq!(to_lock, &Some(lock_id.clone())); + }); + + let sender_info = + token_account_info!(&context, &block_state, sender.account_index(), &token_id); + assert_eq!( + sender_info.account_state.balance.amount, + RawTokenAmount(1000) + ); + let sender_state = token_module_account_state!(&sender_info); + assert_eq!(sender_state.available.unwrap().value(), 750); + assert_eq!(sender_state.locks.len(), 1); + assert_eq!(sender_state.locks[0].lock, lock_id); + assert_eq!(sender_state.locks[0].amount.value(), 250); + + let lock_info: LockInfo = cbor::cbor_decode( + block_state + .query_lock_info(&context, &lock_id) + .expect("lock info query must succeed"), + ) + .expect("lock info must decode"); + assert_eq!(lock_info.funds.len(), 1); + assert_eq!(lock_info.funds[0].amounts.len(), 1); + assert_eq!(lock_info.funds[0].amounts[0].token, token_id); + assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 250); +} +#[test] +fn test_lock_fund_sender_not_in_allow_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable().allow_list(), + 4, + None, + ); + let gov_addr = context + .external + .account_canonical_address(gov_account.account_index()); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![ + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(gov_addr), + }), + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + }), + ], + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![TokenOperation::RemoveAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + })], + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id, + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(sender_addr)); + assert_eq!(reason, "sender not in allow list"); + } + ); +} +#[test] +fn test_lock_fund_sender_in_deny_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable().deny_list(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![TokenOperation::AddDenyList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + })], + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id, + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(sender_addr)); + assert_eq!(reason, "sender in deny list"); + } + ); +} +#[test] +fn test_lock_fund_rejects_when_token_paused() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + utils::pause_token( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + ); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id, + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: None, + reason: Some(reason), + }) if reason == "token operation transfer is paused" + ); +} + +#[test] +fn test_lock_fund_rejects_unauthorized_sender() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let other = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let other_addr = context + .external + .account_canonical_address(other.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + other.account_index(), + 0, + vec![lock_fund( + token_id, + lock_id.clone(), + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockFundNotAuthorized(lock_id, other_addr)); + }); +} + +#[test] +fn test_lock_fund_rejects_after_expiry() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 10, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 20_000, + vec![lock_fund( + token_id, + lock_id.clone(), + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id)); + }); +} diff --git a/plt/plt-scheduler/tests/lock_transfer.rs b/plt/plt-scheduler/tests/lock_return.rs similarity index 66% rename from plt/plt-scheduler/tests/lock_transfer.rs rename to plt/plt-scheduler/tests/lock_return.rs index 19c4cbcb73..98bf24c2e9 100644 --- a/plt/plt-scheduler/tests/lock_transfer.rs +++ b/plt/plt-scheduler/tests/lock_return.rs @@ -1,4 +1,4 @@ -//! Tests for funding, sending, and returning lock-controlled funds. +//! Tests for returning funds from protocol-level token locks. use crate::utils::entity_traits::scheduler::SchedulerOperations; use crate::utils::{BlockStateLatest, TokenInitTestParams}; @@ -8,10 +8,11 @@ use concordium_base::common::cbor; use concordium_base::protocol_level_locks::LockInfo; use concordium_base::protocol_level_locks::{LockControllerSimpleV0Capability, LockId}; use concordium_base::protocol_level_tokens::meta_operations::{ - MetaUpdateOperations, MetaUpdatePayload, lock_fund, lock_return, lock_send, + MetaUpdateOperations, MetaUpdatePayload, lock_fund, lock_return, }; use concordium_base::protocol_level_tokens::{ - RawCbor, TokenAmount, TokenId, TokenModuleAccountState, + CborHolderAccount, OperationNotPermittedRejectReason, RawCbor, TokenAmount, TokenId, + TokenListUpdateDetails, TokenModuleAccountState, TokenModuleRejectReason, TokenOperation, }; use concordium_base::transactions::Payload; use plt_block_state::{ @@ -78,7 +79,7 @@ macro_rules! token_module_account_state { } #[test] -fn test_lock_fund_updates_account_and_lock_state() { +fn test_lock_return_deletes_empty_lock_when_keep_alive_is_false() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); @@ -106,15 +107,17 @@ fn test_lock_fund_updates_account_and_lock_state() { recipients: vec![recipient.account_index()], grants: vec![LockControllerSimpleV0Grant { account: sender.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Return, + ], }], tokens: vec![token_id.clone()], expiry: 1_804_806_000, keep_alive: false, }; utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - - let outcome = execute_meta_update!( + execute_meta_update!( &mut context, &mut block_state, sender.account_index(), @@ -126,12 +129,26 @@ fn test_lock_fund_updates_account_and_lock_state() { None, )], ); - let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); - assert_eq!(events.len(), 1); let sender_addr = context .external .account_canonical_address(sender.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_return( + token_id.clone(), + lock_id.clone(), + sender_addr, + TokenAmount::from_raw(250, 4), + None, + )], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 2); assert_matches!(&events[0], BlockItemEvent::TokenTransfer(TokenTransferEvent { token_id: event_token_id, from, @@ -146,8 +163,11 @@ fn test_lock_fund_updates_account_and_lock_state() { assert_eq!(to, &TokenHolder::Account(sender_addr)); assert_eq!(amount.amount, RawTokenAmount(250)); assert_eq!(amount.decimals, 4); - assert_eq!(from_lock, &None); - assert_eq!(to_lock, &Some(lock_id.clone())); + assert_eq!(from_lock, &Some(lock_id.clone())); + assert_eq!(to_lock, &None); + }); + assert_matches!(&events[1], BlockItemEvent::LockDestroyed(LockDestroyEvent { lock_id: event_lock_id }) => { + assert_eq!(event_lock_id, &lock_id); }); let sender_info = @@ -157,25 +177,16 @@ fn test_lock_fund_updates_account_and_lock_state() { RawTokenAmount(1000) ); let sender_state = token_module_account_state!(&sender_info); - assert_eq!(sender_state.available.unwrap().value(), 750); - assert_eq!(sender_state.locks.len(), 1); - assert_eq!(sender_state.locks[0].lock, lock_id); - assert_eq!(sender_state.locks[0].amount.value(), 250); + assert!(sender_state.available.is_none()); + assert!(sender_state.locks.is_empty()); - let lock_info: LockInfo = cbor::cbor_decode( - block_state - .query_lock_info(&context, &lock_id) - .expect("lock info query must succeed"), - ) - .expect("lock info must decode"); - assert_eq!(lock_info.funds.len(), 1); - assert_eq!(lock_info.funds[0].amounts.len(), 1); - assert_eq!(lock_info.funds[0].amounts[0].token, token_id); - assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 250); + assert_matches!( + block_state.query_lock_info(&context, &lock_id), + Err(QueryLockError::LockDoesNotExist) + ); } - #[test] -fn test_lock_send_moves_locked_funds_to_recipient() { +fn test_lock_return_keeps_empty_lock_when_keep_alive_is_true() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); @@ -205,84 +216,47 @@ fn test_lock_send_moves_locked_funds_to_recipient() { account: sender.account_index(), roles: vec![ LockControllerSimpleV0Capability::Fund, - LockControllerSimpleV0Capability::Send, + LockControllerSimpleV0Capability::Return, ], }], tokens: vec![token_id.clone()], expiry: 1_804_806_000, - keep_alive: false, + keep_alive: true, }; utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); let sender_addr = context .external .account_canonical_address(sender.account_index()); - let recipient_addr = context - .external - .account_canonical_address(recipient.account_index()); let outcome = execute_meta_update!( &mut context, &mut block_state, sender.account_index(), 0, - vec![ - lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - ), - lock_send( - token_id.clone(), - lock_id.clone(), - sender_addr, - recipient_addr, - TokenAmount::from_raw(100, 4), - None, - ) - ], + vec![lock_return( + token_id.clone(), + lock_id.clone(), + sender_addr, + TokenAmount::from_raw(250, 4), + None, + )], ); let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); - assert_eq!(events.len(), 2); - assert_matches!(&events[1], BlockItemEvent::TokenTransfer(TokenTransferEvent { - token_id: event_token_id, - from, - to, - amount, - from_lock, - to_lock, - .. - }) => { - assert_eq!(event_token_id, &token_id); - assert_eq!(from, &TokenHolder::Account(sender_addr)); - assert_eq!(to, &TokenHolder::Account(recipient_addr)); - assert_eq!(amount.amount, RawTokenAmount(100)); - assert_eq!(amount.decimals, 4); - assert_eq!(from_lock, &Some(lock_id.clone())); - assert_eq!(to_lock, &None); - }); - - let sender_info = - token_account_info!(&context, &block_state, sender.account_index(), &token_id); - assert_eq!( - sender_info.account_state.balance.amount, - RawTokenAmount(900) - ); - let sender_state = token_module_account_state!(&sender_info); - assert_eq!(sender_state.available.unwrap().value(), 750); - assert_eq!(sender_state.locks.len(), 1); - assert_eq!(sender_state.locks[0].amount.value(), 150); - - let recipient_info = - token_account_info!(&context, &block_state, recipient.account_index(), &token_id); - assert_eq!( - recipient_info.account_state.balance.amount, - RawTokenAmount(100) - ); - let recipient_state = token_module_account_state!(&recipient_info); - assert!(recipient_state.available.is_none()); - assert!(recipient_state.locks.is_empty()); + assert_eq!(events.len(), 1); + assert_matches!(&events[0], BlockItemEvent::TokenTransfer(..)); let lock_info: LockInfo = cbor::cbor_decode( block_state @@ -290,210 +264,233 @@ fn test_lock_send_moves_locked_funds_to_recipient() { .expect("lock info query must succeed"), ) .expect("lock info must decode"); - assert_eq!(lock_info.funds.len(), 1); - assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 150); + assert!(lock_info.funds.is_empty()); } - #[test] -fn test_lock_return_deletes_empty_lock_when_keep_alive_is_false() { +fn test_lock_return_source_not_in_allow_list() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); - let sender = context.external.create_account(); + let owner = context.external.create_account(); + let returner = context.external.create_account(); let recipient = context.external.create_account(); let token_id: TokenId = "pltX".parse().unwrap(); - utils::create_and_init_token_p11( + let (gov_account, _) = utils::create_and_init_token_p11( &mut context, &mut block_state, token_id.clone(), - TokenInitTestParams::default().mintable(), + TokenInitTestParams::default().mintable().allow_list(), 4, None, ); + let gov_addr = context + .external + .account_canonical_address(gov_account.account_index()); + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![ + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(gov_addr), + }), + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + }), + ], + ); utils::increment_account_balance_p11( &mut context, &mut block_state, - sender.account_index(), + owner.account_index(), &token_id, RawTokenAmount(1000), ); - let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_id = LockId::new(owner.account_index(), 7u64, 0); let lock_config = utils::CreateLockSimpleConfig { recipients: vec![recipient.account_index()], - grants: vec![LockControllerSimpleV0Grant { - account: sender.account_index(), - roles: vec![ - LockControllerSimpleV0Capability::Fund, - LockControllerSimpleV0Capability::Return, - ], - }], + grants: vec![ + LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }, + LockControllerSimpleV0Grant { + account: returner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Return], + }, + ], tokens: vec![token_id.clone()], expiry: 1_804_806_000, keep_alive: false, }; utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - execute_meta_update!( + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::execute_token_operations( &mut context, &mut block_state, - sender.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - )], + &token_id, + gov_account.account_index(), + vec![TokenOperation::RemoveAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + })], ); - let sender_addr = context - .external - .account_canonical_address(sender.account_index()); let outcome = execute_meta_update!( &mut context, &mut block_state, - sender.account_index(), + returner.account_index(), 0, vec![lock_return( token_id.clone(), - lock_id.clone(), - sender_addr, - TokenAmount::from_raw(250, 4), + lock_id, + owner_addr, + TokenAmount::from_raw(100, 4), None, )], ); - let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); - - assert_eq!(events.len(), 2); - assert_matches!(&events[0], BlockItemEvent::TokenTransfer(TokenTransferEvent { - token_id: event_token_id, - from, - to, - amount, - from_lock, - to_lock, - .. - }) => { - assert_eq!(event_token_id, &token_id); - assert_eq!(from, &TokenHolder::Account(sender_addr)); - assert_eq!(to, &TokenHolder::Account(sender_addr)); - assert_eq!(amount.amount, RawTokenAmount(250)); - assert_eq!(amount.decimals, 4); - assert_eq!(from_lock, &Some(lock_id.clone())); - assert_eq!(to_lock, &None); - }); - assert_matches!(&events[1], BlockItemEvent::LockDestroyed(LockDestroyEvent { lock_id: event_lock_id }) => { - assert_eq!(event_lock_id, &lock_id); - }); - - let sender_info = - token_account_info!(&context, &block_state, sender.account_index(), &token_id); - assert_eq!( - sender_info.account_state.balance.amount, - RawTokenAmount(1000) - ); - let sender_state = token_module_account_state!(&sender_info); - assert!(sender_state.available.is_none()); - assert!(sender_state.locks.is_empty()); + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); assert_matches!( - block_state.query_lock_info(&context, &lock_id), - Err(QueryLockError::LockDoesNotExist) + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(owner_addr)); + assert_eq!(reason, "sender not in allow list"); + } ); } - #[test] -fn test_lock_return_keeps_empty_lock_when_keep_alive_is_true() { +fn test_lock_return_source_in_deny_list() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); - let sender = context.external.create_account(); + let owner = context.external.create_account(); + let returner = context.external.create_account(); let recipient = context.external.create_account(); let token_id: TokenId = "pltX".parse().unwrap(); - utils::create_and_init_token_p11( + let (gov_account, _) = utils::create_and_init_token_p11( &mut context, &mut block_state, token_id.clone(), - TokenInitTestParams::default().mintable(), + TokenInitTestParams::default().mintable().deny_list(), 4, None, ); utils::increment_account_balance_p11( &mut context, &mut block_state, - sender.account_index(), + owner.account_index(), &token_id, RawTokenAmount(1000), ); - let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_id = LockId::new(owner.account_index(), 7u64, 0); let lock_config = utils::CreateLockSimpleConfig { recipients: vec![recipient.account_index()], - grants: vec![LockControllerSimpleV0Grant { - account: sender.account_index(), - roles: vec![ - LockControllerSimpleV0Capability::Fund, - LockControllerSimpleV0Capability::Return, - ], - }], + grants: vec![ + LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }, + LockControllerSimpleV0Grant { + account: returner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Return], + }, + ], tokens: vec![token_id.clone()], expiry: 1_804_806_000, - keep_alive: true, + keep_alive: false, }; utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - execute_meta_update!( + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::execute_token_operations( &mut context, &mut block_state, - sender.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - )], + &token_id, + gov_account.account_index(), + vec![TokenOperation::AddDenyList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + })], ); - let sender_addr = context - .external - .account_canonical_address(sender.account_index()); let outcome = execute_meta_update!( &mut context, &mut block_state, - sender.account_index(), + returner.account_index(), 0, vec![lock_return( token_id.clone(), - lock_id.clone(), - sender_addr, - TokenAmount::from_raw(250, 4), + lock_id, + owner_addr, + TokenAmount::from_raw(100, 4), None, )], ); - let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); - assert_eq!(events.len(), 1); - assert_matches!(&events[0], BlockItemEvent::TokenTransfer(..)); - - let lock_info: LockInfo = cbor::cbor_decode( - block_state - .query_lock_info(&context, &lock_id) - .expect("lock info query must succeed"), - ) - .expect("lock info must decode"); - assert!(lock_info.funds.is_empty()); + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(owner_addr)); + assert_eq!(reason, "sender in deny list"); + } + ); } - #[test] -fn test_lock_transfer_rejects_unauthorized_operations() { +fn test_lock_return_rejects_when_token_paused() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); let owner = context.external.create_account(); + let returner = context.external.create_account(); let recipient = context.external.create_account(); - let other = context.external.create_account(); let token_id: TokenId = "pltX".parse().unwrap(); - utils::create_and_init_token_p11( + let (gov_account, _) = utils::create_and_init_token_p11( &mut context, &mut block_state, token_id.clone(), @@ -512,98 +509,79 @@ fn test_lock_transfer_rejects_unauthorized_operations() { let lock_id = LockId::new(owner.account_index(), 7u64, 0); let lock_config = utils::CreateLockSimpleConfig { recipients: vec![recipient.account_index()], - grants: vec![LockControllerSimpleV0Grant { - account: owner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }], + grants: vec![ + LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }, + LockControllerSimpleV0Grant { + account: returner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Return], + }, + ], tokens: vec![token_id.clone()], expiry: 1_804_806_000, keep_alive: false, }; utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - - let other_addr = context - .external - .account_canonical_address(other.account_index()); - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - other.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(1, 4), - None, - )], - ); - assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { - assert_eq!(reason, TransactionRejectReason::LockFundNotAuthorized(lock_id.clone(), other_addr)); - }); - - execute_meta_update!( - &mut context, - &mut block_state, - owner.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - )], - ); - let owner_addr = context .external .account_canonical_address(owner.account_index()); - let recipient_addr = context - .external - .account_canonical_address(recipient.account_index()); - let outcome = execute_meta_update!( + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::pause_token( &mut context, &mut block_state, - owner.account_index(), - 0, - vec![lock_send( - token_id.clone(), - lock_id.clone(), - owner_addr, - recipient_addr, - TokenAmount::from_raw(1, 4), - None, - )], + &token_id, + gov_account.account_index(), ); - assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { - assert_eq!(reason, TransactionRejectReason::LockSendNotAuthorized(lock_id.clone(), owner_addr)); - }); let outcome = execute_meta_update!( &mut context, &mut block_state, - owner.account_index(), + returner.account_index(), 0, vec![lock_return( token_id.clone(), - lock_id.clone(), + lock_id, owner_addr, - TokenAmount::from_raw(1, 4), + TokenAmount::from_raw(100, 4), None, )], ); - assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { - assert_eq!(reason, TransactionRejectReason::LockReturnNotAuthorized(lock_id, owner_addr)); - }); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: None, + reason: Some(reason), + }) if reason == "token operation transfer is paused" + ); } #[test] -fn test_lock_send_rejects_non_recipient() { +fn test_lock_return_rejects_unauthorized_sender() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); let owner = context.external.create_account(); let recipient = context.external.create_account(); - let non_recipient = context.external.create_account(); let token_id: TokenId = "pltX".parse().unwrap(); utils::create_and_init_token_p11( &mut context, @@ -626,10 +604,7 @@ fn test_lock_send_rejects_non_recipient() { recipients: vec![recipient.account_index()], grants: vec![LockControllerSimpleV0Grant { account: owner.account_index(), - roles: vec![ - LockControllerSimpleV0Capability::Fund, - LockControllerSimpleV0Capability::Send, - ], + roles: vec![LockControllerSimpleV0Capability::Fund], }], tokens: vec![token_id.clone()], expiry: 1_804_806_000, @@ -652,31 +627,26 @@ fn test_lock_send_rejects_non_recipient() { let owner_addr = context .external .account_canonical_address(owner.account_index()); - let non_recipient_addr = context - .external - .account_canonical_address(non_recipient.account_index()); let outcome = execute_meta_update!( &mut context, &mut block_state, owner.account_index(), 0, - vec![lock_send( - token_id.clone(), + vec![lock_return( + token_id, lock_id.clone(), owner_addr, - non_recipient_addr, TokenAmount::from_raw(1, 4), None, )], ); - assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { - assert_eq!(reason, TransactionRejectReason::LockRecipientNotPermitted(lock_id, non_recipient_addr)); + assert_eq!(reason, TransactionRejectReason::LockReturnNotAuthorized(lock_id, owner_addr)); }); } #[test] -fn test_lock_operations_reject_after_expiry() { +fn test_lock_return_rejects_after_expiry() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); @@ -706,7 +676,6 @@ fn test_lock_operations_reject_after_expiry() { account: owner.account_index(), roles: vec![ LockControllerSimpleV0Capability::Fund, - LockControllerSimpleV0Capability::Send, LockControllerSimpleV0Capability::Return, ], }], @@ -715,23 +684,6 @@ fn test_lock_operations_reject_after_expiry() { keep_alive: false, }; utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - owner.account_index(), - 20_000, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(1, 4), - None, - )], - ); - assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { - assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id.clone())); - }); - execute_meta_update!( &mut context, &mut block_state, @@ -748,27 +700,6 @@ fn test_lock_operations_reject_after_expiry() { let owner_addr = context .external .account_canonical_address(owner.account_index()); - let recipient_addr = context - .external - .account_canonical_address(recipient.account_index()); - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - owner.account_index(), - 20_000, - vec![lock_send( - token_id.clone(), - lock_id.clone(), - owner_addr, - recipient_addr, - TokenAmount::from_raw(1, 4), - None, - )], - ); - assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { - assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id.clone())); - }); - let outcome = execute_meta_update!( &mut context, &mut block_state, diff --git a/plt/plt-scheduler/tests/lock_send.rs b/plt/plt-scheduler/tests/lock_send.rs new file mode 100644 index 0000000000..4e6f57eb83 --- /dev/null +++ b/plt/plt-scheduler/tests/lock_send.rs @@ -0,0 +1,944 @@ +//! Tests for sending funds from protocol-level token locks. + +use crate::utils::entity_traits::scheduler::SchedulerOperations; +use crate::utils::{BlockStateLatest, TokenInitTestParams}; +use assert_matches::assert_matches; +use concordium_base::base::Energy; +use concordium_base::common::cbor; +use concordium_base::protocol_level_locks::LockInfo; +use concordium_base::protocol_level_locks::{LockControllerSimpleV0Capability, LockId}; +use concordium_base::protocol_level_tokens::meta_operations::{ + MetaUpdateOperations, MetaUpdatePayload, lock_fund, lock_send, +}; +use concordium_base::protocol_level_tokens::{ + CborHolderAccount, OperationNotPermittedRejectReason, RawCbor, TokenAmount, TokenId, + TokenListUpdateDetails, TokenModuleAccountState, TokenModuleRejectReason, TokenOperation, +}; +use concordium_base::transactions::Payload; +use plt_block_state::{ + entity::entity_test_stub, persistent::protocol_level_locks::p11::LockControllerSimpleV0Grant, +}; +use plt_scheduler_types::types::events::{BlockItemEvent, TokenTransferEvent}; +use plt_scheduler_types::types::execution::TransactionOutcome; +use plt_scheduler_types::types::reject_reasons::TransactionRejectReason; +use plt_scheduler_types::types::tokens::{RawTokenAmount, TokenHolder}; + +mod utils; + +macro_rules! execute_meta_update { + ($context:expr, $block_state:expr, $sender:expr, $timestamp:expr, $operations:expr $(,)?) => {{ + let sender_addr = $context.external.account_canonical_address($sender); + let payload = Payload::MetaUpdate { + payload: MetaUpdatePayload { + operations: RawCbor::from(cbor::cbor_encode(&MetaUpdateOperations { + operations: $operations, + })), + }, + }; + + $block_state + .execute_transaction( + $context, + plt_scheduler::TransactionContext { + energy_limit: Energy::from(u64::MAX), + sender_account_address: sender_addr, + transaction_sequence_number: 1.into(), + block_timestamp: $timestamp.into(), + }, + $sender, + payload, + ) + .expect("meta-update transaction must execute") + .outcome + }}; +} + +macro_rules! token_account_info { + ($context:expr, $block_state:expr, $account:expr, $token_id:expr $(,)?) => {{ + $block_state + .query_token_account_infos($context, $account) + .expect("token account query must succeed") + .into_iter() + .find(|info| &info.token_id == $token_id) + .expect("token account info must exist") + }}; +} + +macro_rules! token_module_account_state { + ($info:expr $(,)?) => {{ + cbor::cbor_decode::( + $info + .account_state + .module_state + .as_ref() + .expect("token account state must contain token-module state"), + ) + .expect("token-module account state must decode") + }}; +} + +#[test] +fn test_lock_send_moves_locked_funds_to_recipient() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![ + lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + ), + lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + ) + ], + ); + let events = assert_matches!(outcome, TransactionOutcome::Success(events) => events); + + assert_eq!(events.len(), 2); + assert_matches!(&events[1], BlockItemEvent::TokenTransfer(TokenTransferEvent { + token_id: event_token_id, + from, + to, + amount, + from_lock, + to_lock, + .. + }) => { + assert_eq!(event_token_id, &token_id); + assert_eq!(from, &TokenHolder::Account(sender_addr)); + assert_eq!(to, &TokenHolder::Account(recipient_addr)); + assert_eq!(amount.amount, RawTokenAmount(100)); + assert_eq!(amount.decimals, 4); + assert_eq!(from_lock, &Some(lock_id.clone())); + assert_eq!(to_lock, &None); + }); + + let sender_info = + token_account_info!(&context, &block_state, sender.account_index(), &token_id); + assert_eq!( + sender_info.account_state.balance.amount, + RawTokenAmount(900) + ); + let sender_state = token_module_account_state!(&sender_info); + assert_eq!(sender_state.available.unwrap().value(), 750); + assert_eq!(sender_state.locks.len(), 1); + assert_eq!(sender_state.locks[0].amount.value(), 150); + + let recipient_info = + token_account_info!(&context, &block_state, recipient.account_index(), &token_id); + assert_eq!( + recipient_info.account_state.balance.amount, + RawTokenAmount(100) + ); + let recipient_state = token_module_account_state!(&recipient_info); + assert!(recipient_state.available.is_none()); + assert!(recipient_state.locks.is_empty()); + + let lock_info: LockInfo = cbor::cbor_decode( + block_state + .query_lock_info(&context, &lock_id) + .expect("lock info query must succeed"), + ) + .expect("lock info must decode"); + assert_eq!(lock_info.funds.len(), 1); + assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 150); +} +#[test] +fn test_lock_send_rejects_non_recipient() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let non_recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let non_recipient_addr = context + .external + .account_canonical_address(non_recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + owner_addr, + non_recipient_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockRecipientNotPermitted(lock_id, non_recipient_addr)); + }); +} +#[test] +fn test_lock_send_sender_not_in_allow_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable().allow_list(), + 4, + None, + ); + let gov_addr = context + .external + .account_canonical_address(gov_account.account_index()); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![ + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(gov_addr), + }), + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + }), + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(recipient_addr), + }), + ], + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![TokenOperation::RemoveAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + })], + ); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(sender_addr)); + assert_eq!(reason, "sender not in allow list"); + } + ); +} +#[test] +fn test_lock_send_recipient_not_in_allow_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable().allow_list(), + 4, + None, + ); + let gov_addr = context + .external + .account_canonical_address(gov_account.account_index()); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![ + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(gov_addr), + }), + TokenOperation::AddAllowList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + }), + ], + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(recipient_addr)); + assert_eq!(reason, "recipient not in allow list"); + } + ); +} +#[test] +fn test_lock_send_sender_in_deny_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable().deny_list(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![TokenOperation::AddDenyList(TokenListUpdateDetails { + target: CborHolderAccount::from(sender_addr), + })], + ); + + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(sender_addr)); + assert_eq!(reason, "sender in deny list"); + } + ); +} +#[test] +fn test_lock_send_recipient_in_deny_list() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable().deny_list(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::execute_token_operations( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + vec![TokenOperation::AddDenyList(TokenListUpdateDetails { + target: CborHolderAccount::from(recipient_addr), + })], + ); + + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: Some(address), + reason: Some(reason), + }) => { + assert_eq!(address, CborHolderAccount::from(recipient_addr)); + assert_eq!(reason, "recipient in deny list"); + } + ); +} +#[test] +fn test_lock_send_rejects_when_token_paused() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let sender = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + let (gov_account, _) = utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + sender.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(sender.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: sender.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + let sender_addr = context + .external + .account_canonical_address(sender.account_index()); + assert_matches!( + execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ), + TransactionOutcome::Success(_) + ); + utils::pause_token( + &mut context, + &mut block_state, + &token_id, + gov_account.account_index(), + ); + + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + sender.account_index(), + 0, + vec![lock_send( + token_id.clone(), + lock_id.clone(), + sender_addr, + recipient_addr, + TokenAmount::from_raw(100, 4), + None, + )], + ); + + let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); + let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); + assert_matches!( + reject_reason, + TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { + index: 0, + address: None, + reason: Some(reason), + }) if reason == "token operation transfer is paused" + ); +} + +#[test] +fn test_lock_send_rejects_unauthorized_sender() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![LockControllerSimpleV0Capability::Fund], + }], + tokens: vec![token_id.clone()], + expiry: 1_804_806_000, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_send( + token_id, + lock_id.clone(), + owner_addr, + recipient_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockSendNotAuthorized(lock_id, owner_addr)); + }); +} + +#[test] +fn test_lock_send_rejects_after_expiry() { + let mut context = entity_test_stub::new_stubbed_context(); + let mut block_state = BlockStateLatest::default(); + + let owner = context.external.create_account(); + let recipient = context.external.create_account(); + let token_id: TokenId = "pltX".parse().unwrap(); + utils::create_and_init_token_p11( + &mut context, + &mut block_state, + token_id.clone(), + TokenInitTestParams::default().mintable(), + 4, + None, + ); + utils::increment_account_balance_p11( + &mut context, + &mut block_state, + owner.account_index(), + &token_id, + RawTokenAmount(1000), + ); + + let lock_id = LockId::new(owner.account_index(), 7u64, 0); + let lock_config = utils::CreateLockSimpleConfig { + recipients: vec![recipient.account_index()], + grants: vec![LockControllerSimpleV0Grant { + account: owner.account_index(), + roles: vec![ + LockControllerSimpleV0Capability::Fund, + LockControllerSimpleV0Capability::Send, + ], + }], + tokens: vec![token_id.clone()], + expiry: 10, + keep_alive: false, + }; + utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); + execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + vec![lock_fund( + token_id.clone(), + lock_id.clone(), + TokenAmount::from_raw(250, 4), + None, + )], + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + let recipient_addr = context + .external + .account_canonical_address(recipient.account_index()); + let outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 20_000, + vec![lock_send( + token_id, + lock_id.clone(), + owner_addr, + recipient_addr, + TokenAmount::from_raw(1, 4), + None, + )], + ); + assert_matches!(outcome, TransactionOutcome::Rejected(reason) => { + assert_eq!(reason, TransactionRejectReason::LockExpired(lock_id)); + }); +} diff --git a/plt/plt-scheduler/tests/utils/lock.rs b/plt/plt-scheduler/tests/utils/lock.rs index b74b8af04b..33f9a36bd8 100644 --- a/plt/plt-scheduler/tests/utils/lock.rs +++ b/plt/plt-scheduler/tests/utils/lock.rs @@ -1,8 +1,12 @@ use crate::utils::entity_traits::scheduler::SchedulerOperations; +use assert_matches::assert_matches; use concordium_base::base::{AccountIndex, Energy}; use concordium_base::common::cbor; use concordium_base::common::types::TransactionTime; use concordium_base::protocol_level_locks::LockId; +use concordium_base::protocol_level_tokens::meta_operations::{ + MetaUpdateOperation, MetaUpdateOperations, MetaUpdatePayload, +}; use concordium_base::protocol_level_tokens::{CborHolderAccount, RawCbor, TokenId}; use concordium_base::transactions::Payload; use plt_block_state::entity::EntityContext; diff --git a/plt/plt-scheduler/tests/utils/token.rs b/plt/plt-scheduler/tests/utils/token.rs index e4d7e7bc15..ad7c79b692 100644 --- a/plt/plt-scheduler/tests/utils/token.rs +++ b/plt/plt-scheduler/tests/utils/token.rs @@ -4,6 +4,9 @@ use super::entity_traits::scheduler::SchedulerOperations; use assert_matches::assert_matches; use concordium_base::base::{AccountIndex, Energy}; use concordium_base::common::cbor; +use concordium_base::protocol_level_tokens::meta_operations::{ + MetaUpdateOperation, MetaUpdateOperations, MetaUpdatePayload, +}; use concordium_base::protocol_level_tokens::{ CborHolderAccount, MetadataUrl, RawCbor, TokenAmount, TokenId, TokenModuleInitializationParameters, TokenModuleRejectReason, TokenModuleRejectReasonType, @@ -292,6 +295,31 @@ pub fn execute_token_operations( assert_matches!(result.outcome, TransactionOutcome::Success(events) => events) } +/// Execute meta-update operations as the given sender account. Returns the block item events on +/// success, panics if the transaction fails. +pub fn execute_meta_operations( + context: &mut EntityContext, + block_state: &mut impl SchedulerOperations, + sender: AccountIndex, + operations: Vec, +) -> Vec { + let payload = Payload::MetaUpdate { + payload: MetaUpdatePayload { + operations: RawCbor::from(cbor::cbor_encode(&MetaUpdateOperations { operations })), + }, + }; + let sender_addr = context.external.account_canonical_address(sender); + let result = block_state + .execute_transaction( + context, + crate::utils::simple_transaction_context(sender_addr), + sender, + payload, + ) + .expect("transaction internal error"); + assert_matches!(result.outcome, plt_scheduler_types::types::execution::TransactionOutcome::Success(events) => events) +} + fn decode_token_module_reject_reason( reject_reason: &EncodedTokenModuleRejectReason, ) -> TokenModuleRejectReason { From ded6f7ff79da356cb670d515e3554e4ff3b44773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Wed, 3 Jun 2026 15:36:42 +0200 Subject: [PATCH 10/15] Address PR feedback --- .../balance_operations.rs | 2 + .../src/scheduler/plt_scheduler.rs | 41 +++++++++---------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs b/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs index 337ecc0472..65b676350f 100644 --- a/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs +++ b/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs @@ -228,6 +228,7 @@ pub fn transfer( /// # Errors /// /// - [`InsufficientBalanceError`] The account has insufficient available balance. +#[allow(clippy::too_many_arguments)] pub fn lock_amount( context: &mut EntityContext, events: &mut impl Extend, @@ -374,6 +375,7 @@ pub fn send_locked_amount( /// # Errors /// /// - [`InsufficientBalanceError`] The account has insufficient locked balance. +#[allow(clippy::too_many_arguments)] pub fn return_locked_amount( context: &mut EntityContext, events: &mut impl Extend, diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index ce314d0344..ee028270c4 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -1,4 +1,5 @@ -//! Scheduler implementation for protocol-level lock operations. +//! Scheduler implementation for protocol-level token updates. This module implements execution +//! of transactions related to protocol-level tokens. use crate::locks::lock_controller::LockController; use crate::locks::{get_lock_config, lock_controller}; @@ -536,18 +537,17 @@ fn check_token_transfer_restrictions( "sender not in allow list", )); } - if let Some((recipient_account, recipient_addr)) = recipient { - if !token + if let Some((recipient_account, recipient_addr)) = recipient + && !token .token_p9_base .get_allow_list_for(context, recipient_account.account_index()) - { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - Some(recipient_addr), - "recipient not in allow list", - )); - } + { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + Some(recipient_addr), + "recipient not in allow list", + )); } } @@ -563,18 +563,17 @@ fn check_token_transfer_restrictions( "sender in deny list", )); } - if let Some((recipient_account, recipient_addr)) = recipient { - if token + if let Some((recipient_account, recipient_addr)) = recipient + && token .token_p9_base .get_deny_list_for(context, recipient_account.account_index()) - { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - Some(recipient_addr), - "recipient in deny list", - )); - } + { + return Err(token_operation_not_permitted_reject_reason( + operation_index, + token_configuration, + Some(recipient_addr), + "recipient in deny list", + )); } } From 6a8277cc6f3af0c95f42bd54388e55defdf0dc00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 4 Jun 2026 12:26:57 +0200 Subject: [PATCH 11/15] Add shared constraint checks for token transfer and lock send --- .../token_module/update.rs | 99 ++++-- .../src/scheduler/plt_scheduler.rs | 169 +++------- plt/plt-scheduler/tests/lock_cancel.rs | 33 +- plt/plt-scheduler/tests/lock_fund.rs | 239 +------------- plt/plt-scheduler/tests/lock_return.rs | 311 +----------------- 5 files changed, 125 insertions(+), 726 deletions(-) diff --git a/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs index 64120bb72e..86844d5bd2 100644 --- a/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs +++ b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs @@ -17,7 +17,7 @@ use concordium_base::protocol_level_tokens::{ UnsupportedOperationRejectReason, }; use concordium_base::transactions::Memo; -use plt_block_state::entity::accounts::Accounts; +use plt_block_state::entity::accounts::{Account, Accounts}; use plt_block_state::entity::protocol_level_tokens::p9::TokenP9Base; use plt_block_state::entity::protocol_level_tokens::p11::TokenP11; use plt_block_state::entity::{EntityContext, EntityContextTypes}; @@ -213,7 +213,7 @@ fn operation_name(operation: &TokenOperation) -> &'static str { /// Internal variant of `TokenUpdateError` where the reject reason is /// not encoded as CBOR #[derive(Debug, thiserror::Error)] -enum TokenUpdateErrorInternal { +pub(crate) enum TokenUpdateErrorInternal { #[error("The given account does not exist: {0}")] AccountDoesNotExist(#[from] AccountNotFoundByAddressError), #[error("The token amount has wrong number of decimals: {0}")] @@ -391,35 +391,37 @@ fn check_authorized( Ok(()) } -fn execute_token_transfer( - transaction_execution: &mut TransactionExecution, - context: &mut EntityContext, - events: &mut impl Extend, - token: &mut TokenP9Base, - transfer_operation: &TokenTransfer, -) -> Result<(), TokenUpdateErrorInternal> { - let token_configuration = token.token_configuration(context)?; +pub(crate) struct ValidatedAccounts { + pub sender: Account, + pub receiver: Account, +} - // preprocessing - let raw_amount = util::to_raw_token_amount(&token_configuration, transfer_operation.amount)?; +pub(crate) enum TransferConstraintError { + Paused, + SenderNotAllowed { reason: &'static str }, + RecipientNotAllowed { reason: &'static str }, + AccountNotFound(AccountNotFoundByAddressError), +} - // operation execution - check_not_paused(context, token)?; +pub(crate) fn check_transfer_constraints( + context: &EntityContext, + token: &TokenP9Base, + sender: Result, + receiver: Result, +) -> Result { + check_not_paused(context, token).map_err(|_| TransferConstraintError::Paused)?; - let sender = transaction_execution.sender_account(); - let sender_address = transaction_execution.sender_account_address(); - let receiver = context.account_by_address(&transfer_operation.recipient.address)?; + let sender = sender.map_err(TransferConstraintError::AccountNotFound)?; + let receiver = receiver.map_err(TransferConstraintError::AccountNotFound)?; if token.has_allow_list(context) { if !token.get_allow_list_for(context, sender.account_index()) { - return Err(TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(sender_address), + return Err(TransferConstraintError::SenderNotAllowed { reason: "sender not in allow list", }); } if !token.get_allow_list_for(context, receiver.account_index()) { - return Err(TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(transfer_operation.recipient.address), + return Err(TransferConstraintError::RecipientNotAllowed { reason: "recipient not in allow list", }); } @@ -427,27 +429,70 @@ fn execute_token_transfer( if token.has_deny_list(context) { if token.get_deny_list_for(context, sender.account_index()) { - return Err(TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(sender_address), + return Err(TransferConstraintError::SenderNotAllowed { reason: "sender in deny list", }); } if token.get_deny_list_for(context, receiver.account_index()) { - return Err(TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(transfer_operation.recipient.address), + return Err(TransferConstraintError::RecipientNotAllowed { reason: "recipient in deny list", }); } } + Ok(ValidatedAccounts { sender, receiver }) +} + +fn execute_token_transfer( + transaction_execution: &mut TransactionExecution, + context: &mut EntityContext, + events: &mut impl Extend, + token: &mut TokenP9Base, + transfer_operation: &TokenTransfer, +) -> Result<(), TokenUpdateErrorInternal> { + let token_configuration = token.token_configuration(context)?; + + // preprocessing + let raw_amount = util::to_raw_token_amount(&token_configuration, transfer_operation.amount)?; + + let sender = transaction_execution.sender_account(); + let sender_address = transaction_execution.sender_account_address(); + let receiver = context.account_by_address(&transfer_operation.recipient.address); + let receiver_address = transfer_operation.recipient.address; + + let ValidatedAccounts { sender, receiver } = + match check_transfer_constraints(context, token, Ok(sender.clone()), receiver) { + Ok(accs) => accs, + Err(err) => { + return Err(match err { + TransferConstraintError::Paused => TokenUpdateErrorInternal::Paused, + TransferConstraintError::AccountNotFound(inner) => { + TokenUpdateErrorInternal::AccountDoesNotExist(inner) + } + TransferConstraintError::SenderNotAllowed { reason } => { + TokenUpdateErrorInternal::OperationNotPermitted { + account_address: Some(sender_address), + reason, + } + } + TransferConstraintError::RecipientNotAllowed { reason } => { + TokenUpdateErrorInternal::OperationNotPermitted { + account_address: Some(receiver_address), + reason, + } + } + }); + } + }; + balance_operations::transfer( context, events, token, - sender, + &sender, sender_address, &receiver, - transfer_operation.recipient.address, + receiver_address, raw_amount, transfer_operation.memo.clone().map(Memo::from), )??; diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index ee028270c4..6eed7db47c 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -5,6 +5,9 @@ use crate::locks::lock_controller::LockController; use crate::locks::{get_lock_config, lock_controller}; use crate::protocol_level_tokens::balance_operations; use crate::protocol_level_tokens::token_module::errors::InsufficientBalanceError; +use crate::protocol_level_tokens::token_module::{ + TransferConstraintError, ValidatedAccounts, check_transfer_constraints, +}; use crate::scheduler::TransactionFailure; use crate::transaction_execution::TransactionExecution; use concordium_base::base::AccountIndex; @@ -21,10 +24,9 @@ use concordium_base::protocol_level_tokens::{ }; use concordium_base::transactions; use plt_block_state::block_state::ExecutionTimeBlockStateP11; -use plt_block_state::entity::accounts::{Account, Accounts}; +use plt_block_state::entity::accounts::Accounts; use plt_block_state::entity::block_state::TokenNotFoundByIdError; use plt_block_state::entity::block_state::p11::BlockStateP11; -use plt_block_state::entity::protocol_level_tokens::p11::TokenP11; use plt_block_state::entity::{EntityContext, EntityContextTypes}; use plt_block_state::failure::BlockStateFailure; use plt_block_state::persistent::protocol_level_locks::p11::{ @@ -124,19 +126,6 @@ where let token_configuration = token.token_p9_base.token_configuration(context)?; let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; - let sender = ( - transaction_execution.sender_account(), - transaction_execution.sender_account_address(), - ); - check_token_transfer_restrictions( - context, - &token, - operation_index, - &token_configuration, - sender, - None, - )?; - let memo = details.memo.map(transactions::Memo::from); let is_new_holder = match balance_operations::lock_amount( context, @@ -201,13 +190,49 @@ where } let source_address = details.source.address; - let source = context - .account_by_address(&source_address) - .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; + let source = context.account_by_address(&source_address); let recipient_address = details.recipient.address; - let recipient = context - .account_by_address(&recipient_address) - .map_err(|_| TransactionRejectReason::InvalidAccountReference(recipient_address))?; + let recipient = context.account_by_address(&recipient_address); + + let mut token = block_state.token_by_id(context, &details.token)?.map_err( + |TokenNotFoundByIdError(token_id)| TransactionRejectReason::NonExistentTokenId(token_id), + )?; + let token_configuration = token.token_p9_base.token_configuration(context)?; + + let ValidatedAccounts { + sender: source, + receiver: recipient, + } = check_transfer_constraints(context, &token.token_p9_base, source, recipient).map_err( + |err| match err { + TransferConstraintError::Paused => token_operation_not_permitted_reject_reason( + operation_index, + &token_configuration, + None, + "token operation transfer is paused", + ), + TransferConstraintError::SenderNotAllowed { reason } => { + token_operation_not_permitted_reject_reason( + operation_index, + &token_configuration, + Some(source_address), + reason, + ) + } + TransferConstraintError::RecipientNotAllowed { reason } => { + token_operation_not_permitted_reject_reason( + operation_index, + &token_configuration, + Some(recipient_address), + reason, + ) + } + TransferConstraintError::AccountNotFound(account_not_found_by_address_error) => { + TransactionRejectReason::InvalidAccountReference( + account_not_found_by_address_error.0, + ) + } + }, + )?; if !lock_configuration.is_recipient(&recipient.account_index()) { return Err(TransactionRejectReason::LockRecipientNotPermitted( @@ -224,22 +249,8 @@ where &lock_controller::LockOperation::Send(details.clone()), )?; - let mut token = block_state.token_by_id(context, &details.token)?.map_err( - |TokenNotFoundByIdError(token_id)| TransactionRejectReason::NonExistentTokenId(token_id), - )?; - let token_configuration = token.token_p9_base.token_configuration(context)?; let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; - let sender = (&source, source_address); - check_token_transfer_restrictions( - context, - &token, - operation_index, - &token_configuration, - sender, - Some((&recipient, recipient_address)), - )?; - let memo = details.memo.map(transactions::Memo::from); let remaining_locked = balance_operations::send_locked_amount( context, @@ -322,16 +333,6 @@ where let token_configuration = token.token_p9_base.token_configuration(context)?; let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; - let sender = (&source, source_address); - check_token_transfer_restrictions( - context, - &token, - operation_index, - &token_configuration, - sender, - None, - )?; - let memo = details.memo.map(transactions::Memo::from); let remaining_locked = balance_operations::return_locked_amount( context, @@ -502,84 +503,6 @@ fn lock_configuration_keeps_alive(configuration: &LockConfiguration) -> bool { } } -/// Check token-level transfer restrictions (pause, allow list, deny list) for a lock transfer -/// operation. -/// -/// `sender` and `sender_address` are the source of the locked funds being moved. -/// `recipient` is `Some` only for `lockSend` where funds are delivered to a different account; -/// for `lockFund` and `lockReturn` pass `None`. -fn check_token_transfer_restrictions( - context: &EntityContext, - token: &TokenP11, - operation_index: usize, - token_configuration: &TokenConfiguration, - sender: (&Account, AccountAddress), - recipient: Option<(&Account, AccountAddress)>, -) -> Result<(), TransactionRejectReason> { - if token.token_p9_base.is_paused(context) { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - None, - "token operation transfer is paused", - )); - } - - if token.token_p9_base.has_allow_list(context) { - if !token - .token_p9_base - .get_allow_list_for(context, sender.0.account_index()) - { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - Some(sender.1), - "sender not in allow list", - )); - } - if let Some((recipient_account, recipient_addr)) = recipient - && !token - .token_p9_base - .get_allow_list_for(context, recipient_account.account_index()) - { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - Some(recipient_addr), - "recipient not in allow list", - )); - } - } - - if token.token_p9_base.has_deny_list(context) { - if token - .token_p9_base - .get_deny_list_for(context, sender.0.account_index()) - { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - Some(sender.1), - "sender in deny list", - )); - } - if let Some((recipient_account, recipient_addr)) = recipient - && token - .token_p9_base - .get_deny_list_for(context, recipient_account.account_index()) - { - return Err(token_operation_not_permitted_reject_reason( - operation_index, - token_configuration, - Some(recipient_addr), - "recipient in deny list", - )); - } - } - - Ok(()) -} - fn parse_raw_amount( token_configuration: &TokenConfiguration, amount: BaseTokenAmount, diff --git a/plt/plt-scheduler/tests/lock_cancel.rs b/plt/plt-scheduler/tests/lock_cancel.rs index 40432bdb40..dbb46edf62 100644 --- a/plt/plt-scheduler/tests/lock_cancel.rs +++ b/plt/plt-scheduler/tests/lock_cancel.rs @@ -372,9 +372,9 @@ fn test_cancel_nonexistent() { }); } -/// Test that cancelling a lock is not blocked by token pause, allow-list, or deny-list restrictions. +/// Test that cancelling a lock is not blocked by token pause or deny-list restrictions. #[test] -fn test_cancel_ignores_token_pause_allow_list_and_deny_list() { +fn test_cancel_ignores_token_pause_and_deny_list() { let mut context = entity_test_stub::new_stubbed_context(); let mut block_state = BlockStateLatest::default(); @@ -389,7 +389,6 @@ fn test_cancel_ignores_token_pause_allow_list_and_deny_list() { TokenInitTestParams::default() .mintable() .burnable() - .allow_list() .deny_list(), 2, Some(RawTokenAmount(10000)), @@ -398,23 +397,6 @@ fn test_cancel_ignores_token_pause_allow_list_and_deny_list() { let owner_addr = context .external .account_canonical_address(owner.account_index()); - let gov_addr = context - .external - .account_canonical_address(gov_account.account_index()); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![ - TokenOperation::AddAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(gov_addr), - }), - TokenOperation::AddAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(owner_addr), - }), - ], - ); utils::increment_account_balance_p11( &mut context, &mut block_state, @@ -464,14 +446,9 @@ fn test_cancel_ignores_token_pause_allow_list_and_deny_list() { &mut block_state, &token_id, gov_account.account_index(), - vec![ - TokenOperation::RemoveAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(owner_addr), - }), - TokenOperation::AddDenyList(TokenListUpdateDetails { - target: CborHolderAccount::from(owner_addr), - }), - ], + vec![TokenOperation::AddDenyList(TokenListUpdateDetails { + target: CborHolderAccount::from(owner_addr), + })], ); utils::pause_token( &mut context, diff --git a/plt/plt-scheduler/tests/lock_fund.rs b/plt/plt-scheduler/tests/lock_fund.rs index 0d385a24cd..a20b3c8321 100644 --- a/plt/plt-scheduler/tests/lock_fund.rs +++ b/plt/plt-scheduler/tests/lock_fund.rs @@ -11,8 +11,7 @@ use concordium_base::protocol_level_tokens::meta_operations::{ MetaUpdateOperations, MetaUpdatePayload, lock_fund, }; use concordium_base::protocol_level_tokens::{ - CborHolderAccount, OperationNotPermittedRejectReason, RawCbor, TokenAmount, TokenId, - TokenListUpdateDetails, TokenModuleAccountState, TokenModuleRejectReason, TokenOperation, + RawCbor, TokenAmount, TokenId, TokenModuleAccountState, }; use concordium_base::transactions::Payload; use plt_block_state::{ @@ -173,242 +172,6 @@ fn test_lock_fund_updates_account_and_lock_state() { assert_eq!(lock_info.funds[0].amounts[0].token, token_id); assert_eq!(lock_info.funds[0].amounts[0].amount.value(), 250); } -#[test] -fn test_lock_fund_sender_not_in_allow_list() { - let mut context = entity_test_stub::new_stubbed_context(); - let mut block_state = BlockStateLatest::default(); - - let sender = context.external.create_account(); - let recipient = context.external.create_account(); - let token_id: TokenId = "pltX".parse().unwrap(); - let (gov_account, _) = utils::create_and_init_token_p11( - &mut context, - &mut block_state, - token_id.clone(), - TokenInitTestParams::default().mintable().allow_list(), - 4, - None, - ); - let gov_addr = context - .external - .account_canonical_address(gov_account.account_index()); - let sender_addr = context - .external - .account_canonical_address(sender.account_index()); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![ - TokenOperation::AddAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(gov_addr), - }), - TokenOperation::AddAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(sender_addr), - }), - ], - ); - utils::increment_account_balance_p11( - &mut context, - &mut block_state, - sender.account_index(), - &token_id, - RawTokenAmount(1000), - ); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![TokenOperation::RemoveAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(sender_addr), - })], - ); - - let lock_id = LockId::new(sender.account_index(), 7u64, 0); - let lock_config = utils::CreateLockSimpleConfig { - recipients: vec![recipient.account_index()], - grants: vec![LockControllerSimpleV0Grant { - account: sender.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }], - tokens: vec![token_id.clone()], - expiry: 1_804_806_000, - keep_alive: false, - }; - utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - sender.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id, - TokenAmount::from_raw(250, 4), - None, - )], - ); - - let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); - let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); - assert_matches!( - reject_reason, - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: 0, - address: Some(address), - reason: Some(reason), - }) => { - assert_eq!(address, CborHolderAccount::from(sender_addr)); - assert_eq!(reason, "sender not in allow list"); - } - ); -} -#[test] -fn test_lock_fund_sender_in_deny_list() { - let mut context = entity_test_stub::new_stubbed_context(); - let mut block_state = BlockStateLatest::default(); - - let sender = context.external.create_account(); - let recipient = context.external.create_account(); - let token_id: TokenId = "pltX".parse().unwrap(); - let (gov_account, _) = utils::create_and_init_token_p11( - &mut context, - &mut block_state, - token_id.clone(), - TokenInitTestParams::default().mintable().deny_list(), - 4, - None, - ); - utils::increment_account_balance_p11( - &mut context, - &mut block_state, - sender.account_index(), - &token_id, - RawTokenAmount(1000), - ); - let sender_addr = context - .external - .account_canonical_address(sender.account_index()); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![TokenOperation::AddDenyList(TokenListUpdateDetails { - target: CborHolderAccount::from(sender_addr), - })], - ); - - let lock_id = LockId::new(sender.account_index(), 7u64, 0); - let lock_config = utils::CreateLockSimpleConfig { - recipients: vec![recipient.account_index()], - grants: vec![LockControllerSimpleV0Grant { - account: sender.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }], - tokens: vec![token_id.clone()], - expiry: 1_804_806_000, - keep_alive: false, - }; - utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - sender.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id, - TokenAmount::from_raw(250, 4), - None, - )], - ); - - let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); - let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); - assert_matches!( - reject_reason, - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: 0, - address: Some(address), - reason: Some(reason), - }) => { - assert_eq!(address, CborHolderAccount::from(sender_addr)); - assert_eq!(reason, "sender in deny list"); - } - ); -} -#[test] -fn test_lock_fund_rejects_when_token_paused() { - let mut context = entity_test_stub::new_stubbed_context(); - let mut block_state = BlockStateLatest::default(); - - let sender = context.external.create_account(); - let recipient = context.external.create_account(); - let token_id: TokenId = "pltX".parse().unwrap(); - let (gov_account, _) = utils::create_and_init_token_p11( - &mut context, - &mut block_state, - token_id.clone(), - TokenInitTestParams::default().mintable(), - 4, - None, - ); - utils::increment_account_balance_p11( - &mut context, - &mut block_state, - sender.account_index(), - &token_id, - RawTokenAmount(1000), - ); - - let lock_id = LockId::new(sender.account_index(), 7u64, 0); - let lock_config = utils::CreateLockSimpleConfig { - recipients: vec![recipient.account_index()], - grants: vec![LockControllerSimpleV0Grant { - account: sender.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }], - tokens: vec![token_id.clone()], - expiry: 1_804_806_000, - keep_alive: false, - }; - utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - utils::pause_token( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - ); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - sender.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id, - TokenAmount::from_raw(250, 4), - None, - )], - ); - - let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); - let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); - assert_matches!( - reject_reason, - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: 0, - address: None, - reason: Some(reason), - }) if reason == "token operation transfer is paused" - ); -} #[test] fn test_lock_fund_rejects_unauthorized_sender() { diff --git a/plt/plt-scheduler/tests/lock_return.rs b/plt/plt-scheduler/tests/lock_return.rs index 98bf24c2e9..9f56e08cf3 100644 --- a/plt/plt-scheduler/tests/lock_return.rs +++ b/plt/plt-scheduler/tests/lock_return.rs @@ -11,8 +11,7 @@ use concordium_base::protocol_level_tokens::meta_operations::{ MetaUpdateOperations, MetaUpdatePayload, lock_fund, lock_return, }; use concordium_base::protocol_level_tokens::{ - CborHolderAccount, OperationNotPermittedRejectReason, RawCbor, TokenAmount, TokenId, - TokenListUpdateDetails, TokenModuleAccountState, TokenModuleRejectReason, TokenOperation, + RawCbor, TokenAmount, TokenId, TokenModuleAccountState, }; use concordium_base::transactions::Payload; use plt_block_state::{ @@ -266,314 +265,6 @@ fn test_lock_return_keeps_empty_lock_when_keep_alive_is_true() { .expect("lock info must decode"); assert!(lock_info.funds.is_empty()); } -#[test] -fn test_lock_return_source_not_in_allow_list() { - let mut context = entity_test_stub::new_stubbed_context(); - let mut block_state = BlockStateLatest::default(); - - let owner = context.external.create_account(); - let returner = context.external.create_account(); - let recipient = context.external.create_account(); - let token_id: TokenId = "pltX".parse().unwrap(); - let (gov_account, _) = utils::create_and_init_token_p11( - &mut context, - &mut block_state, - token_id.clone(), - TokenInitTestParams::default().mintable().allow_list(), - 4, - None, - ); - let gov_addr = context - .external - .account_canonical_address(gov_account.account_index()); - let owner_addr = context - .external - .account_canonical_address(owner.account_index()); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![ - TokenOperation::AddAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(gov_addr), - }), - TokenOperation::AddAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(owner_addr), - }), - ], - ); - utils::increment_account_balance_p11( - &mut context, - &mut block_state, - owner.account_index(), - &token_id, - RawTokenAmount(1000), - ); - - let lock_id = LockId::new(owner.account_index(), 7u64, 0); - let lock_config = utils::CreateLockSimpleConfig { - recipients: vec![recipient.account_index()], - grants: vec![ - LockControllerSimpleV0Grant { - account: owner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }, - LockControllerSimpleV0Grant { - account: returner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Return], - }, - ], - tokens: vec![token_id.clone()], - expiry: 1_804_806_000, - keep_alive: false, - }; - utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - assert_matches!( - execute_meta_update!( - &mut context, - &mut block_state, - owner.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - )], - ), - TransactionOutcome::Success(_) - ); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![TokenOperation::RemoveAllowList(TokenListUpdateDetails { - target: CborHolderAccount::from(owner_addr), - })], - ); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - returner.account_index(), - 0, - vec![lock_return( - token_id.clone(), - lock_id, - owner_addr, - TokenAmount::from_raw(100, 4), - None, - )], - ); - - let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); - let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); - assert_matches!( - reject_reason, - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: 0, - address: Some(address), - reason: Some(reason), - }) => { - assert_eq!(address, CborHolderAccount::from(owner_addr)); - assert_eq!(reason, "sender not in allow list"); - } - ); -} -#[test] -fn test_lock_return_source_in_deny_list() { - let mut context = entity_test_stub::new_stubbed_context(); - let mut block_state = BlockStateLatest::default(); - - let owner = context.external.create_account(); - let returner = context.external.create_account(); - let recipient = context.external.create_account(); - let token_id: TokenId = "pltX".parse().unwrap(); - let (gov_account, _) = utils::create_and_init_token_p11( - &mut context, - &mut block_state, - token_id.clone(), - TokenInitTestParams::default().mintable().deny_list(), - 4, - None, - ); - utils::increment_account_balance_p11( - &mut context, - &mut block_state, - owner.account_index(), - &token_id, - RawTokenAmount(1000), - ); - - let lock_id = LockId::new(owner.account_index(), 7u64, 0); - let lock_config = utils::CreateLockSimpleConfig { - recipients: vec![recipient.account_index()], - grants: vec![ - LockControllerSimpleV0Grant { - account: owner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }, - LockControllerSimpleV0Grant { - account: returner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Return], - }, - ], - tokens: vec![token_id.clone()], - expiry: 1_804_806_000, - keep_alive: false, - }; - utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - let owner_addr = context - .external - .account_canonical_address(owner.account_index()); - assert_matches!( - execute_meta_update!( - &mut context, - &mut block_state, - owner.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - )], - ), - TransactionOutcome::Success(_) - ); - utils::execute_token_operations( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - vec![TokenOperation::AddDenyList(TokenListUpdateDetails { - target: CborHolderAccount::from(owner_addr), - })], - ); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - returner.account_index(), - 0, - vec![lock_return( - token_id.clone(), - lock_id, - owner_addr, - TokenAmount::from_raw(100, 4), - None, - )], - ); - - let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); - let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); - assert_matches!( - reject_reason, - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: 0, - address: Some(address), - reason: Some(reason), - }) => { - assert_eq!(address, CborHolderAccount::from(owner_addr)); - assert_eq!(reason, "sender in deny list"); - } - ); -} -#[test] -fn test_lock_return_rejects_when_token_paused() { - let mut context = entity_test_stub::new_stubbed_context(); - let mut block_state = BlockStateLatest::default(); - - let owner = context.external.create_account(); - let returner = context.external.create_account(); - let recipient = context.external.create_account(); - let token_id: TokenId = "pltX".parse().unwrap(); - let (gov_account, _) = utils::create_and_init_token_p11( - &mut context, - &mut block_state, - token_id.clone(), - TokenInitTestParams::default().mintable(), - 4, - None, - ); - utils::increment_account_balance_p11( - &mut context, - &mut block_state, - owner.account_index(), - &token_id, - RawTokenAmount(1000), - ); - - let lock_id = LockId::new(owner.account_index(), 7u64, 0); - let lock_config = utils::CreateLockSimpleConfig { - recipients: vec![recipient.account_index()], - grants: vec![ - LockControllerSimpleV0Grant { - account: owner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Fund], - }, - LockControllerSimpleV0Grant { - account: returner.account_index(), - roles: vec![LockControllerSimpleV0Capability::Return], - }, - ], - tokens: vec![token_id.clone()], - expiry: 1_804_806_000, - keep_alive: false, - }; - utils::create_lock(&mut context, &mut block_state, &lock_id, lock_config); - let owner_addr = context - .external - .account_canonical_address(owner.account_index()); - assert_matches!( - execute_meta_update!( - &mut context, - &mut block_state, - owner.account_index(), - 0, - vec![lock_fund( - token_id.clone(), - lock_id.clone(), - TokenAmount::from_raw(250, 4), - None, - )], - ), - TransactionOutcome::Success(_) - ); - utils::pause_token( - &mut context, - &mut block_state, - &token_id, - gov_account.account_index(), - ); - - let outcome = execute_meta_update!( - &mut context, - &mut block_state, - returner.account_index(), - 0, - vec![lock_return( - token_id.clone(), - lock_id, - owner_addr, - TokenAmount::from_raw(100, 4), - None, - )], - ); - - let reject_reason = assert_matches!(outcome, TransactionOutcome::Rejected(reason) => reason); - let reject_reason = utils::assert_token_module_reject_reason(&token_id, reject_reason); - assert_matches!( - reject_reason, - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: 0, - address: None, - reason: Some(reason), - }) if reason == "token operation transfer is paused" - ); -} #[test] fn test_lock_return_rejects_unauthorized_sender() { From 87e8478b23371e675a64d49149b66c3d358362e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 4 Jun 2026 12:48:59 +0200 Subject: [PATCH 12/15] docs --- .../token_module/update.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs index 86844d5bd2..cb24d7e426 100644 --- a/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs +++ b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs @@ -391,18 +391,50 @@ fn check_authorized( Ok(()) } +/// Resolved and validated sender and receiver accounts, returned by +/// [`check_transfer_constraints`] on success. pub(crate) struct ValidatedAccounts { + /// The resolved sender account. pub sender: Account, + /// The resolved receiver account. pub receiver: Account, } +/// Reasons why a token transfer is not permitted according to the token module +/// state. Returned by [`check_transfer_constraints`] when a constraint is +/// violated. pub(crate) enum TransferConstraintError { + /// The token is currently paused; no balance-affecting operations are + /// allowed. Paused, + /// The sender account is not permitted to send (not in the allow list, or + /// in the deny list). The `reason` string is a human-readable explanation. SenderNotAllowed { reason: &'static str }, + /// The recipient account is not permitted to receive (not in the allow + /// list, or in the deny list). The `reason` string is a human-readable + /// explanation. RecipientNotAllowed { reason: &'static str }, + /// One of the account addresses could not be resolved in the block state. AccountNotFound(AccountNotFoundByAddressError), } +/// Validate that a token transfer between `sender` and `receiver` is permitted +/// by the token module's current state. +/// +/// Checks, in order: +/// 1. The token is not paused. +/// 2. Both accounts can be resolved (the `Result` arguments allow the caller +/// to forward lookup errors here rather than handling them separately). +/// 3. If the token has an allow list, both accounts must be on it. +/// 4. If the token has a deny list, neither account may be on it. +/// +/// On success, the resolved [`ValidatedAccounts`] are returned so the caller +/// does not need to look them up again. +/// +/// # Errors +/// +/// Returns a [`TransferConstraintError`] describing the first constraint that +/// is violated. pub(crate) fn check_transfer_constraints( context: &EntityContext, token: &TokenP9Base, From 4f500d507c6ea503a57f949b2b4921a29bc1eddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 4 Jun 2026 18:52:21 +0200 Subject: [PATCH 13/15] Address pr feedback --- .../token_module/update.rs | 194 ++++++++---------- .../src/scheduler/plt_scheduler.rs | 98 ++++----- 2 files changed, 131 insertions(+), 161 deletions(-) diff --git a/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs index cb24d7e426..555c0f2675 100644 --- a/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs +++ b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs @@ -23,6 +23,7 @@ use plt_block_state::entity::protocol_level_tokens::p11::TokenP11; use plt_block_state::entity::{EntityContext, EntityContextTypes}; use plt_block_state::external::AccountNotFoundByAddressError; use plt_block_state::failure::{BlockStateFailure, BlockStateResult}; +use plt_block_state::persistent::protocol_level_tokens::p9::TokenConfiguration; use plt_scheduler_types::types::events::{BlockItemEvent, EncodedTokenModuleEvent}; /// Represents the reasons why [`execute_token_update_transaction`] can fail. @@ -116,7 +117,49 @@ pub fn execute_token_update_operation_at_index( Err(int_err) => int_err, }; - Ok(Err(match int_err { + let token_configuration = token.token_p9_base().token_configuration(context)?; + token_update_error_internal_to_external( + &token_configuration, + index, + operation_name(operation), + int_err, + ) + .map(Err) +} + +/// Translate an internal token update error into the externally visible token +/// update error. +/// +/// # Arguments +/// +/// - `token_configuration`: the token configuration used to format amounts and +/// identify the token in reject details. +/// - `index`: the operation index in the transaction. +/// - `operation_type`: the token operation type used in reject messages. +/// - `err`: the internal error to translate. +/// +/// # Errors +/// +/// Returns a [`BlockStateFailure`] when `err` represents an unrecoverable block +/// state failure. +/// +/// # Examples +/// +/// ```ignore +/// let external = token_update_error_internal_to_external( +/// &token_configuration, +/// 0, +/// "transfer", +/// TokenUpdateErrorInternal::Paused, +/// )?; +/// ``` +pub(crate) fn token_update_error_internal_to_external( + token_configuration: &TokenConfiguration, + index: usize, + operation_type: &'static str, + err: TokenUpdateErrorInternal, +) -> BlockStateResult { + Ok(match err { TokenUpdateErrorInternal::AccountDoesNotExist(err) => TokenUpdateError::TokenModuleReject( TokenModuleRejectReason::AddressNotFound(AddressNotFoundRejectReason { index: index as u64, @@ -131,40 +174,31 @@ pub fn execute_token_update_operation_at_index( )) } TokenUpdateErrorInternal::InsufficientBalance(err) => { - let token_configuration = token.token_p9_base().token_configuration(context)?; - TokenUpdateError::TokenModuleReject(TokenModuleRejectReason::TokenBalanceInsufficient( TokenBalanceInsufficientRejectReason { index: index as u64, - available_balance: util::to_token_amount(&token_configuration, err.available), - required_balance: util::to_token_amount(&token_configuration, err.required), - }, - )) - } - TokenUpdateErrorInternal::MintWouldOverflow(err) => { - let token_configuration = token.token_p9_base().token_configuration(context)?; - - TokenUpdateError::TokenModuleReject(TokenModuleRejectReason::MintWouldOverflow( - MintWouldOverflowRejectReason { - index: index as u64, - requested_amount: util::to_token_amount( - &token_configuration, - err.requested_amount, - ), - current_supply: util::to_token_amount(&token_configuration, err.current_supply), - max_representable_amount: util::to_token_amount( - &token_configuration, - err.max_representable_amount, - ), + available_balance: util::to_token_amount(token_configuration, err.available), + required_balance: util::to_token_amount(token_configuration, err.required), }, )) } + TokenUpdateErrorInternal::MintWouldOverflow(err) => TokenUpdateError::TokenModuleReject( + TokenModuleRejectReason::MintWouldOverflow(MintWouldOverflowRejectReason { + index: index as u64, + requested_amount: util::to_token_amount(token_configuration, err.requested_amount), + current_supply: util::to_token_amount(token_configuration, err.current_supply), + max_representable_amount: util::to_token_amount( + token_configuration, + err.max_representable_amount, + ), + }), + ), TokenUpdateErrorInternal::OutOfEnergy(err) => TokenUpdateError::OutOfEnergy(err), TokenUpdateErrorInternal::Paused => TokenUpdateError::TokenModuleReject( TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { index: index as u64, address: None, - reason: format!("token operation {} is paused", operation_name(operation)) + reason: format!("token operation {operation_type} is paused") .to_string() .into(), }), @@ -183,14 +217,13 @@ pub fn execute_token_update_operation_at_index( TokenUpdateError::TokenModuleReject(TokenModuleRejectReason::UnsupportedOperation( UnsupportedOperationRejectReason { index: index as u64, - operation_type: operation_name(operation).to_string(), + operation_type: operation_type.to_string(), reason: reason.to_string().into(), }, )) } - TokenUpdateErrorInternal::BlockStateFailure(err) => return Err(err), - })) + }) } fn operation_name(operation: &TokenOperation) -> &'static str { @@ -391,69 +424,38 @@ fn check_authorized( Ok(()) } -/// Resolved and validated sender and receiver accounts, returned by -/// [`check_transfer_constraints`] on success. -pub(crate) struct ValidatedAccounts { - /// The resolved sender account. - pub sender: Account, - /// The resolved receiver account. - pub receiver: Account, -} - -/// Reasons why a token transfer is not permitted according to the token module -/// state. Returned by [`check_transfer_constraints`] when a constraint is -/// violated. -pub(crate) enum TransferConstraintError { - /// The token is currently paused; no balance-affecting operations are - /// allowed. - Paused, - /// The sender account is not permitted to send (not in the allow list, or - /// in the deny list). The `reason` string is a human-readable explanation. - SenderNotAllowed { reason: &'static str }, - /// The recipient account is not permitted to receive (not in the allow - /// list, or in the deny list). The `reason` string is a human-readable - /// explanation. - RecipientNotAllowed { reason: &'static str }, - /// One of the account addresses could not be resolved in the block state. - AccountNotFound(AccountNotFoundByAddressError), -} - /// Validate that a token transfer between `sender` and `receiver` is permitted /// by the token module's current state. /// -/// Checks, in order: -/// 1. The token is not paused. -/// 2. Both accounts can be resolved (the `Result` arguments allow the caller -/// to forward lookup errors here rather than handling them separately). -/// 3. If the token has an allow list, both accounts must be on it. -/// 4. If the token has a deny list, neither account may be on it. -/// -/// On success, the resolved [`ValidatedAccounts`] are returned so the caller -/// does not need to look them up again. +/// This checks that the token is not paused, that both accounts satisfy an +/// allow list if one is configured, and that neither account is on a configured +/// deny list. /// /// # Errors /// -/// Returns a [`TransferConstraintError`] describing the first constraint that -/// is violated. +/// Returns [`TokenUpdateErrorInternal::Paused`] if the token is paused, or +/// [`TokenUpdateErrorInternal::OperationNotPermitted`] if either account is not +/// permitted to participate in the transfer. pub(crate) fn check_transfer_constraints( context: &EntityContext, token: &TokenP9Base, - sender: Result, - receiver: Result, -) -> Result { - check_not_paused(context, token).map_err(|_| TransferConstraintError::Paused)?; - - let sender = sender.map_err(TransferConstraintError::AccountNotFound)?; - let receiver = receiver.map_err(TransferConstraintError::AccountNotFound)?; + sender: &Account, + sender_address: AccountAddress, + receiver: &Account, + receiver_address: AccountAddress, +) -> Result<(), TokenUpdateErrorInternal> { + check_not_paused(context, token)?; if token.has_allow_list(context) { if !token.get_allow_list_for(context, sender.account_index()) { - return Err(TransferConstraintError::SenderNotAllowed { + return Err(TokenUpdateErrorInternal::OperationNotPermitted { + account_address: Some(sender_address), reason: "sender not in allow list", }); } if !token.get_allow_list_for(context, receiver.account_index()) { - return Err(TransferConstraintError::RecipientNotAllowed { + return Err(TokenUpdateErrorInternal::OperationNotPermitted { + account_address: Some(receiver_address), reason: "recipient not in allow list", }); } @@ -461,18 +463,20 @@ pub(crate) fn check_transfer_constraints( if token.has_deny_list(context) { if token.get_deny_list_for(context, sender.account_index()) { - return Err(TransferConstraintError::SenderNotAllowed { + return Err(TokenUpdateErrorInternal::OperationNotPermitted { + account_address: Some(sender_address), reason: "sender in deny list", }); } if token.get_deny_list_for(context, receiver.account_index()) { - return Err(TransferConstraintError::RecipientNotAllowed { + return Err(TokenUpdateErrorInternal::OperationNotPermitted { + account_address: Some(receiver_address), reason: "recipient in deny list", }); } } - Ok(ValidatedAccounts { sender, receiver }) + Ok(()) } fn execute_token_transfer( @@ -489,39 +493,23 @@ fn execute_token_transfer( let sender = transaction_execution.sender_account(); let sender_address = transaction_execution.sender_account_address(); - let receiver = context.account_by_address(&transfer_operation.recipient.address); let receiver_address = transfer_operation.recipient.address; + let receiver = context.account_by_address(&receiver_address)?; - let ValidatedAccounts { sender, receiver } = - match check_transfer_constraints(context, token, Ok(sender.clone()), receiver) { - Ok(accs) => accs, - Err(err) => { - return Err(match err { - TransferConstraintError::Paused => TokenUpdateErrorInternal::Paused, - TransferConstraintError::AccountNotFound(inner) => { - TokenUpdateErrorInternal::AccountDoesNotExist(inner) - } - TransferConstraintError::SenderNotAllowed { reason } => { - TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(sender_address), - reason, - } - } - TransferConstraintError::RecipientNotAllowed { reason } => { - TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(receiver_address), - reason, - } - } - }); - } - }; + check_transfer_constraints( + context, + token, + sender, + sender_address, + &receiver, + receiver_address, + )?; balance_operations::transfer( context, events, token, - &sender, + sender, sender_address, &receiver, receiver_address, diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index 6eed7db47c..a9141c60eb 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -6,21 +6,20 @@ use crate::locks::{get_lock_config, lock_controller}; use crate::protocol_level_tokens::balance_operations; use crate::protocol_level_tokens::token_module::errors::InsufficientBalanceError; use crate::protocol_level_tokens::token_module::{ - TransferConstraintError, ValidatedAccounts, check_transfer_constraints, + TokenUpdateError, check_transfer_constraints, token_update_error_internal_to_external, }; use crate::scheduler::TransactionFailure; use crate::transaction_execution::TransactionExecution; use concordium_base::base::AccountIndex; use concordium_base::common::cbor::{self}; -use concordium_base::contracts_common::AccountAddress; use concordium_base::protocol_level_locks::LockId; use concordium_base::protocol_level_tokens::meta_operations::{ LockOperation, MetaLockCancelDetails, MetaLockCreateDetails, MetaLockFundDetails, MetaLockReturnDetails, MetaLockSendDetails, }; use concordium_base::protocol_level_tokens::{ - DeserializationFailureRejectReason, OperationNotPermittedRejectReason, RawCbor, - TokenAmount as BaseTokenAmount, TokenBalanceInsufficientRejectReason, TokenModuleRejectReason, + DeserializationFailureRejectReason, RawCbor, TokenAmount as BaseTokenAmount, + TokenBalanceInsufficientRejectReason, TokenModuleRejectReason, }; use concordium_base::transactions; use plt_block_state::block_state::ExecutionTimeBlockStateP11; @@ -190,49 +189,35 @@ where } let source_address = details.source.address; - let source = context.account_by_address(&source_address); + let source = context + .account_by_address(&source_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(source_address))?; let recipient_address = details.recipient.address; - let recipient = context.account_by_address(&recipient_address); + let recipient = context + .account_by_address(&recipient_address) + .map_err(|_| TransactionRejectReason::InvalidAccountReference(recipient_address))?; let mut token = block_state.token_by_id(context, &details.token)?.map_err( |TokenNotFoundByIdError(token_id)| TransactionRejectReason::NonExistentTokenId(token_id), )?; let token_configuration = token.token_p9_base.token_configuration(context)?; - let ValidatedAccounts { - sender: source, - receiver: recipient, - } = check_transfer_constraints(context, &token.token_p9_base, source, recipient).map_err( - |err| match err { - TransferConstraintError::Paused => token_operation_not_permitted_reject_reason( - operation_index, - &token_configuration, - None, - "token operation transfer is paused", - ), - TransferConstraintError::SenderNotAllowed { reason } => { - token_operation_not_permitted_reject_reason( - operation_index, - &token_configuration, - Some(source_address), - reason, - ) - } - TransferConstraintError::RecipientNotAllowed { reason } => { - token_operation_not_permitted_reject_reason( - operation_index, - &token_configuration, - Some(recipient_address), - reason, - ) - } - TransferConstraintError::AccountNotFound(account_not_found_by_address_error) => { - TransactionRejectReason::InvalidAccountReference( - account_not_found_by_address_error.0, - ) - } - }, - )?; + if let Err(err) = check_transfer_constraints( + context, + &token.token_p9_base, + &source, + source_address, + &recipient, + recipient_address, + ) { + let err = token_update_error_internal_to_external( + &token_configuration, + operation_index, + "transfer", + err, + )?; + return Err(token_update_error_reject_reason(&token_configuration, err)); + } if !lock_configuration.is_recipient(&recipient.account_index()) { return Err(TransactionRejectReason::LockRecipientNotPermitted( @@ -542,25 +527,22 @@ fn token_deserialization_failure_reject_reason( }) } -fn token_operation_not_permitted_reject_reason( - operation_index: usize, +fn token_update_error_reject_reason( token_configuration: &TokenConfiguration, - address: Option, - reason: &'static str, -) -> TransactionRejectReason { - let (reason_type, details) = - TokenModuleRejectReason::OperationNotPermitted(OperationNotPermittedRejectReason { - index: operation_index as u64, - address: address.map(Into::into), - reason: reason.to_string().into(), - }) - .encode_reject_reason(); - - TransactionRejectReason::TokenUpdateTransactionFailed(EncodedTokenModuleRejectReason { - token_id: token_configuration.token_id.clone(), - reason_type: reason_type.to_type_discriminator(), - details: Some(details), - }) + err: TokenUpdateError, +) -> TransactionFailure { + match err { + TokenUpdateError::OutOfEnergy(_) => TransactionRejectReason::OutOfEnergy.into(), + TokenUpdateError::TokenModuleReject(reject_reason) => { + let (reason_type, details) = reject_reason.encode_reject_reason(); + TransactionRejectReason::TokenUpdateTransactionFailed(EncodedTokenModuleRejectReason { + token_id: token_configuration.token_id.clone(), + reason_type: reason_type.to_type_discriminator(), + details: Some(details), + }) + .into() + } + } } fn token_balance_insufficient_reject_reason( From ef2c7783ba43297fa25ed3a88f88e6e99d26ccc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 4 Jun 2026 20:55:51 +0200 Subject: [PATCH 14/15] Update haskell test to match new reject reason ordering --- .../SchedulerTests/TokenHolderTransactions.hs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs b/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs index 3af536c277..05bb7c67ad 100644 --- a/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs +++ b/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs @@ -769,20 +769,20 @@ testTransfer _ = property (ioProperty . theTest) -- The full supplied energy will be used in the case of an -- out-of-energy failure. postCheck False - | tcPaused -> do + | tcRecvInvalid -> do assertTokenReject - CBOR.OperationNotPermitted + CBOR.AddressNotFound { trrOperationIndex = 0, - trrAddressNotPermitted = Nothing, - trrReason = Just "token operation transfer is paused" + trrAddress = CBOR.accountTokenHolder actualRecipientAddress } result postCheck False - | tcRecvInvalid -> do + | tcPaused -> do assertTokenReject - CBOR.AddressNotFound + CBOR.OperationNotPermitted { trrOperationIndex = 0, - trrAddress = CBOR.accountTokenHolder actualRecipientAddress + trrAddressNotPermitted = Nothing, + trrReason = Just "token operation transfer is paused" } result postCheck False From 404cb8fe4280df3ec27352c7fde5dca3bad898c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 4 Jun 2026 21:25:07 +0200 Subject: [PATCH 15/15] .. --- .../SchedulerTests/TokenHolderTransactions.hs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs b/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs index 05bb7c67ad..7b82953d41 100644 --- a/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs +++ b/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs @@ -554,7 +554,7 @@ testTransfer :: (IsProtocolVersion pv, PVSupportsPLT pv) => SProtocolVersion pv -> Property -testTransfer _ = property (ioProperty . theTest) +testTransfer spv = property (ioProperty . theTest) where theTest TransferConfig{..} = do let govAcct = CBOR.accountTokenHolder dummyAddress @@ -769,7 +769,7 @@ testTransfer _ = property (ioProperty . theTest) -- The full supplied energy will be used in the case of an -- out-of-energy failure. postCheck False - | tcRecvInvalid -> do + | tcRecvInvalid && demoteProtocolVersion spv >= Types.P11 -> do assertTokenReject CBOR.AddressNotFound { trrOperationIndex = 0, @@ -786,6 +786,14 @@ testTransfer _ = property (ioProperty . theTest) } result postCheck False + | tcRecvInvalid -> do + assertTokenReject + CBOR.AddressNotFound + { trrOperationIndex = 0, + trrAddress = CBOR.accountTokenHolder actualRecipientAddress + } + result + postCheck False | tcAllowList && not tcSenderAllow -> do assertTokenReject CBOR.OperationNotPermitted