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/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. diff --git a/concordium-base b/concordium-base index 1fbf4cfcc5..24a8db13dc 160000 --- a/concordium-base +++ b/concordium-base @@ -1 +1 @@ -Subproject commit 1fbf4cfcc5dab7c041b69753da74e01ac6285b24 +Subproject commit 24a8db13dc67e7b9cf782f041fa62a87d891160b diff --git a/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs b/concordium-consensus/tests/scheduler/SchedulerTests/TokenHolderTransactions.hs index 3af536c277..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,6 +769,14 @@ testTransfer _ = property (ioProperty . theTest) -- The full supplied energy will be used in the case of an -- out-of-energy failure. postCheck False + | tcRecvInvalid && demoteProtocolVersion spv >= Types.P11 -> do + assertTokenReject + CBOR.AddressNotFound + { trrOperationIndex = 0, + trrAddress = CBOR.accountTokenHolder actualRecipientAddress + } + result + postCheck False | tcPaused -> do assertTokenReject CBOR.OperationNotPermitted diff --git a/plt/plt-block-state/src/block_state.rs b/plt/plt-block-state/src/block_state.rs index 9f82277d5f..9ef3b653c1 100644 --- a/plt/plt-block-state/src/block_state.rs +++ b/plt/plt-block-state/src/block_state.rs @@ -287,6 +287,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 81265d3a91..fcacb78208 100644 --- a/plt/plt-block-state/src/block_state_interface.rs +++ b/plt/plt-block-state/src/block_state_interface.rs @@ -329,4 +329,26 @@ 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, + ); } 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 5f577628a7..ac555a50f5 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-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/locks/lock_controller.rs b/plt/plt-scheduler/src/locks/lock_controller.rs index bee1740f5f..650219d289 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, }; @@ -23,7 +24,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 @@ -31,9 +33,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 @@ -67,12 +70,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 37ed014640..4741c200a5 100644 --- a/plt/plt-scheduler/src/locks/lock_controller_simple.rs +++ b/plt/plt-scheduler/src/locks/lock_controller_simple.rs @@ -1,4 +1,5 @@ use concordium_base::base::AccountIndex; +use concordium_base::contracts_common::AccountAddress; use concordium_base::protocol_level_locks::LockControllerSimpleV0Capability; use concordium_base::protocol_level_tokens::{CborHolderAccount, TokenId}; use plt_block_state::block_state_interface::BlockStateQuery; @@ -15,17 +16,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/protocol_level_tokens/balance_operations.rs b/plt/plt-scheduler/src/protocol_level_tokens/balance_operations.rs index a7a592035e..65b676350f 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,207 @@ 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. +#[allow(clippy::too_many_arguments)] +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. +#[allow(clippy::too_many_arguments)] +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/protocol_level_tokens/token_module/update.rs b/plt/plt-scheduler/src/protocol_level_tokens/token_module/update.rs index 64120bb72e..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 @@ -17,12 +17,13 @@ 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}; 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 { @@ -213,7 +246,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,25 +424,28 @@ 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, +/// Validate that a token transfer between `sender` and `receiver` is permitted +/// by the token module's current state. +/// +/// 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 [`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: &Account, + sender_address: AccountAddress, + receiver: &Account, + receiver_address: AccountAddress, ) -> Result<(), TokenUpdateErrorInternal> { - let token_configuration = token.token_configuration(context)?; - - // preprocessing - let raw_amount = util::to_raw_token_amount(&token_configuration, transfer_operation.amount)?; - - // operation execution check_not_paused(context, token)?; - let sender = transaction_execution.sender_account(); - let sender_address = transaction_execution.sender_account_address(); - let receiver = context.account_by_address(&transfer_operation.recipient.address)?; - if token.has_allow_list(context) { if !token.get_allow_list_for(context, sender.account_index()) { return Err(TokenUpdateErrorInternal::OperationNotPermitted { @@ -419,7 +455,7 @@ fn execute_token_transfer( } if !token.get_allow_list_for(context, receiver.account_index()) { return Err(TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(transfer_operation.recipient.address), + account_address: Some(receiver_address), reason: "recipient not in allow list", }); } @@ -434,12 +470,41 @@ fn execute_token_transfer( } if token.get_deny_list_for(context, receiver.account_index()) { return Err(TokenUpdateErrorInternal::OperationNotPermitted { - account_address: Some(transfer_operation.recipient.address), + account_address: Some(receiver_address), reason: "recipient in deny list", }); } } + 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)?; + + // 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_address = transfer_operation.recipient.address; + let receiver = context.account_by_address(&receiver_address)?; + + check_transfer_constraints( + context, + token, + sender, + sender_address, + &receiver, + receiver_address, + )?; + balance_operations::transfer( context, events, @@ -447,7 +512,7 @@ fn execute_token_transfer( 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/p11.rs b/plt/plt-scheduler/src/scheduler/p11.rs index f1eeebfe4d..a4a4da7da6 100644 --- a/plt/plt-scheduler/src/scheduler/p11.rs +++ b/plt/plt-scheduler/src/scheduler/p11.rs @@ -105,14 +105,14 @@ where }; // Execute operations - for operation in operations { + for (index, operation) in operations.into_iter().enumerate() { match MetaUpdateOperationKind::from(operation) { MetaUpdateOperationKind::Token(token_id, token_operation) => { match protocol_level_tokens::p11::execute_token_update_operation( context, transaction_execution, block_state, - 0, + index, &token_id, token_operation, &mut events, @@ -128,6 +128,7 @@ where context, transaction_execution, block_state, + index, lock_operation, &mut events, ) { diff --git a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs index 57dedb0734..a9141c60eb 100644 --- a/plt/plt-scheduler/src/scheduler/plt_scheduler.rs +++ b/plt/plt-scheduler/src/scheduler/plt_scheduler.rs @@ -3,29 +3,47 @@ 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::{ + 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::protocol_level_locks::LockId; -use concordium_base::protocol_level_tokens::RawCbor; -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, +}; use concordium_base::transactions; use plt_block_state::block_state::ExecutionTimeBlockStateP11; 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::{EntityContext, EntityContextTypes}; use plt_block_state::failure::BlockStateFailure; -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}; -use plt_scheduler_types::types::reject_reasons::TransactionRejectReason; +use plt_scheduler_types::types::reject_reasons::{ + EncodedTokenModuleRejectReason, TransactionRejectReason, +}; +use plt_scheduler_types::types::tokens::RawTokenAmount; -/// Execute [`LockOperation`] +/// Execute [`LockOperation`]. pub fn execute_lock_operation( context: &mut EntityContext, transaction_execution: &mut TransactionExecution, block_state: &mut BlockStateP11, + operation_index: usize, lock_operation: LockOperation, events: &mut Vec, ) -> Result<(), TransactionFailure> @@ -33,105 +51,522 @@ where EntityContext: Clone, { match lock_operation { - LockOperation::Fund(_meta_lock_fund_details) => todo!(), - LockOperation::Send(_meta_lock_send_details) => todo!(), - LockOperation::Return(_meta_lock_return_details) => todo!(), - 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); - - // We reconstruct the lock config for the event, rather than using - // the original one from the transaction. This results in a config - // that is in a canonical form. - 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::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) } - 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(), - &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(TransactionFailure::RejectReason( - TransactionRejectReason::LockCancelNotAuthorized( - lock.lock_id().clone(), - transaction_execution.sender_account_address(), - ), - )); - } - 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)); + } +} + +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 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 token_index = token.token_p9_base.token_index(); + block_state.update_token(context, token)?; + + if is_new_holder { + lock.add_lock_balance_ref( + transaction_execution.sender_account().account_index(), + token_index, + ); + block_state.update_lock(context, lock)?; } Ok(()) } + +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 = 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))?; + + 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)?; + + 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( + 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(details.clone()), + )?; + + let raw_amount = parse_raw_amount(&token_configuration, details.amount, operation_index)?; + + 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, + )?; + } + + Ok(()) +} + +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 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 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(()) +} + +fn lock_configuration_keeps_alive(configuration: &LockConfiguration) -> bool { + match configuration.controller() { + LockControllerConfig::SimpleV0(controller) => controller.keep_alive, + } +} + +fn parse_raw_amount( + token_configuration: &TokenConfiguration, + amount: BaseTokenAmount, + operation_index: usize, +) -> Result { + if amount.decimals() != token_configuration.decimals { + Err(token_deserialization_failure_reject_reason( + token_configuration, + operation_index, + format!( + "Token amount decimals mismatch: expected {}, found {}", + token_configuration.decimals, + amount.decimals() + ), + )) + } else { + Ok(RawTokenAmount(amount.value())) + } +} + +fn token_deserialization_failure_reject_reason( + token_configuration: &TokenConfiguration, + operation_index: usize, + cause: String, +) -> TransactionRejectReason { + let _ = operation_index; + let (reason_type, details) = + TokenModuleRejectReason::DeserializationFailure(DeserializationFailureRejectReason { + cause: Some(cause), + }) + .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_update_error_reject_reason( + token_configuration: &TokenConfiguration, + 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( + operation_index: usize, + token_configuration: &TokenConfiguration, + error: InsufficientBalanceError, +) -> TransactionRejectReason { + let (reason_type, details) = + TokenModuleRejectReason::TokenBalanceInsufficient(TokenBalanceInsufficientRejectReason { + index: operation_index as u64, + available_balance: BaseTokenAmount::from_raw( + error.available.0, + token_configuration.decimals, + ), + required_balance: BaseTokenAmount::from_raw( + error.required.0, + token_configuration.decimals, + ), + }) + .encode_reject_reason(); + + TransactionRejectReason::TokenUpdateTransactionFailed(EncodedTokenModuleRejectReason { + token_id: token_configuration.token_id.clone(), + reason_type: reason_type.to_type_discriminator(), + details: Some(details), + }) +} diff --git a/plt/plt-scheduler/tests/lock_cancel.rs b/plt/plt-scheduler/tests/lock_cancel.rs index 951df724ee..dbb46edf62 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, @@ -54,18 +55,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 +114,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 +172,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 +248,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 +267,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, @@ -374,3 +371,100 @@ fn test_cancel_nonexistent() { assert_eq!(rejected_lock_id, lock_id); }); } + +/// Test that cancelling a lock is not blocked by token pause or deny-list restrictions. +#[test] +fn test_cancel_ignores_token_pause_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() + .deny_list(), + 2, + Some(RawTokenAmount(10000)), + ); + + let owner_addr = context + .external + .account_canonical_address(owner.account_index()); + 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::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..a20b3c8321 --- /dev/null +++ b/plt/plt-scheduler/tests/lock_fund.rs @@ -0,0 +1,286 @@ +//! 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::{ + 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); + 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_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_return.rs b/plt/plt-scheduler/tests/lock_return.rs new file mode 100644 index 0000000000..9f56e08cf3 --- /dev/null +++ b/plt/plt-scheduler/tests/lock_return.rs @@ -0,0 +1,410 @@ +//! Tests for returning 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_return, +}; +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::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}; + +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_return_deletes_empty_lock_when_keep_alive_is_false() { + 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: false, + }; + 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(), 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()); + + 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) + .expect("lock info query must succeed"), + ) + .expect("lock info must decode"); + assert!(lock_info.funds.is_empty()); +} + +#[test] +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 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 outcome = execute_meta_update!( + &mut context, + &mut block_state, + owner.account_index(), + 0, + 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::LockReturnNotAuthorized(lock_id, owner_addr)); + }); +} + +#[test] +fn test_lock_return_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::Return, + ], + }], + 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 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)); + }); +} 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/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..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; @@ -12,18 +16,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 +46,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 +58,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, }), })], 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 {