Skip to content
17 changes: 17 additions & 0 deletions pallets/asset/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,4 +864,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
);
}
}
92 changes: 92 additions & 0 deletions pallets/asset/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,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 +600,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 @@ -1718,6 +1733,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 +1886,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 @@ -1865,6 +1927,7 @@ pub mod pallet {
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
8 changes: 8 additions & 0 deletions pallets/runtime/common/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,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::<Runtime>::get((&owner, &spender, &asset_id))
}
}

impl pallet_group_rpc_runtime_api::GroupApi<Block> for Runtime {
Expand Down
123 changes: 123 additions & 0 deletions pallets/runtime/tests/src/asset_pallet/allowances.rs
Original file line number Diff line number Diff line change
@@ -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<TestStorage>;
type IdentityError = pallet_identity::Error<TestStorage>;
type RuntimeOrigin = <TestStorage as frame_system::Config>::RuntimeOrigin;
type System = frame_system::Pallet<TestStorage>;

/// 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::<TestStorage>::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::<TestStorage>::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::<TestStorage>::get((&alice.acc(), &bob.acc(), &asset_id)),
0
);
assert!(!Allowances::<TestStorage>::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::<TestStorage>::get((&alice.acc(), &bob.acc(), &asset_id)),
0
);
});
}
1 change: 1 addition & 0 deletions pallets/runtime/tests/src/asset_pallet/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod accept_ticker_transfer;
mod allowances;
mod asset_ownership_transfer;
mod asset_transfer;
mod base_transfer;
Expand Down
1 change: 1 addition & 0 deletions pallets/runtime/tests/src/settlement_pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading