Skip to content
45 changes: 20 additions & 25 deletions pallets/asset/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use polymesh_primitives::bench::reg_unique_ticker;
use polymesh_primitives::constants::currency::{ONE_UNIT, POLY};
use polymesh_primitives::settlement::AffirmationRequirement;
use polymesh_primitives::ticker::TICKER_LEN;
use polymesh_primitives::traits::{ComplianceFnConfig, NFTTrait};
use polymesh_primitives::traits::{AssetOrNft, ComplianceFnConfig, NFTTrait};
use polymesh_primitives::{
AuthorizationData, Fund, FundDescription, IdentityId, NFTCollectionKeys, PortfolioId,
PortfolioKind, PortfolioName, PortfolioNumber, Signatory, Ticker, Url, WeightMeter,
Expand Down 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,11 +824,10 @@ 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 instruction_id = T::SettlementFn::transfer_and_try_execute(
alice.origin.into(),
bob.account(),
asset_id,
ONE_UNIT,
AssetOrNft::Asset { asset_id, amount: ONE_UNIT },
None,
&mut weight_meter,
false,
Expand All @@ -864,4 +842,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
);
}
}
121 changes: 106 additions & 15 deletions pallets/asset/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,14 @@ 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.
#[pallet::storage]
pub type Allowances<T: Config> =
StorageMap<_, Blake2_128Concat, (T::AccountId, T::AccountId, AssetId), Balance, ValueQuery>;
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.

This should be a double map, so that the owner can easily query all current allowances.

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.

Could even use a NMap.


/// Storage version.
#[pallet::storage]
pub type StorageVersion<T: Config> = StorageValue<_, Version, ValueQuery>;
Expand Down Expand Up @@ -1638,7 +1654,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 +1734,51 @@ 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;

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

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

Ok(())
}
}

#[pallet::error]
Expand Down Expand Up @@ -1826,6 +1887,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 +1926,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 +2662,35 @@ 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 key = (owner.clone(), spender.clone(), asset_id);
let current = Allowances::<T>::get(&key);
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(&key);
} else {
Allowances::<T>::insert(&key, new_allowance);
}
Ok(())
}

pub fn base_link_ticker_to_asset_id(
origin: T::RuntimeOrigin,
ticker: Ticker,
Expand Down Expand Up @@ -2713,24 +2805,23 @@ impl<T: AssetConfig> Pallet<T> {
#[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,
);

// Create the transfer instruction via the settlement engine and affirm it as the sender.
let instruction_id = T::SettlementFn::transfer_asset_and_try_execute(
Comment on lines -2716 to -2722
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.

Since this method emits an event, we should still create the weight meter here. Also since this code path only uses account holder (no portfolios), it's worse-case weight should be lower.

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 (instruction_id, consumed) = T::SettlementFn::transfer_funds(
origin,
to.clone(),
asset_id,
amount,
memo.clone(),
&mut weight_meter,
None,
to_holder,
fund,
#[cfg(feature = "runtime-benchmarks")]
bench_base_weight,
)?;

// Emit a transfer event.
Self::deposit_event(Event::CreatedAssetTransfer {
asset_id,
from,
Expand All @@ -2740,7 +2831,7 @@ impl<T: AssetConfig> Pallet<T> {
pending_transfer_id: instruction_id,
});

Ok(PostDispatchInfo::from(Some(weight_meter.consumed())))
Ok(PostDispatchInfo::from(Some(consumed)))
}

pub fn base_receiver_affirm_asset_transfer(
Expand Down
57 changes: 54 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,33 @@ 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 _caller_data = IdentityPallet::<T>::ensure_origin_call_permissions(origin.clone())?;

let fund = Fund {
description: FundDescription::NonFungible(nfts),
memo,
};

let (_instruction_id, consumed) =
<T as pallet_asset::Config>::SettlementFn::transfer_funds(
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.

The call permission check here is a duplicate, since it is checked in transfer_funds we don't need it here.
The pallet/extrinsic names are set before the extrinsic is called, the permissions checks are not based on the fn name.

Suggested change
let _caller_data = IdentityPallet::<T>::ensure_origin_call_permissions(origin.clone())?;
let fund = Fund {
description: FundDescription::NonFungible(nfts),
memo,
};
let (_instruction_id, consumed) =
<T as pallet_asset::Config>::SettlementFn::transfer_funds(
origin,
let fund = Fund {
description: FundDescription::NonFungible(nfts),
memo,
};
let (_instruction_id, consumed) =
<T as pallet_asset::Config>::SettlementFn::transfer_funds(
origin,

from,
to,
fund,
#[cfg(feature = "runtime-benchmarks")]
false,
)?;

Ok(PostDispatchInfo::from(Some(consumed)))
}

pub fn base_controller_transfer(
origin: T::RuntimeOrigin,
nfts: NFTs,
Expand Down Expand Up @@ -898,7 +949,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 +966,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