Skip to content
50 changes: 25 additions & 25 deletions pallets/asset/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -814,27 +814,6 @@ benchmarks! {
.unwrap();
}: _(RawOrigin::Root, asset_metadata_name, asset_metadata_spec)

transfer_asset_base_weight {
let alice = UserBuilder::<T>::default().generate_did().build("Alice");
let bob = UserBuilder::<T>::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::<T>(&alice, &bob, None, None, true, true, 0, false, false);
}: {
Pallet::<T>::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::<T>::default().generate_did().build("Alice");
let bob = UserBuilder::<T>::default().generate_did().build("Bob");
Expand All @@ -845,12 +824,16 @@ benchmarks! {
setup_asset_transfer::<T>(&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");
Expand All @@ -864,4 +847,21 @@ benchmarks! {
)
.expect("Receiver affirm must work");
}

approve {
let caller = UserBuilder::<T>::default().generate_did().build("Caller");
let spender = UserBuilder::<T>::default().generate_did().build("Spender");
let asset_id = create_sample_asset::<T>(&caller, true);
// Pre-insert an allowance to benchmark the overwrite path (worst case).
Allowances::<T>::insert(
(&caller.account(), &spender.account(), asset_id),
1000u128,
);
}: _(RawOrigin::Signed(caller.account()), asset_id, spender.account(), 500u128)
verify {
assert_eq!(
Allowances::<T>::get((&caller.account(), &spender.account(), asset_id)),
500u128
);
}
}
134 changes: 119 additions & 15 deletions pallets/asset/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::{
Expand Down Expand Up @@ -344,6 +345,13 @@ pub mod pallet {
memo: Option<Memo>,
pending_transfer_id: Option<InstructionId>,
},
/// 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`]).
Expand Down Expand Up @@ -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<T: Config> = StorageNMap<
_,
(
NMapKey<Blake2_128Concat, T::AccountId>,
NMapKey<Blake2_128Concat, T::AccountId>,
NMapKey<Blake2_128Concat, AssetId>,
),
Balance,
ValueQuery,
>;

/// Storage version.
#[pallet::storage]
pub type StorageVersion<T: Config> = StorageValue<_, Version, ValueQuery>;
Expand Down Expand Up @@ -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(<T as Config>::SettlementFn::transfer_and_try_execute_weight_meter(<T as Config>::WeightInfo::transfer_asset_base_weight(), true).limit())]
#[pallet::weight(<T as Config>::SettlementFn::transfer_funds_weight())]
pub fn transfer_asset(
origin: OriginFor<T>,
asset_id: AssetId,
Expand Down Expand Up @@ -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(<T as Config>::WeightInfo::approve())]
pub fn approve(
origin: OriginFor<T>,
asset_id: AssetId,
spender: T::AccountId,
amount: Balance,
) -> DispatchResult {
let caller_data = IdentityPallet::<T>::ensure_origin_call_permissions(origin)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be doing call permissions here. When account keys hold the assets only that key has the authority over those assets.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on our discussion we will keep using call permissions here. Which means we should change Asset.transfer_asset to also use call permissions.

let owner = caller_data.sender;

if amount == 0 {
Allowances::<T>::remove((&owner, &spender, &asset_id));
} else {
Allowances::<T>::insert((&owner, &spender, &asset_id), amount);
}

Self::deposit_event(Event::Approval {
owner,
spender,
asset_id,
amount,
});

Ok(())
}
}

#[pallet::error]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -2599,6 +2672,34 @@ impl<T: AssetConfig> Pallet<T> {
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::<T>::get((owner, spender, &asset_id));
ensure!(current >= amount, Error::<T>::InsufficientAllowance);

// Infinite allowance — no deduction.
if current == Balance::MAX {
return Ok(());
}

let new_allowance = current.saturating_sub(amount);
if new_allowance == 0 {
Allowances::<T>::remove((owner, spender, &asset_id));
} else {
Allowances::<T>::insert((owner, spender, &asset_id), new_allowance);
}
Ok(())
}

pub fn base_link_ticker_to_asset_id(
origin: T::RuntimeOrigin,
ticker: Ticker,
Expand Down Expand Up @@ -2712,25 +2813,28 @@ impl<T: AssetConfig> Pallet<T> {
memo: Option<Memo>,
#[cfg(feature = "runtime-benchmarks")] bench_base_weight: bool,
) -> DispatchResultWithPostInfo {
let from = ensure_signed(origin.clone())?;
let mut weight_meter = <T as Config>::SettlementFn::transfer_and_try_execute_weight_meter(
<T as Config>::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(<T as Config>::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,
Expand Down
59 changes: 56 additions & 3 deletions pallets/nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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(<T as pallet_asset::Config>::SettlementFn::transfer_funds_weight())]
pub fn transfer_nft(
origin: OriginFor<T>,
nfts: NFTs,
from: Option<AssetHolder>,
to: AssetHolder,
memo: Option<Memo>,
) -> DispatchResultWithPostInfo {
Self::base_transfer_nft(origin, nfts, from, to, memo)
}
}

#[pallet::error]
Expand Down Expand Up @@ -740,6 +764,35 @@ impl<T: Config> Pallet<T> {
Ok(())
}

pub fn base_transfer_nft(
origin: T::RuntimeOrigin,
nfts: NFTs,
from: Option<AssetHolder>,
to: AssetHolder,
memo: Option<Memo>,
) -> DispatchResultWithPostInfo {
let fund = Fund {
description: FundDescription::NonFungible(nfts),
memo,
};

let mut weight_meter = WeightMeter::max_limit(
<T as pallet_asset::Config>::SettlementFn::transfer_funds_weight(),
);

<T as pallet_asset::Config>::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,
Expand Down Expand Up @@ -898,7 +951,7 @@ impl<T: Config> Pallet<T> {
sender: AssetHolder,
receiver: AssetHolder,
nfts: NFTs,
inst_id: InstructionId,
inst_id: Option<InstructionId>,
inst_memo: Option<Memo>,
caller_did: IdentityId,
) -> DispatchResult {
Expand All @@ -915,7 +968,7 @@ impl<T: Config> Pallet<T> {
Some(sender),
Some(receiver),
HoldingsUpdateReason::Transferred {
instruction_id: Some(inst_id),
instruction_id: inst_id,
instruction_memo: inst_memo,
},
));
Expand Down
10 changes: 10 additions & 0 deletions pallets/portfolio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1205,3 +1206,12 @@ impl<T: Config> Pallet<T> {
Ok(())
}
}

impl<T: Config> PortfolioFnTrait for Pallet<T> {
fn ensure_portfolio_custody(
portfolio: &PortfolioId,
custodian: IdentityId,
) -> Result<(), DispatchError> {
Self::ensure_portfolio_custody(portfolio, custodian)
}
}
Loading