diff --git a/tokens/src/impls.rs b/tokens/src/impls.rs index 83a65ad60..15b98b6f7 100644 --- a/tokens/src/impls.rs +++ b/tokens/src/impls.rs @@ -5,7 +5,9 @@ use frame_support::traits::{ Contains, Get, }; use sp_arithmetic::{traits::Bounded, ArithmeticError}; +use sp_runtime::traits::Zero; use sp_runtime::DispatchError; +use sp_runtime::DispatchResult; pub struct Combiner(sp_std::marker::PhantomData<(AccountId, TestKey, A, B)>); @@ -350,3 +352,34 @@ where T::set_total_issuance(GetCurrencyId::get(), amount) } } + +impl fungibles::approvals::Inspect for crate::Pallet { + // Check the amount approved to be spent by an owner to a delegate + fn allowance(asset: T::CurrencyId, owner: &T::AccountId, delegate: &T::AccountId) -> T::Balance { + crate::Approvals::::get((asset, &owner, &delegate)) + .map(|x| x) + .unwrap_or_else(Zero::zero) + } +} + +impl fungibles::approvals::Mutate for crate::Pallet { + // Approve spending tokens from a given account + fn approve( + asset: T::CurrencyId, + owner: &T::AccountId, + delegate: &T::AccountId, + amount: T::Balance, + ) -> DispatchResult { + Self::do_approve(asset, owner, delegate, amount) + } + + fn transfer_from( + asset: T::CurrencyId, + owner: &T::AccountId, + delegate: &T::AccountId, + dest: &T::AccountId, + amount: T::Balance, + ) -> DispatchResult { + Self::do_transfer_from(asset, owner, delegate, dest, amount) + } +} diff --git a/tokens/src/lib.rs b/tokens/src/lib.rs index 314066671..37f8772ef 100644 --- a/tokens/src/lib.rs +++ b/tokens/src/lib.rs @@ -255,6 +255,7 @@ pub mod module { DeadAccount, // Number of named reserves exceed `T::MaxReserves` TooManyReserves, + Unapproved, } #[pallet::event] @@ -365,6 +366,23 @@ pub mod module { currency_id: T::CurrencyId, amount: T::Balance, }, + /// (Additional) funds have been approved for transfer to a destination + /// account. + ApprovedTransfer { + currency_id: T::CurrencyId, + source: T::AccountId, + delegate: T::AccountId, + amount: T::Balance, + }, + /// An `amount` was transferred in its entirety from `owner` to + /// `destination` by the approved `delegate`. + TransferredApproved { + currency_id: T::CurrencyId, + owner: T::AccountId, + delegate: T::AccountId, + destination: T::AccountId, + amount: T::Balance, + }, } /// The total issuance of a token type. @@ -417,6 +435,22 @@ pub mod module { ValueQuery, >; + #[pallet::storage] + /// Approved balance transfers. First balance is the amount approved for + /// transfer. Second is the amount of `T::Currency` reserved for storing + /// this. First key is the asset ID, second key is the owner and third key + /// is the delegate. + pub(super) type Approvals = StorageNMap< + _, + ( + NMapKey, + NMapKey, // owner + NMapKey, // delegate + ), + T::Balance, + OptionQuery, + >; + #[pallet::genesis_config] pub struct GenesisConfig { pub balances: Vec<(T::AccountId, T::CurrencyId, T::Balance)>, @@ -650,6 +684,74 @@ pub mod module { Ok(()) } + + /// Approve an amount of asset for transfer by a delegated third-party + /// account. + /// + /// Origin must be Signed. + /// + /// Ensures that `ApprovalDeposit` worth of `Currency` is reserved from + /// signing account for the purpose of holding the approval. If some + /// non-zero amount of assets is already approved from signing account + /// to `delegate`, then it is topped up or unreserved to + /// meet the right value. + /// + /// NOTE: The signing account does not need to own `amount` of assets at + /// the point of making this call. + /// + /// - `id`: The identifier of the asset. + /// - `delegate`: The account to delegate permission to transfer asset. + /// - `amount`: The amount of asset that may be transferred by + /// `delegate`. If there is + /// already an approval in place, then this acts additively. + /// + /// Emits `ApprovedTransfer` on success. + /// + /// Weight: `O(1)` + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::approve())] + pub fn approve( + origin: OriginFor, + currency_id: T::CurrencyId, + delegate: T::AccountId, + #[pallet::compact] amount: T::Balance, + ) -> DispatchResult { + let owner = ensure_signed(origin)?; + Self::do_approve(currency_id, &owner, &delegate, amount) + } + + /// Transfer some asset balance from a previously delegated account to + /// some third-party account. + /// + /// Origin must be Signed and there must be an approval in place by the + /// `owner` to the signer. + /// + /// If the entire amount approved for transfer is transferred, then any + /// deposit previously reserved by `approve` is unreserved. + /// + /// - `id`: The identifier of the asset. + /// - `owner`: The account which previously approved for a transfer of + /// at least `amount` and + /// from which the asset balance will be withdrawn. + /// - `destination`: The account to which the asset balance of `amount` + /// will be transferred. + /// - `amount`: The amount of assets to transfer. + /// + /// Emits `TransferredApproved` on success. + /// + /// Weight: `O(1)` + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::transfer_from())] + pub fn transfer_from( + origin: OriginFor, + currency_id: T::CurrencyId, + owner: T::AccountId, + destination: T::AccountId, + #[pallet::compact] amount: T::Balance, + ) -> DispatchResult { + let delegate = ensure_signed(origin)?; + Self::do_transfer_from(currency_id, &owner, &delegate, &destination, amount) + } } } @@ -1130,6 +1232,62 @@ impl Pallet { }); Ok(amount) } + + /// Creates an approval from `owner` to spend `amount` of asset `id` tokens + /// by 'delegate' while reserving `T::ApprovalDeposit` from owner + /// + /// If an approval already exists, the new amount is added to such existing + /// approval + pub(crate) fn do_approve( + id: T::CurrencyId, + owner: &T::AccountId, + delegate: &T::AccountId, + amount: T::Balance, + ) -> DispatchResult { + if amount == Default::default() { + Approvals::::remove((id.clone(), &owner, &delegate)); + } else { + Approvals::::set((id.clone(), &owner, &delegate), Some(amount)); + } + Self::deposit_event(Event::ApprovedTransfer { + currency_id: id, + source: owner.clone(), + delegate: delegate.clone(), + amount, + }); + + Ok(()) + } + + /// Reduces the asset `id` balance of `owner` by some `amount` and increases + /// the balance of `dest` by (similar) amount, checking that 'delegate' has + /// an existing approval from `owner` to spend`amount`. + /// + /// Will fail if `amount` is greater than the approval from `owner` to + /// 'delegate' Will unreserve the deposit from `owner` if the entire + /// approved `amount` is spent by 'delegate' + pub(crate) fn do_transfer_from( + id: T::CurrencyId, + owner: &T::AccountId, + delegate: &T::AccountId, + destination: &T::AccountId, + amount: T::Balance, + ) -> DispatchResult { + Approvals::::try_mutate_exists((id.clone(), &owner, delegate), |maybe_approved| -> DispatchResult { + let approved = maybe_approved.take().ok_or(Error::::Unapproved)?; + let remaining = approved.checked_sub(&amount).ok_or(Error::::Unapproved)?; + + Self::do_transfer(id, &owner, &destination, amount, ExistenceRequirement::AllowDeath)?; + + if remaining.is_zero() { + Approvals::::remove((id.clone(), &owner, &delegate)); + } else { + *maybe_approved = Some(remaining); + } + Ok(()) + })?; + Ok(()) + } } impl MultiCurrency for Pallet { diff --git a/tokens/src/tests.rs b/tokens/src/tests.rs index 458fe25e5..23077ee0e 100644 --- a/tokens/src/tests.rs +++ b/tokens/src/tests.rs @@ -3,6 +3,7 @@ #![cfg(test)] use super::*; +use frame_support::traits::fungibles::approvals::Inspect; use frame_support::{assert_noop, assert_ok}; use frame_system::RawOrigin; use mock::*; @@ -1269,3 +1270,82 @@ fn post_transfer_can_use_new_balance() { )); }); } + +#[test] +fn approval_lifecycle_works() { + ExtBuilder::default() + .balances(vec![(ALICE, DOT, 100)]) + .build() + .execute_with(|| { + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 50)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)).unwrap(), 50); + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 20)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)).unwrap(), 20); + + assert_ok!(Tokens::transfer_from(Some(BOB).into(), DOT, ALICE, CHARLIE, 10)); + assert_eq!(Tokens::free_balance(DOT, &ALICE), 90); + assert_eq!(Tokens::free_balance(DOT, &BOB), 0); + assert_eq!(Tokens::free_balance(DOT, &CHARLIE), 10); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)).unwrap(), 10); + + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 0)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)), None); + + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 0)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)), None); + + assert_noop!( + Tokens::transfer_from(Some(BOB).into(), DOT, ALICE, CHARLIE, 10), + Error::::Unapproved + ); + }); +} + +#[test] +fn transfer_from_all_funds() { + ExtBuilder::default() + .balances(vec![(ALICE, DOT, 100)]) + .build() + .execute_with(|| { + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 50)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)).unwrap(), 50); + assert_eq!(Tokens::allowance(DOT, &ALICE, &BOB), 50); + + // transfer the full amount, which should trigger auto-cleanup + assert_ok!(Tokens::transfer_from(Some(BOB).into(), DOT, ALICE, BOB, 50)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)), None); + + assert_eq!(Tokens::free_balance(DOT, &ALICE), 50); + assert_eq!(Tokens::free_balance(DOT, &BOB), 50); + }); +} + +#[test] +fn cannot_transfer_more_than_approved() { + ExtBuilder::default() + .balances(vec![(ALICE, DOT, 100)]) + .build() + .execute_with(|| { + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 50)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)).unwrap(), 50); + assert_noop!( + Tokens::transfer_from(Some(BOB).into(), DOT, ALICE, BOB, 51), + Error::::Unapproved + ); + }); +} + +#[test] +fn cannot_transfer_more_than_exists() { + ExtBuilder::default() + .balances(vec![(ALICE, DOT, 100)]) + .build() + .execute_with(|| { + assert_ok!(Tokens::approve(Some(ALICE).into(), DOT, BOB, 101)); + assert_eq!(Approvals::::get((DOT, ALICE, BOB)).unwrap(), 101); + assert_noop!( + Tokens::transfer_from(Some(BOB).into(), DOT, ALICE, BOB, 101), + Error::::BalanceTooLow + ); + }); +} diff --git a/tokens/src/weights.rs b/tokens/src/weights.rs index 8f5637160..1ed178a91 100644 --- a/tokens/src/weights.rs +++ b/tokens/src/weights.rs @@ -34,6 +34,8 @@ pub trait WeightInfo { fn transfer_keep_alive() -> Weight; fn force_transfer() -> Weight; fn set_balance() -> Weight; + fn approve() -> Weight; + fn transfer_from() -> Weight; } /// Default weights. @@ -63,4 +65,14 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(3 as u64)) .saturating_add(RocksDbWeight::get().writes(3 as u64)) } + fn approve() -> Weight { + Weight::from_parts(38_000_000, 0) + .saturating_add(RocksDbWeight::get().reads(5 as u64)) + .saturating_add(RocksDbWeight::get().writes(4 as u64)) + } + fn transfer_from() -> Weight { + Weight::from_parts(69_000_000, 0) + .saturating_add(RocksDbWeight::get().reads(5 as u64)) + .saturating_add(RocksDbWeight::get().writes(4 as u64)) + } }