diff --git a/pallets/asset/src/benchmarking.rs b/pallets/asset/src/benchmarking.rs index 988bd960bb..0270a1a25e 100644 --- a/pallets/asset/src/benchmarking.rs +++ b/pallets/asset/src/benchmarking.rs @@ -814,27 +814,6 @@ benchmarks! { .unwrap(); }: _(RawOrigin::Root, asset_metadata_name, asset_metadata_spec) - transfer_asset_base_weight { - let alice = UserBuilder::::default().generate_did().build("Alice"); - let bob = UserBuilder::::default().generate_did().build("Bob"); - - // Setup the transfer with worse case conditions. - // Don't move the assets from the default portfolio. - let (_sender_portfolio, _receiver_portfolio, _, asset_id) = - setup_asset_transfer::(&alice, &bob, None, None, true, true, 0, false, false); - }: { - Pallet::::base_transfer_asset( - alice.origin.into(), - asset_id, - bob.account(), - ONE_UNIT, - None, - // Only benchmark the base cost. - true, - ) - .unwrap(); - } - receiver_affirm_asset_transfer_base_weight { let alice = UserBuilder::::default().generate_did().build("Alice"); let bob = UserBuilder::::default().generate_did().build("Bob"); @@ -845,12 +824,16 @@ benchmarks! { setup_asset_transfer::(&alice, &bob, None, None, true, true, 0, true, true); let mut weight_meter = WeightMeter::max_limit_no_minimum(); - let instruction_id = T::SettlementFn::transfer_asset_and_try_execute( + let to = AssetHolder::try_from(bob.account().encode()).unwrap(); + let fund = Fund { + description: FundDescription::Fungible { asset_id, amount: ONE_UNIT }, + memo: None, + }; + let instruction_id = T::SettlementFn::transfer_funds( alice.origin.into(), - bob.account(), - asset_id, - ONE_UNIT, None, + to, + fund, &mut weight_meter, false, ).expect("Transfer setup must work"); @@ -864,4 +847,21 @@ benchmarks! { ) .expect("Receiver affirm must work"); } + + approve { + let caller = UserBuilder::::default().generate_did().build("Caller"); + let spender = UserBuilder::::default().generate_did().build("Spender"); + let asset_id = create_sample_asset::(&caller, true); + // Pre-insert an allowance to benchmark the overwrite path (worst case). + Allowances::::insert( + (&caller.account(), &spender.account(), asset_id), + 1000u128, + ); + }: _(RawOrigin::Signed(caller.account()), asset_id, spender.account(), 500u128) + verify { + assert_eq!( + Allowances::::get((&caller.account(), &spender.account(), asset_id)), + 500u128 + ); + } } diff --git a/pallets/asset/src/lib.rs b/pallets/asset/src/lib.rs index 68a71f6776..c63e3903ec 100644 --- a/pallets/asset/src/lib.rs +++ b/pallets/asset/src/lib.rs @@ -95,8 +95,8 @@ use frame_support::pallet_prelude::DispatchError; use frame_support::traits::{Currency, Get, UnixTime}; use frame_support::weights::Weight; use frame_support::BoundedBTreeSet; +use frame_system::ensure_root; use frame_system::pallet_prelude::*; -use frame_system::{ensure_root, ensure_signed}; use sp_io::hashing::blake2_128; use sp_runtime::traits::Zero; use sp_std::collections::btree_set::BTreeSet; @@ -117,6 +117,7 @@ use polymesh_primitives::asset_metadata::{ AssetMetadataSpec, AssetMetadataValue, AssetMetadataValueDetail, }; use polymesh_primitives::constants::*; +use polymesh_primitives::portfolio::{Fund, FundDescription}; use polymesh_primitives::protocol_fee::{ChargeProtocolFee, ProtocolOp}; use polymesh_primitives::settlement::InstructionId; use polymesh_primitives::traits::{ @@ -344,6 +345,13 @@ pub mod pallet { memo: Option, pending_transfer_id: Option, }, + /// A spender allowance was set for an asset. + Approval { + owner: T::AccountId, + spender: T::AccountId, + asset_id: AssetId, + amount: Balance, + }, } /// Map each [`Ticker`] to its registration details ([`TickerRegistration`]). @@ -593,6 +601,25 @@ pub mod pallet { ValueQuery, >; + /// Maps (owner, spender, asset_id) to the approved allowance amount. + /// + /// A non-existent entry returns 0 (via `ValueQuery`), matching ERC-20 behavior. + /// When an allowance is revoked (set to 0), the entry is removed to bound storage growth. + /// + /// Uses `StorageNMap` so that all allowances for a given owner can be iterated + /// via prefix. + #[pallet::storage] + pub type Allowances = StorageNMap< + _, + ( + NMapKey, + NMapKey, + NMapKey, + ), + Balance, + ValueQuery, + >; + /// Storage version. #[pallet::storage] pub type StorageVersion = StorageValue<_, Version, ValueQuery>; @@ -1638,7 +1665,7 @@ pub mod pallet { /// * `UnexpectedOFFChainAsset` - If the asset could not be found on-chain. /// * `MissingIdentity` - The caller doesn't have an identity. #[pallet::call_index(34)] - #[pallet::weight(::SettlementFn::transfer_and_try_execute_weight_meter(::WeightInfo::transfer_asset_base_weight(), true).limit())] + #[pallet::weight(::SettlementFn::transfer_funds_weight())] pub fn transfer_asset( origin: OriginFor, asset_id: AssetId, @@ -1718,6 +1745,50 @@ pub mod pallet { ) -> DispatchResultWithPostInfo { Self::base_reject_asset_transfer(origin, transfer_id) } + + /// Set the allowance for `spender` to transfer up to `amount` of `asset_id` from + /// the caller's account. + /// + /// Replaces any existing allowance for this (owner, spender, asset_id) combination. + /// Setting `amount` to 0 revokes the allowance (removes the storage entry). + /// Setting `amount` to `Balance::MAX` grants an unlimited allowance that is never + /// decremented on spend. + /// + /// # Arguments + /// * `origin` — Signed origin. Caller must have a registered DID. + /// * `asset_id` — The asset for which the allowance is set. + /// * `spender` — The AccountId authorized to spend. + /// * `amount` — Maximum amount the spender may transfer. 0 = revoke. Balance::MAX = unlimited. + /// + /// # Errors + /// * `BadOrigin` — Unsigned origin. + /// * `MissingIdentity` — Caller's key is not linked to a DID. + #[pallet::call_index(37)] + #[pallet::weight(::WeightInfo::approve())] + pub fn approve( + origin: OriginFor, + asset_id: AssetId, + spender: T::AccountId, + amount: Balance, + ) -> DispatchResult { + let caller_data = IdentityPallet::::ensure_origin_call_permissions(origin)?; + let owner = caller_data.sender; + + if amount == 0 { + Allowances::::remove((&owner, &spender, &asset_id)); + } else { + Allowances::::insert((&owner, &spender, &asset_id), amount); + } + + Self::deposit_event(Event::Approval { + owner, + spender, + asset_id, + amount, + }); + + Ok(()) + } } #[pallet::error] @@ -1826,6 +1897,8 @@ pub mod pallet { KeyNotFoundForDid, /// Insufficient tokens are locked. InsufficientTokensLocked, + /// The spender's allowance for this asset is insufficient for the requested transfer amount. + InsufficientAllowance, } pub trait WeightInfo { @@ -1863,8 +1936,8 @@ pub mod pallet { fn link_ticker_to_asset_id() -> Weight; fn unlink_ticker_from_asset_id() -> Weight; fn update_global_metadata_spec() -> Weight; - fn transfer_asset_base_weight() -> Weight; fn receiver_affirm_asset_transfer_base_weight() -> Weight; + fn approve() -> Weight; } } @@ -2599,6 +2672,34 @@ impl Pallet { Ok(()) } + /// Check and decrement spender allowance for a fungible transfer. + /// + /// - `Balance::MAX` (infinite allowance): no storage write. + /// - Depletes to zero: removes the storage entry. + /// - No `Approval` event is emitted on spend. + pub fn spend_allowance( + owner: &T::AccountId, + spender: &T::AccountId, + asset_id: AssetId, + amount: Balance, + ) -> DispatchResult { + let current = Allowances::::get((owner, spender, &asset_id)); + ensure!(current >= amount, Error::::InsufficientAllowance); + + // Infinite allowance — no deduction. + if current == Balance::MAX { + return Ok(()); + } + + let new_allowance = current.saturating_sub(amount); + if new_allowance == 0 { + Allowances::::remove((owner, spender, &asset_id)); + } else { + Allowances::::insert((owner, spender, &asset_id), new_allowance); + } + Ok(()) + } + pub fn base_link_ticker_to_asset_id( origin: T::RuntimeOrigin, ticker: Ticker, @@ -2712,25 +2813,28 @@ impl Pallet { memo: Option, #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, ) -> DispatchResultWithPostInfo { - let from = ensure_signed(origin.clone())?; - let mut weight_meter = ::SettlementFn::transfer_and_try_execute_weight_meter( - ::WeightInfo::transfer_asset_base_weight(), - true, - ); + let from = frame_system::ensure_signed(origin.clone())?; - // Create the transfer instruction via the settlement engine and affirm it as the sender. - let instruction_id = T::SettlementFn::transfer_asset_and_try_execute( + let to_holder = AssetHolder::try_from(to.encode()) + .map_err(|_| DispatchError::Other("InvalidAccountId"))?; + let fund = Fund { + description: FundDescription::Fungible { asset_id, amount }, + memo: memo.clone(), + }; + + let mut weight_meter = + WeightMeter::max_limit(::SettlementFn::transfer_funds_weight()); + + let instruction_id = T::SettlementFn::transfer_funds( origin, - to.clone(), - asset_id, - amount, - memo.clone(), + None, + to_holder, + fund, &mut weight_meter, #[cfg(feature = "runtime-benchmarks")] bench_base_weight, )?; - // Emit a transfer event. Self::deposit_event(Event::CreatedAssetTransfer { asset_id, from, diff --git a/pallets/nft/src/lib.rs b/pallets/nft/src/lib.rs index 0b74ac1be4..7686484976 100644 --- a/pallets/nft/src/lib.rs +++ b/pallets/nft/src/lib.rs @@ -19,8 +19,9 @@ use polymesh_primitives::nft::{ NFTCollection, NFTCollectionId, NFTCollectionKeys, NFTCount, NFTId, NFTMetadataAttribute, NFTOwnerStatus, NFTs, }; +use polymesh_primitives::portfolio::{Fund, FundDescription}; use polymesh_primitives::settlement::InstructionId; -use polymesh_primitives::traits::{ComplianceFnConfig, NFTTrait}; +use polymesh_primitives::traits::{ComplianceFnConfig, NFTTrait, SettlementFnTrait}; use polymesh_primitives::{ AccountId as AccountId32, HoldingsUpdateReason, IdentityId, Memo, PortfolioId, WeightMeter, }; @@ -297,6 +298,29 @@ pub mod pallet { ) -> DispatchResult { Self::base_controller_transfer(origin, nfts, source, callers_holdings_kind) } + + /// Transfer NFTs between accounts or portfolios. + /// + /// Same-identity transfers move NFTs directly. Cross-identity transfers + /// route through the settlement engine. + /// + /// # Arguments + /// * `origin` — Signed origin. Caller must have a registered DID. + /// * `nfts` — The NFTs to transfer. + /// * `from` — Source. `None` defaults to caller's account. + /// * `to` — Destination account or portfolio. + /// * `memo` — Optional memo attached to the transfer. + #[pallet::call_index(4)] + #[pallet::weight(::SettlementFn::transfer_funds_weight())] + pub fn transfer_nft( + origin: OriginFor, + nfts: NFTs, + from: Option, + to: AssetHolder, + memo: Option, + ) -> DispatchResultWithPostInfo { + Self::base_transfer_nft(origin, nfts, from, to, memo) + } } #[pallet::error] @@ -740,6 +764,35 @@ impl Pallet { Ok(()) } + pub fn base_transfer_nft( + origin: T::RuntimeOrigin, + nfts: NFTs, + from: Option, + to: AssetHolder, + memo: Option, + ) -> DispatchResultWithPostInfo { + let fund = Fund { + description: FundDescription::NonFungible(nfts), + memo, + }; + + let mut weight_meter = WeightMeter::max_limit( + ::SettlementFn::transfer_funds_weight(), + ); + + ::SettlementFn::transfer_funds( + origin, + from, + to, + fund, + &mut weight_meter, + #[cfg(feature = "runtime-benchmarks")] + false, + )?; + + Ok(PostDispatchInfo::from(Some(weight_meter.consumed()))) + } + pub fn base_controller_transfer( origin: T::RuntimeOrigin, nfts: NFTs, @@ -898,7 +951,7 @@ impl Pallet { sender: AssetHolder, receiver: AssetHolder, nfts: NFTs, - inst_id: InstructionId, + inst_id: Option, inst_memo: Option, caller_did: IdentityId, ) -> DispatchResult { @@ -915,7 +968,7 @@ impl Pallet { Some(sender), Some(receiver), HoldingsUpdateReason::Transferred { - instruction_id: Some(inst_id), + instruction_id: inst_id, instruction_memo: inst_memo, }, )); diff --git a/pallets/portfolio/src/lib.rs b/pallets/portfolio/src/lib.rs index 8649efde39..198f10d710 100644 --- a/pallets/portfolio/src/lib.rs +++ b/pallets/portfolio/src/lib.rs @@ -59,6 +59,7 @@ use pallet_identity::PermissionedCallOriginData; use polymesh_primitives::asset::AssetId; use polymesh_primitives::traits::{ AffirmationFnConfig, AffirmationFnTrait, AssetFnConfig, AssetFnTrait, NFTTrait, + PortfolioFnTrait, }; use polymesh_primitives::{ extract_auth, storage_migration_ver, Balance, Fund, FundDescription, IdentityId, Memo, NFTId, @@ -1205,3 +1206,12 @@ impl Pallet { Ok(()) } } + +impl PortfolioFnTrait for Pallet { + fn ensure_portfolio_custody( + portfolio: &PortfolioId, + custodian: IdentityId, + ) -> Result<(), DispatchError> { + Self::ensure_portfolio_custody(portfolio, custodian) + } +} diff --git a/pallets/runtime/common/src/runtime.rs b/pallets/runtime/common/src/runtime.rs index 12a5acbacf..312a003abb 100644 --- a/pallets/runtime/common/src/runtime.rs +++ b/pallets/runtime/common/src/runtime.rs @@ -555,6 +555,10 @@ macro_rules! misc_pallet_impls { type AffirmationFn = pallet_settlement::Pallet; } + impl polymesh_primitives::traits::PortfolioFnConfig for Runtime { + type PortfolioFn = pallet_portfolio::Pallet; + } + impl pallet_asset::checkpoint::Config for Runtime { type WeightInfo = polymesh_weights::pallet_checkpoint::SubstrateWeight; } @@ -1400,6 +1404,14 @@ macro_rules! runtime_apis { &mut weight_meter ) } + + fn allowance( + owner: polymesh_primitives::AccountId, + spender: polymesh_primitives::AccountId, + asset_id: AssetId, + ) -> Balance { + pallet_asset::Allowances::::get((&owner, &spender, asset_id)) + } } impl pallet_group_rpc_runtime_api::GroupApi for Runtime { diff --git a/pallets/runtime/tests/src/asset_pallet/allowances.rs b/pallets/runtime/tests/src/asset_pallet/allowances.rs new file mode 100644 index 0000000000..be8ec1599b --- /dev/null +++ b/pallets/runtime/tests/src/asset_pallet/allowances.rs @@ -0,0 +1,123 @@ +use frame_support::{assert_noop, assert_ok}; +use sp_keyring::Sr25519Keyring; + +use pallet_asset::Allowances; + +use super::setup::create_and_issue_sample_asset; +use crate::storage::{account_from, EventTest, User}; +use crate::{ExtBuilder, TestStorage}; + +type Asset = pallet_asset::Pallet; +type IdentityError = pallet_identity::Error; +type RuntimeOrigin = ::RuntimeOrigin; +type System = frame_system::Pallet; + +/// Basic approve stores allowance and emits Approval event. +#[test] +fn approve_stores_allowance_and_emits_event() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_sample_asset(&alice); + + System::set_block_number(1); + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 500 + ); + assert_eq!( + System::events().pop().unwrap().event, + EventTest::Asset(pallet_asset::Event::Approval { + owner: alice.acc(), + spender: bob.acc(), + asset_id, + amount: 500, + }) + ); + }); +} + +/// Second approve replaces (not sums) previous allowance. +#[test] +fn approve_overwrites_previous_allowance() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_sample_asset(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 200)); + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 200 + ); + }); +} + +/// Approve to 0 removes storage entry, emits Approval with amount 0. +#[test] +fn approve_zero_removes_entry() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_sample_asset(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + System::set_block_number(1); + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 0)); + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 0 + ); + assert!(!Allowances::::contains_key(( + &alice.acc(), + &bob.acc(), + asset_id + ))); + assert_eq!( + System::events().pop().unwrap().event, + EventTest::Asset(pallet_asset::Event::Approval { + owner: alice.acc(), + spender: bob.acc(), + asset_id, + amount: 0, + }) + ); + }); +} + +/// No DID rejects with MissingIdentity. +#[test] +fn approve_no_did_fails_missing_identity() { + ExtBuilder::default().build().execute_with(|| { + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_sample_asset(&bob); + let no_did_account = account_from(999); + + assert_noop!( + Asset::approve( + RuntimeOrigin::signed(no_did_account), + asset_id, + bob.acc(), + 500 + ), + IdentityError::MissingIdentity + ); + }); +} + +/// Allowance query for non-existent entry returns 0. +#[test] +fn allowance_query_nonexistent_returns_zero() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_sample_asset(&alice); + + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 0 + ); + }); +} diff --git a/pallets/runtime/tests/src/asset_pallet/mod.rs b/pallets/runtime/tests/src/asset_pallet/mod.rs index 4f53f14477..80322dc967 100644 --- a/pallets/runtime/tests/src/asset_pallet/mod.rs +++ b/pallets/runtime/tests/src/asset_pallet/mod.rs @@ -1,4 +1,5 @@ mod accept_ticker_transfer; +mod allowances; mod asset_ownership_transfer; mod asset_transfer; mod base_transfer; diff --git a/pallets/runtime/tests/src/settlement_pallet/mod.rs b/pallets/runtime/tests/src/settlement_pallet/mod.rs index a4130bde68..62c47270aa 100644 --- a/pallets/runtime/tests/src/settlement_pallet/mod.rs +++ b/pallets/runtime/tests/src/settlement_pallet/mod.rs @@ -3,4 +3,5 @@ pub(crate) mod lock_instruction; pub(crate) mod manual_execution; pub(crate) mod reject_instruction; pub(crate) mod setup; +pub(crate) mod transfer_funds; pub(crate) mod withdraw_affirmation; diff --git a/pallets/runtime/tests/src/settlement_pallet/setup.rs b/pallets/runtime/tests/src/settlement_pallet/setup.rs index a4677e9b73..da5042d382 100644 --- a/pallets/runtime/tests/src/settlement_pallet/setup.rs +++ b/pallets/runtime/tests/src/settlement_pallet/setup.rs @@ -66,7 +66,7 @@ pub fn add_and_affirm_simple_instruction( med_default_portfolio.clone().into(), sender_default_portfolio.clone().into(), NFTs::new_unverified(nft_asset_id, vec![NFTId(1)]), - InstructionId(0), + Some(InstructionId(0)), None, mediator.did, ) diff --git a/pallets/runtime/tests/src/settlement_pallet/transfer_funds.rs b/pallets/runtime/tests/src/settlement_pallet/transfer_funds.rs new file mode 100644 index 0000000000..560f7a8568 --- /dev/null +++ b/pallets/runtime/tests/src/settlement_pallet/transfer_funds.rs @@ -0,0 +1,518 @@ +use frame_support::{assert_noop, assert_ok}; +use sp_keyring::Sr25519Keyring; + +use pallet_asset::Allowances; +use polymesh_primitives::asset::{AssetHolder, AssetHolderKind, AssetType, NonFungibleType}; +use polymesh_primitives::nft::{NFTId, NFTOwnerStatus}; +use polymesh_primitives::{ + Balance, Fund, FundDescription, HoldingsUpdateReason, NFTs, PortfolioId, +}; + +use crate::asset_pallet::setup::ISSUE_AMOUNT; +use crate::storage::User; +use crate::{ExtBuilder, TestStorage}; + +type Asset = pallet_asset::Pallet; +type Nft = pallet_nft::Pallet; +type Settlement = pallet_settlement::Pallet; +type SettlementError = pallet_settlement::Error; +type AssetError = pallet_asset::Error; +type PortfolioError = pallet_portfolio::Error; + +/// Helper to issue tokens to an Account holder. +fn create_and_issue_to_account(owner: &User) -> polymesh_primitives::asset::AssetId { + use polymesh_primitives::AssetHolderKind; + + let asset_id = Asset::generate_asset_id(owner.acc(), false); + assert_ok!(Asset::create_asset( + owner.origin(), + b"MyAsset".into(), + true, + Default::default(), + Vec::new(), + None, + )); + assert_ok!(Asset::issue( + owner.origin(), + asset_id, + ISSUE_AMOUNT, + AssetHolderKind::Account, + )); + asset_id +} + +fn fungible_fund(asset_id: polymesh_primitives::asset::AssetId, amount: Balance) -> Fund { + Fund { + description: FundDescription::Fungible { asset_id, amount }, + memo: None, + } +} + +#[test] +fn same_identity_transfer_succeeds() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let asset_id = create_and_issue_to_account(&alice); + let from = AssetHolder::Account(alice.acc()); + let to = AssetHolder::Portfolio(PortfolioId::default_portfolio(alice.did)); + + frame_system::Pallet::::set_block_number(1); + + // from = None defaults to caller's account. + assert_ok!(Settlement::transfer_funds( + alice.origin(), + None, + to.clone(), + fungible_fund(asset_id, 100), + )); + + assert_eq!( + Asset::get_holders_balance(&from, &asset_id), + ISSUE_AMOUNT - 100 + ); + assert_eq!(Asset::get_holders_balance(&to, &asset_id), 100); + + // AssetBalanceUpdated emitted with instruction_id: None (direct transfer). + let events = frame_system::Pallet::::events(); + assert!(events.iter().any(|record| { + matches!( + &record.event, + crate::storage::EventTest::Asset(pallet_asset::Event::AssetBalanceUpdated( + _did, a_id, amount, Some(src), Some(dst), + HoldingsUpdateReason::Transferred { instruction_id: None, .. } + )) if *a_id == asset_id && *amount == 100 && *src == from && *dst == to + ) + })); + }); +} + +#[test] +fn cross_identity_transfer_creates_settlement() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_to_account(&alice); + + frame_system::Pallet::::set_block_number(1); + + assert_ok!(Settlement::transfer_funds( + alice.origin(), + None, + AssetHolder::Account(bob.acc()), + fungible_fund(asset_id, 100), + )); + + // Balance moved from alice to bob. + assert_eq!( + Asset::get_holders_balance(&AssetHolder::Account(alice.acc()), &asset_id), + ISSUE_AMOUNT - 100 + ); + assert_eq!( + Asset::get_holders_balance(&AssetHolder::Account(bob.acc()), &asset_id), + 100 + ); + + // Settlement instruction was created and executed. + let events = frame_system::Pallet::::events(); + assert!(events.iter().any(|record| { + matches!( + &record.event, + crate::storage::EventTest::Settlement( + pallet_settlement::Event::InstructionExecuted(_, _) + ) + ) + })); + }); +} + +#[test] +fn spender_same_identity_uses_direct_transfer() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_to_account(&alice); + + // Bob has allowance to spend from alice's account. + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + + // Transfer from alice's account to alice's default portfolio (same DID). + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Portfolio(PortfolioId::default_portfolio(alice.did)); + + assert_ok!(Settlement::transfer_funds( + bob.origin(), + from.clone(), + to.clone(), + fungible_fund(asset_id, 100), + )); + + // Allowance decremented. + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 400 + ); + // Balance moved within alice's identity. + assert_eq!( + Asset::get_holders_balance(&from.unwrap(), &asset_id), + ISSUE_AMOUNT - 100 + ); + assert_eq!(Asset::get_holders_balance(&to, &asset_id), 100); + }); +} + +#[test] +fn spender_as_receiver_affirms_both_sides() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_to_account(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + + // Bob spends alice's allowance to transfer to himself. + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Account(bob.acc()); + + assert_ok!(Settlement::transfer_funds( + bob.origin(), + from, + to, + fungible_fund(asset_id, 200), + )); + + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 300 + ); + assert_eq!( + Asset::get_holders_balance(&AssetHolder::Account(alice.acc()), &asset_id), + ISSUE_AMOUNT - 200 + ); + // Bob received the tokens (settlement executed immediately). + assert_eq!( + Asset::get_holders_balance(&AssetHolder::Account(bob.acc()), &asset_id), + 200 + ); + }); +} + +#[test] +fn spender_unlimited_allowance_not_decremented() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let charlie = User::new(Sr25519Keyring::Charlie); + let asset_id = create_and_issue_to_account(&alice); + + assert_ok!(Asset::approve( + alice.origin(), + asset_id, + bob.acc(), + Balance::MAX + )); + + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Account(charlie.acc()); + + assert_ok!(Settlement::transfer_funds( + bob.origin(), + from, + to, + fungible_fund(asset_id, 100), + )); + + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + Balance::MAX + ); + assert_eq!( + Asset::get_holders_balance(&AssetHolder::Account(charlie.acc()), &asset_id), + 100 + ); + }); +} + +#[test] +fn spender_insufficient_allowance_returns_error() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let charlie = User::new(Sr25519Keyring::Charlie); + let asset_id = create_and_issue_to_account(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 100)); + + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Account(charlie.acc()); + + assert_noop!( + Settlement::transfer_funds(bob.origin(), from, to, fungible_fund(asset_id, 150)), + AssetError::InsufficientAllowance + ); + }); +} + +#[test] +fn spender_zero_allowance_removes_entry() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let charlie = User::new(Sr25519Keyring::Charlie); + let asset_id = create_and_issue_to_account(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 200)); + + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Account(charlie.acc()); + + assert_ok!(Settlement::transfer_funds( + bob.origin(), + from, + to, + fungible_fund(asset_id, 200), + )); + + assert!(!Allowances::::contains_key(( + &alice.acc(), + &bob.acc(), + asset_id + ))); + }); +} + +#[test] +fn self_transfer_rejected() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let asset_id = create_and_issue_to_account(&alice); + let same_holder = AssetHolder::Account(alice.acc()); + + assert_noop!( + Settlement::transfer_funds( + alice.origin(), + Some(same_holder.clone()), + same_holder, + fungible_fund(asset_id, 100), + ), + SettlementError::SenderSameAsReceiver + ); + }); +} + +#[test] +fn spender_nft_rejected() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_to_account(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Account(bob.acc()); + let nft_fund = Fund { + description: FundDescription::NonFungible(NFTs::new_unverified( + asset_id, + vec![NFTId(1)], + )), + memo: None, + }; + + assert_noop!( + Settlement::transfer_funds(bob.origin(), from, to, nft_fund), + SettlementError::AllowancesNotSupportedForNFTs + ); + }); +} + +#[test] +fn spender_atomicity_failed_transfer_restores_allowance() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_to_account(&alice); + + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + assert_ok!(Asset::freeze(alice.origin(), asset_id)); + + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Portfolio(PortfolioId::default_portfolio(alice.did)); + + assert_noop!( + Settlement::transfer_funds(bob.origin(), from, to, fungible_fund(asset_id, 100)), + AssetError::InvalidTransferFrozenAsset + ); + + // Allowance unchanged — extrinsic rollback reverts the decrement. + assert_eq!( + Allowances::::get((&alice.acc(), &bob.acc(), asset_id)), + 500 + ); + }); +} + +#[test] +fn portfolio_custody_authorized_succeeds() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + + // Issue tokens to alice's default portfolio. + let asset_id = Asset::generate_asset_id(alice.acc(), false); + assert_ok!(Asset::create_asset( + alice.origin(), + b"MyAsset".into(), + true, + Default::default(), + Vec::new(), + None, + )); + assert_ok!(Asset::issue( + alice.origin(), + asset_id, + ISSUE_AMOUNT, + AssetHolderKind::DefaultPortfolio, + )); + + // Owner is default custodian — can transfer from own portfolio. + let from = Some(AssetHolder::Portfolio(PortfolioId::default_portfolio( + alice.did, + ))); + let to = AssetHolder::Account(alice.acc()); + + assert_ok!(Settlement::transfer_funds( + alice.origin(), + from, + to, + fungible_fund(asset_id, 100), + )); + }); +} + +#[test] +fn portfolio_custody_unauthorized_rejected() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let _asset_id = create_and_issue_to_account(&alice); + + // Bob tries to transfer from alice's default portfolio without custody. + let from = Some(AssetHolder::Portfolio(PortfolioId::default_portfolio( + alice.did, + ))); + let to = AssetHolder::Account(bob.acc()); + + assert_noop!( + Settlement::transfer_funds(bob.origin(), from, to, fungible_fund(_asset_id, 100)), + PortfolioError::UnauthorizedCustodian + ); + }); +} + +/// Helper to create an NFT collection and mint one NFT to an Account holder. +fn create_and_issue_nft_to_account(owner: &User) -> polymesh_primitives::asset::AssetId { + let asset_id = Asset::generate_asset_id(owner.acc(), false); + assert_ok!(Asset::create_asset( + owner.origin(), + b"MyNFTAsset".into(), + false, + AssetType::NonFungible(NonFungibleType::Derivative), + Vec::new(), + None, + )); + assert_ok!(Nft::create_nft_collection( + owner.origin(), + Some(asset_id), + None, + Vec::new().into(), + )); + assert_ok!(Nft::issue_nft( + owner.origin(), + asset_id, + Vec::new(), + AssetHolderKind::Account, + )); + asset_id +} + +fn nft_fund(asset_id: polymesh_primitives::asset::AssetId, nft_id: NFTId) -> Fund { + Fund { + description: FundDescription::NonFungible(NFTs::new_unverified(asset_id, vec![nft_id])), + memo: None, + } +} + +#[test] +fn nft_same_identity_transfer_succeeds() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let asset_id = create_and_issue_nft_to_account(&alice); + + let from = Some(AssetHolder::Account(alice.acc())); + let to = AssetHolder::Portfolio(PortfolioId::default_portfolio(alice.did)); + + assert_ok!(Settlement::transfer_funds( + alice.origin(), + from, + to, + nft_fund(asset_id, NFTId(1)), + )); + + // NFT moved to portfolio. + assert_eq!( + pallet_nft::NFTHolder::::get(&alice.acc(), (&asset_id, &NFTId(1))), + NFTOwnerStatus::NotOwned + ); + }); +} + +#[test] +fn nft_cross_identity_creates_settlement() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_nft_to_account(&alice); + + frame_system::Pallet::::set_block_number(1); + + assert_ok!(Settlement::transfer_funds( + alice.origin(), + None, + AssetHolder::Account(bob.acc()), + nft_fund(asset_id, NFTId(1)), + )); + + // Settlement instruction was created and executed. + let events = frame_system::Pallet::::events(); + assert!(events.iter().any(|record| { + matches!( + &record.event, + crate::storage::EventTest::Settlement( + pallet_settlement::Event::InstructionExecuted(_, _) + ) + ) + })); + }); +} + +#[test] +fn nft_spender_rejected() { + ExtBuilder::default().build().execute_with(|| { + let alice = User::new(Sr25519Keyring::Alice); + let bob = User::new(Sr25519Keyring::Bob); + let asset_id = create_and_issue_nft_to_account(&alice); + + // Approve bob as spender (fungible allowance). + assert_ok!(Asset::approve(alice.origin(), asset_id, bob.acc(), 500)); + + // Bob tries to transfer alice's NFT — rejected (allowances not supported for NFTs). + assert_noop!( + Settlement::transfer_funds( + bob.origin(), + Some(AssetHolder::Account(alice.acc())), + AssetHolder::Account(bob.acc()), + nft_fund(asset_id, NFTId(1)), + ), + SettlementError::AllowancesNotSupportedForNFTs + ); + }); +} diff --git a/pallets/settlement/src/benchmarking.rs b/pallets/settlement/src/benchmarking.rs index 2d94ba3301..3abd4ee9d4 100644 --- a/pallets/settlement/src/benchmarking.rs +++ b/pallets/settlement/src/benchmarking.rs @@ -28,6 +28,7 @@ use polymesh_primitives::checked_inc::CheckedInc; use polymesh_primitives::constants::currency::ONE_UNIT; use polymesh_primitives::constants::ENSURED_MAX_LEN; use polymesh_primitives::crypto::{ChainScopedMessage, SETTLEMENT_RECEIPT_LABEL}; +use polymesh_primitives::portfolio::{Fund, FundDescription}; use polymesh_primitives::settlement::{AffirmationRequirement, ReceiptMetadata}; use polymesh_primitives::{AssetHolder, IdentityId, Memo, NFTId, NFTs, Ticker, WeightMeter}; @@ -933,4 +934,94 @@ benchmarks! { set_mandatory_receiver_affirmation { let alice = UserBuilder::::default().generate_did().build("Alice"); }: _(alice.origin, AffirmationRequirement::Required) + + transfer_funds_portfolio_same_did { + let alice = UserBuilder::::default().generate_did().build("Alice"); + let bob = UserBuilder::::default().generate_did().build("Bob"); + + let (sender_holding, _receiver_holding, _, asset_id) = + setup_asset_transfer::(&alice, &bob, None, None, true, true, 0, true, false); + + let to = AssetHolder::from(polymesh_primitives::PortfolioId { + did: alice.did(), + kind: polymesh_primitives::PortfolioKind::Default, + }); + let fund = Fund { + description: FundDescription::Fungible { asset_id, amount: ONE_UNIT }, + memo: None, + }; + }: transfer_funds(alice.origin.clone(), Some(sender_holding), to.clone(), fund) + verify { + assert_eq!(pallet_asset::Pallet::::get_holders_balance(&to, &asset_id), ONE_UNIT); + } + + transfer_funds_portfolio_diff_did { + let alice = UserBuilder::::default().generate_did().build("Alice"); + let bob = UserBuilder::::default().generate_did().build("Bob"); + + let (sender_holding, receiver_holding, _, asset_id) = + setup_asset_transfer::(&alice, &bob, None, None, true, true, 0, true, false); + + let fund = Fund { + description: FundDescription::Fungible { asset_id, amount: ONE_UNIT }, + memo: None, + }; + }: transfer_funds(alice.origin.clone(), Some(sender_holding), receiver_holding, fund) + verify { + // Instruction created but not executed (receiver affirmation pending). + assert!(InstructionStatuses::::get(InstructionId(1)) == InstructionStatus::Pending); + } + + transfer_funds_account_same_did { + let alice = UserBuilder::::default().generate_did().build("Alice"); + let bob = UserBuilder::::default().generate_did().build("Bob"); + + let (_sender_holding, _receiver_holding, _, asset_id) = + setup_asset_transfer::(&alice, &bob, None, None, true, true, 0, false, true); + + let from = AssetHolder::try_from(alice.account().encode()).unwrap(); + let to = AssetHolder::from(polymesh_primitives::PortfolioId { + did: alice.did(), + kind: polymesh_primitives::PortfolioKind::Default, + }); + let fund = Fund { + description: FundDescription::Fungible { asset_id, amount: ONE_UNIT }, + memo: None, + }; + }: transfer_funds(alice.origin.clone(), Some(from), to.clone(), fund) + verify { + assert_eq!(pallet_asset::Pallet::::get_holders_balance(&to, &asset_id), ONE_UNIT); + } + + transfer_funds_account_diff_did { + let alice = UserBuilder::::default().generate_did().build("Alice"); + let bob = UserBuilder::::default().generate_did().build("Bob"); + let charlie = UserBuilder::::default().generate_did().build("Charlie"); + + let (_sender_holding, _receiver_holding, _, asset_id) = + setup_asset_transfer::(&alice, &charlie, None, None, true, true, 0, false, true); + + pallet_asset::Pallet::::approve( + alice.origin().into(), + asset_id, + bob.account(), + ONE_UNIT * 10, + ) + .unwrap(); + + let from = Some(AssetHolder::try_from(alice.account().encode()).unwrap()); + let to = AssetHolder::try_from(charlie.account().encode()).unwrap(); + let fund = Fund { + description: FundDescription::Fungible { asset_id, amount: ONE_UNIT }, + memo: None, + }; + }: transfer_funds(bob.origin.clone(), from, to, fund) + verify { + assert_eq!( + pallet_asset::Allowances::::get((&alice.account(), &bob.account(), asset_id)), + ONE_UNIT * 9 + ); + // Instruction created but not executed (receiver affirmation pending). + assert!(InstructionStatuses::::get(InstructionId(1)) == InstructionStatus::Pending); + } } diff --git a/pallets/settlement/src/lib.rs b/pallets/settlement/src/lib.rs index 13b2840d68..60e93fc4b2 100644 --- a/pallets/settlement/src/lib.rs +++ b/pallets/settlement/src/lib.rs @@ -81,11 +81,13 @@ use polymesh_primitives::settlement::{ InstructionStatus, Leg, LegId, LegStatus, MediatorAffirmationStatus, Receipt, ReceiptDetails, ReceiptMetadata, SettlementType, Venue, VenueDetails, VenueId, VenueType, }; -use polymesh_primitives::traits::{AffirmationFnTrait, AssetOrNft, SettlementFnTrait}; +use polymesh_primitives::traits::{ + AffirmationFnTrait, PortfolioFnConfig, PortfolioFnTrait, SettlementFnTrait, +}; use polymesh_primitives::with_transaction; use polymesh_primitives::SystematicIssuers::Settlement as SettlementDID; use polymesh_primitives::{ - AssetHolder, Balance, IdentityId, Memo, NFTs, SecondaryKey, WeightMeter, + AssetHolder, Balance, Fund, FundDescription, IdentityId, Memo, NFTs, SecondaryKey, WeightMeter, }; type System = frame_system::Pallet; @@ -220,6 +222,24 @@ pub mod pallet { fn execute_locked_instruction(f: u32, n: u32, o: u32) -> Weight; fn execute_manual_instruction_paused(f: u32, n: u32, o: u32) -> Weight; fn set_mandatory_receiver_affirmation() -> Weight; + /// Same-DID direct transfer between portfolios (custody check). + fn transfer_funds_portfolio_same_did() -> Weight; + /// Cross-DID settlement with portfolio holders (custody check, no execution). + fn transfer_funds_portfolio_diff_did() -> Weight; + /// Same-DID direct transfer between accounts. + fn transfer_funds_account_same_did() -> Weight; + /// Cross-DID settlement with account holders (no execution). + fn transfer_funds_account_diff_did() -> Weight; + + /// Worst-case weight across all `transfer_funds` paths, including try-execution. + fn transfer_funds() -> Weight { + let worst_path = Self::transfer_funds_portfolio_same_did() + .max(Self::transfer_funds_portfolio_diff_did()) + .max(Self::transfer_funds_account_same_did()) + .max(Self::transfer_funds_account_diff_did()); + // Cross-DID paths may also execute the instruction immediately. + worst_path.saturating_add(Self::execute_manual_instruction(1, 0, 0)) + } fn add_and_affirm_with_mediators_legs( legs: &[Leg], @@ -422,6 +442,7 @@ pub mod pallet { + pallet_permissions::Config + pallet_nft::Config + pallet_timestamp::Config + + PortfolioFnConfig { /// A call type used by the scheduler. type SchedulerCall: From> @@ -585,6 +606,10 @@ pub mod pallet { InvalidTaskName, /// The receipt has expired and can no longer be claimed. ReceiptExpired, + /// Source and destination are the exact same AssetHolder. + SenderSameAsReceiver, + /// Spender allowances are not supported for non-fungible token transfers. + AllowancesNotSupportedForNFTs, } const STORAGE_VERSION: StorageVersion = StorageVersion::new(4); @@ -1530,10 +1555,173 @@ pub mod pallet { )); Ok(()) } + + /// Transfer assets between accounts and portfolios. + /// + /// Currently supports two modes: + /// - Direct (owner, same-identity): `from` and `to` resolve to the same DID. + /// Transfers immediately via `base_transfer` — no settlement instruction, no affirmation. + /// - Direct (spender): Caller differs from source owner. Spender-approval mode. + /// Allowance is checked and decremented. Spender mode is only available for + /// `AssetHolder::Account` sources with fungible funds. + /// + /// When `from` is `None`, defaults to `AssetHolder::Account(caller)`. + /// + /// # Spender-mode allowance behavior + /// - Finite allowance: decremented by transfer amount. Removed when depleted to zero. + /// - Unlimited allowance (`Balance::MAX`): never decremented, no storage write. + /// - No `Approval` event emitted on spend. Use the `allowance` Runtime API to query + /// remaining allowance. + /// + /// # Arguments + /// * `origin` — Signed origin. Caller must have a registered DID. + /// * `from` — Source. `None` defaults to caller's account. When set to a different + /// account, spender-approval mode activates. + /// * `to` — Destination account or portfolio. + /// * `fund` — Asset and amount (fungible) or NFT IDs (non-fungible), plus optional memo. + #[pallet::call_index(26)] + #[pallet::weight(::WeightInfo::transfer_funds())] + pub fn transfer_funds( + origin: OriginFor, + from: Option, + to: AssetHolder, + fund: Fund, + ) -> DispatchResultWithPostInfo { + let mut weight_meter = + WeightMeter::max_limit(::WeightInfo::transfer_funds()); + + Self::base_transfer_funds( + origin, + from, + to, + fund, + &mut weight_meter, + #[cfg(feature = "runtime-benchmarks")] + false, + )?; + Ok(PostDispatchInfo::from(Some(weight_meter.consumed()))) + } } } impl Pallet { + fn base_transfer_funds( + origin: T::RuntimeOrigin, + from: Option, + to: AssetHolder, + fund: Fund, + weight_meter: &mut WeightMeter, + #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, + ) -> Result, DispatchError> { + let origin_data = + pallet_identity::Pallet::::ensure_origin_call_permissions(origin.clone())?; + let origin_did = origin_data.primary_did; + + // Resolve source: None defaults to caller's account. + let resolved_from = match from { + Some(holder) => holder, + None => AssetHolder::try_from(origin_data.sender.encode()) + .map_err(|_| Error::::InvalidAccountId)?, + }; + + // Self-transfer guard. + ensure!(resolved_from != to, Error::::SenderSameAsReceiver); + + // Resolve DIDs and determine transfer path. + let from_did = pallet_identity::Pallet::::asset_holder_did(&resolved_from)?; + let to_did = pallet_identity::Pallet::::asset_holder_did(&to)?; + + let same_did = from_did == to_did; + let is_portfolio = matches!(resolved_from, AssetHolder::Portfolio(_)); + let _ = + weight_meter.check_accrue(Self::transfer_funds_actual_weight(is_portfolio, same_did)); + + let instruction_id = if same_did { + // Same-identity: authorize and transfer directly. + Self::ensure_transfer_source_authorized(&resolved_from, &origin_data, &fund)?; + match fund.description { + FundDescription::Fungible { asset_id, amount } => { + Asset::::base_transfer( + resolved_from, + to, + asset_id, + amount, + None, + fund.memo, + origin_did, + weight_meter, + )?; + } + FundDescription::NonFungible(nfts) => { + Nft::::simplified_nft_transfer( + resolved_from, + to, + nfts, + None, + fund.memo, + origin_did, + )?; + } + } + None + } else { + // Cross-identity: authorize and create settlement instruction. + Self::base_transfer_and_try_execute( + origin, + &origin_data, + resolved_from, + from_did, + to, + to_did, + fund, + weight_meter, + #[cfg(feature = "runtime-benchmarks")] + bench_base_weight, + )? + }; + + Ok(instruction_id) + } + + /// Authorize the transfer source. + /// + /// - Account source where caller != owner: checks and decrements spender allowance. + /// - Portfolio source: checks custody. + /// Authorize the transfer source. + /// + /// - Account source where caller != owner: checks and decrements spender allowance. + /// - Portfolio source: checks custody. + fn ensure_transfer_source_authorized( + resolved_from: &AssetHolder, + caller_data: &pallet_identity::PermissionedCallOriginData, + fund: &Fund, + ) -> DispatchResult { + match resolved_from { + AssetHolder::Account(ref owner) => { + let owner_acc = pallet_base::pallet_account_id::(owner)?; + if owner_acc != caller_data.sender { + match &fund.description { + FundDescription::Fungible { asset_id, amount } => { + Asset::::spend_allowance( + &owner_acc, + &caller_data.sender, + *asset_id, + *amount, + )?; + } + FundDescription::NonFungible(_) => { + return Err(Error::::AllowancesNotSupportedForNFTs.into()); + } + } + } + } + AssetHolder::Portfolio(ref portfolio_id) => { + T::PortfolioFn::ensure_portfolio_custody(portfolio_id, caller_data.primary_did)?; + } + } + Ok(()) + } + fn lock_asset(leg: &Leg) -> DispatchResult { match leg { Leg::Fungible { @@ -3439,7 +3627,7 @@ impl Pallet { sender, receiver, nfts, - inst_id, + Some(inst_id), inst_memo.clone(), caller_did, )?; @@ -3529,6 +3717,15 @@ impl Pallet { ::WeightInfo::withdraw_affirmation_input(Some(affirmation_count), 0) } + fn transfer_funds_actual_weight(is_portfolio: bool, same_did: bool) -> Weight { + match (is_portfolio, same_did) { + (true, true) => ::WeightInfo::transfer_funds_portfolio_same_did(), + (true, false) => ::WeightInfo::transfer_funds_portfolio_diff_did(), + (false, true) => ::WeightInfo::transfer_funds_account_same_did(), + (false, false) => ::WeightInfo::transfer_funds_account_diff_did(), + } + } + /// Returns the miminum weight for calling the `reject_instruction` extrinsic. fn reject_instruction_minimum_weight() -> Weight { ::WeightInfo::base_reject_instruction(0, 0, 1) @@ -3749,37 +3946,42 @@ impl Pallet { /// Initiates a transfer instruction for fungible or non-fungible assets. fn base_transfer_and_try_execute( origin: OriginFor, - to: T::AccountId, - asset_or_nft: AssetOrNft, - memo: Option, + origin_data: &pallet_identity::PermissionedCallOriginData, + from: AssetHolder, + from_did: IdentityId, + to: AssetHolder, + to_did: IdentityId, + fund: Fund, weight_meter: &mut WeightMeter, #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, ) -> Result, DispatchError> { - let origin_data = - pallet_identity::Pallet::::ensure_origin_call_permissions(origin.clone())?; + let origin_did = origin_data.primary_did; - let from = AssetHolder::try_from(origin_data.sender.encode())?; - let to = AssetHolder::try_from(to.encode())?; + // Authorize: spender allowance (account) or custody (portfolio). + Self::ensure_transfer_source_authorized(&from, origin_data, &fund)?; // Prepare the leg depending on whether it's a fungible or non-fungible transfer - let (leg, is_fungible) = match asset_or_nft { - AssetOrNft::Asset { asset_id, amount } => ( + let (leg, is_fungible) = match &fund.description { + FundDescription::Fungible { asset_id, amount } => ( Leg::Fungible { sender: from.clone(), - receiver: to, - asset_id, - amount, + receiver: to.clone(), + asset_id: *asset_id, + amount: *amount, }, true, ), - AssetOrNft::Nft { asset_id, nft_id } => ( - Leg::NonFungible { - sender: from.clone(), - receiver: to, - nfts: NFTs::new_unverified(asset_id, vec![nft_id]), - }, - false, - ), + FundDescription::NonFungible(nfts) => { + Nft::::ensure_within_nfts_transfer_limits(nfts)?; + ( + Leg::NonFungible { + sender: from.clone(), + receiver: to.clone(), + nfts: NFTs::new_unverified(*nfts.asset_id(), vec![nfts.ids()[0]]), + }, + false, + ) + } }; // Consume weight for the transfer and affirmation @@ -3794,24 +3996,37 @@ impl Pallet { // Create the instruction with the prepared leg let instruction_id = Self::base_add_instruction( - origin_data.primary_did, + from_did, None, SettlementType::SettleManual(System::::block_number()), None, None, vec![leg], - memo, + fund.memo, None, )?; + // In spender mode (sender != caller), pass None to skip the caller's secondary key + // check and instead verify the sender's account via their DID's primary key. + let sender_sk = if from_did == origin_did { + origin_data.secondary_key.as_ref() + } else { + None + }; + // Affirm the instruction on behalf of the sender. - Self::unsafe_affirm_instruction( - origin_data.primary_did, - instruction_id, - [from].into(), - origin_data.secondary_key.as_ref(), - None, - )?; + Self::unsafe_affirm_instruction(from_did, instruction_id, [from].into(), sender_sk, None)?; + + // Try affirming if caller is the receiver (spender mode) and receiver affirmation is needed. + if to_did == origin_did && InstructionAffirmsPending::::get(instruction_id) > 0 { + Self::unsafe_affirm_instruction( + origin_did, + instruction_id, + [to].into(), + origin_data.secondary_key.as_ref(), + None, + )?; + } let instruction_id = if InstructionAffirmsPending::::get(instruction_id) == 0 { // If there are no pending affirmations, execute the instruction immediately. @@ -3879,32 +4094,6 @@ impl Pallet { } impl SettlementFnTrait for Pallet { - /// Initiates a transfer instruction for fungible or non-fungible assets. - fn transfer_and_try_execute( - origin: OriginFor, - to: T::AccountId, - asset_or_nft: AssetOrNft, - memo: Option, - weight_meter: &mut WeightMeter, - #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, - ) -> Result, DispatchErrorWithPostInfo> { - let instruction_id = Self::base_transfer_and_try_execute( - origin, - to, - asset_or_nft, - memo, - weight_meter, - #[cfg(feature = "runtime-benchmarks")] - bench_base_weight, - ) - .map_err(|error| DispatchErrorWithPostInfo { - post_info: Some(weight_meter.consumed()).into(), - error, - })?; - - Ok(instruction_id) - } - /// Receiver affirms the transfer of fungible or non-fungible assets. fn receiver_affirm_transfer_and_try_execute( origin: OriginFor, @@ -3975,6 +4164,29 @@ impl SettlementFnTrait for Pallet { ::WeightInfo::reject_instruction(Some(asset_count)), ) } + + fn transfer_funds_weight() -> Weight { + ::WeightInfo::transfer_funds() + } + + fn transfer_funds( + origin: OriginFor, + from: Option, + to: AssetHolder, + fund: Fund, + weight_meter: &mut WeightMeter, + #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, + ) -> Result, DispatchError> { + Self::base_transfer_funds( + origin, + from, + to, + fund, + weight_meter, + #[cfg(feature = "runtime-benchmarks")] + bench_base_weight, + ) + } } impl AffirmationFnTrait for Pallet { diff --git a/pallets/weights/src/pallet_asset.rs b/pallets/weights/src/pallet_asset.rs index 92a52469aa..b43a6dde59 100644 --- a/pallets/weights/src/pallet_asset.rs +++ b/pallets/weights/src/pallet_asset.rs @@ -775,18 +775,6 @@ impl pallet_asset::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().reads(2)) .saturating_add(DbWeight::get().writes(1)) } - // Storage: Identity KeyRecords (r:2 w:0) - // Proof: Identity KeyRecords (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) - // Storage: Timestamp Now (r:1 w:0) - // Proof: Timestamp Now (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) - // Storage: CddServiceProviders ActiveMembers (r:1 w:0) - // Proof Skipped: CddServiceProviders ActiveMembers (max_values: Some(1), max_size: None, mode: Measured) - // Storage: Identity Claims (r:2 w:0) - // Proof Skipped: Identity Claims (max_values: None, max_size: None, mode: Measured) - fn transfer_asset_base_weight() -> Weight { - // Minimum execution time: 28_885 nanoseconds. - Weight::from_parts(30_178_000, 0).saturating_add(DbWeight::get().reads(6)) - } // Storage: Identity KeyRecords (r:1 w:0) // Proof: Identity KeyRecords (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) // Storage: Timestamp Now (r:1 w:0) @@ -799,4 +787,12 @@ impl pallet_asset::WeightInfo for SubstrateWeight { // Minimum execution time: 24_004 nanoseconds. Weight::from_parts(26_399_000, 0).saturating_add(DbWeight::get().reads(5)) } + // TODO: Replace with generated weights after benchmark run. + // Storage: Identity KeyRecords (r:1 w:0) + // Storage: Asset Allowances (r:0 w:1) + fn approve() -> Weight { + Weight::from_parts(50_000_000, 0) + .saturating_add(DbWeight::get().reads(1)) + .saturating_add(DbWeight::get().writes(1)) + } } diff --git a/pallets/weights/src/pallet_settlement.rs b/pallets/weights/src/pallet_settlement.rs index 637d738e36..9126a1760d 100644 --- a/pallets/weights/src/pallet_settlement.rs +++ b/pallets/weights/src/pallet_settlement.rs @@ -1333,7 +1333,6 @@ impl pallet_settlement::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().writes((17_u64).saturating_mul(n.into()))) .saturating_add(DbWeight::get().writes((3_u64).saturating_mul(o.into()))) } - // Storage: Identity KeyRecords (r:1 w:0) // Storage: Settlement MandatoryReceiverAffirmation (r:0 w:1) fn set_mandatory_receiver_affirmation() -> Weight { @@ -1342,4 +1341,28 @@ impl pallet_settlement::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().reads(1)) .saturating_add(DbWeight::get().writes(1)) } + fn transfer_funds_portfolio_same_did() -> Weight { + // Minimum execution time: 141_000_000 picoseconds. + Weight::from_parts(142_000_000, 0) + .saturating_add(DbWeight::get().reads(15)) + .saturating_add(DbWeight::get().writes(4)) + } + fn transfer_funds_portfolio_diff_did() -> Weight { + // Minimum execution time: 122_000_000 picoseconds. + Weight::from_parts(131_000_000, 0) + .saturating_add(DbWeight::get().reads(19)) + .saturating_add(DbWeight::get().writes(10)) + } + fn transfer_funds_account_same_did() -> Weight { + // Minimum execution time: 140_000_000 picoseconds. + Weight::from_parts(140_000_000, 0) + .saturating_add(DbWeight::get().reads(14)) + .saturating_add(DbWeight::get().writes(4)) + } + fn transfer_funds_account_diff_did() -> Weight { + // Minimum execution time: 133_000_000 picoseconds. + Weight::from_parts(139_000_000, 0) + .saturating_add(DbWeight::get().reads(17)) + .saturating_add(DbWeight::get().writes(11)) + } } diff --git a/primitives/src/traits.rs b/primitives/src/traits.rs index 0249c64dfe..65c6ba1313 100644 --- a/primitives/src/traits.rs +++ b/primitives/src/traits.rs @@ -83,6 +83,20 @@ pub trait SubsidiserTrait { ) -> Result, InvalidTransaction>; } +pub trait PortfolioFnTrait { + /// Returns `Ok(())` if `custodian` has custody over the portfolio. + /// The portfolio owner is the default custodian when none is assigned. + fn ensure_portfolio_custody( + portfolio: &PortfolioId, + custodian: IdentityId, + ) -> Result<(), DispatchError>; +} + +/// Supertrait config for pallets that need portfolio custody queries. +pub trait PortfolioFnConfig: frame_system::Config { + type PortfolioFn: PortfolioFnTrait; +} + pub trait ComplianceFnConfig { /// Returns `true` if there are no requirements or if any requirement is satisfied. /// Otherwise, returns `false`. diff --git a/primitives/src/traits/settlement.rs b/primitives/src/traits/settlement.rs index 02a901e327..bb1ee1b278 100644 --- a/primitives/src/traits/settlement.rs +++ b/primitives/src/traits/settlement.rs @@ -1,11 +1,12 @@ -use frame_support::dispatch::{DispatchErrorWithPostInfo, DispatchResultWithPostInfo}; +use frame_support::dispatch::DispatchResultWithPostInfo; +use frame_support::pallet_prelude::DispatchError; use frame_support::weights::Weight; use frame_system::{pallet_prelude::OriginFor, Config}; #[cfg(feature = "runtime-benchmarks")] use crate::settlement::AffirmationRequirement; use crate::{ - asset::AssetId, settlement::InstructionId, Balance, IdentityId, Memo, NFTId, WeightMeter, + asset::AssetHolder, portfolio::Fund, settlement::InstructionId, IdentityId, WeightMeter, }; /// Trait for querying affirmation settings stored in the settlement pallet. @@ -23,67 +24,8 @@ pub trait AffirmationFnConfig: frame_system::Config { type AffirmationFn: AffirmationFnTrait; } -/// Enum representing either a fungible asset or a non-fungible token. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum AssetOrNft { - Asset { asset_id: AssetId, amount: Balance }, - Nft { asset_id: AssetId, nft_id: NFTId }, -} - /// Trait defining settlement functions for transferring assets. pub trait SettlementFnTrait { - /// Creates a transfer instruction for a fungible asset and attempts to execute it (if no pending affirmations). - fn transfer_asset_and_try_execute( - origin: OriginFor, - to: T::AccountId, - asset_id: AssetId, - amount: Balance, - memo: Option, - weight_meter: &mut WeightMeter, - #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, - ) -> Result, DispatchErrorWithPostInfo> { - Self::transfer_and_try_execute( - origin, - to, - AssetOrNft::Asset { asset_id, amount }, - memo, - weight_meter, - #[cfg(feature = "runtime-benchmarks")] - bench_base_weight, - ) - } - - /// Creates a transfer instruction for a non-fungible asset and attempts to execute it (if no pending affirmations). - fn transfer_nft_and_try_execute( - origin: OriginFor, - to: T::AccountId, - asset_id: AssetId, - nft_id: NFTId, - memo: Option, - weight_meter: &mut WeightMeter, - #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, - ) -> Result, DispatchErrorWithPostInfo> { - Self::transfer_and_try_execute( - origin, - to, - AssetOrNft::Nft { asset_id, nft_id }, - memo, - weight_meter, - #[cfg(feature = "runtime-benchmarks")] - bench_base_weight, - ) - } - - /// Creates a transfer instruction for fungible or non-fungible assets and attempts to execute it (if no pending affirmations). - fn transfer_and_try_execute( - origin: OriginFor, - to: T::AccountId, - asset_or_nft: AssetOrNft, - memo: Option, - weight_meter: &mut WeightMeter, - #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, - ) -> Result, DispatchErrorWithPostInfo>; - /// Receiver affirms the transfer of fungible or non-fungible assets and attempts to execute it. fn receiver_affirm_transfer_and_try_execute( origin: OriginFor, @@ -107,13 +49,6 @@ pub trait SettlementFnTrait { /// Get the try execute weight based on the type of asset. fn try_execute_weight(is_fungible: bool) -> Weight; - /// Get the transfer and try execute weight based on the type of asset. - fn transfer_and_try_execute_weight_meter(base: Weight, is_fungible: bool) -> WeightMeter { - let minimum_charge = Self::transfer_weight(is_fungible).saturating_add(base); - let limit = minimum_charge.saturating_add(Self::try_execute_weight(is_fungible)); - WeightMeter::from_limit_unchecked(minimum_charge, limit) - } - /// Get the receiver affirm transfer weight based on the type of asset. fn receiver_affirm_transfer_weight(is_fungible: bool) -> Weight; @@ -130,4 +65,18 @@ pub trait SettlementFnTrait { /// Get the reject transfer weight meter. fn reject_transfer_weight_meter(is_fungible: bool) -> WeightMeter; + + /// Get the worst-case weight for `transfer_funds` / `base_transfer_funds`. + fn transfer_funds_weight() -> Weight; + + /// Routes a transfer: same-identity direct, cross-identity settlement. + /// Returns the settlement instruction ID (None for same-identity). + fn transfer_funds( + origin: OriginFor, + from: Option, + to: AssetHolder, + fund: Fund, + weight_meter: &mut WeightMeter, + #[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool, + ) -> Result, DispatchError>; } diff --git a/rpc/runtime-api/src/asset.rs b/rpc/runtime-api/src/asset.rs index eefd42a591..90113c4b0c 100644 --- a/rpc/runtime-api/src/asset.rs +++ b/rpc/runtime-api/src/asset.rs @@ -18,7 +18,7 @@ use frame_support::pallet_prelude::DispatchError; use sp_std::vec::Vec; use polymesh_primitives::asset::AssetId; -use polymesh_primitives::{AssetHolder, Balance, PortfolioId}; +use polymesh_primitives::{AccountId, AssetHolder, Balance, PortfolioId}; /// The maximum number of DIDs allowed in a `balance_at` RPC query. pub const MAX_BALANCE_AT_QUERY_SIZE: usize = 100; @@ -46,5 +46,14 @@ sp_api::decl_runtime_apis! { transfer_value: Balance, skip_locked_check: bool, ) -> Vec; + + /// Returns the remaining allowance that `spender` can transfer from `owner` + /// for the given `asset_id`. Returns 0 if no allowance exists. + /// `Balance::MAX` (u128::MAX) indicates an unlimited allowance. + fn allowance( + owner: AccountId, + spender: AccountId, + asset_id: AssetId, + ) -> Balance; } }