--- a/Cargo.lock +++ b/Cargo.lock @@ -6108,7 +6108,7 @@ [[package]] name = "pallet-template-transaction-payment" version = "3.0.0" -source = "git+https://github.com/UniqueNetwork/pallet-sponsoring?branch=polkadot-v0.9.14#858001a6fd841614eb3bc47562e1a08685e6c8df" +source = "git+https://github.com/UniqueNetwork/pallet-sponsoring?branch=polkadot-v0.9.14#0f6291079a781cb9f8815efd5113a6a61059ce97" dependencies = [ "frame-benchmarking", "frame-support", @@ -6272,7 +6272,6 @@ "parity-scale-codec", "scale-info", "serde", - "sp-api", "sp-core", "sp-io", "sp-runtime", @@ -11313,7 +11312,7 @@ "chrono", "lazy_static", "matchers", - "parking_lot 0.11.2", + "parking_lot 0.10.2", "regex", "serde", "serde_json", @@ -11443,7 +11442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee73e6e4924fe940354b8d4d98cad5231175d615cd855b758adc658c0aac6a0" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 0.1.10", "rand 0.8.4", "static_assertions", ] @@ -11812,7 +11811,7 @@ [[package]] name = "up-sponsorship" version = "0.1.0" -source = "git+https://github.com/UniqueNetwork/pallet-sponsoring?branch=polkadot-v0.9.14#858001a6fd841614eb3bc47562e1a08685e6c8df" +source = "git+https://github.com/UniqueNetwork/pallet-sponsoring?branch=polkadot-v0.9.14#0f6291079a781cb9f8815efd5113a6a61059ce97" dependencies = [ "impl-trait-for-tuples", ] --- a/pallets/scheduler/Cargo.toml +++ b/pallets/scheduler/Cargo.toml @@ -20,11 +20,11 @@ sp-io = { default-features = false, git = 'https://github.com/paritytech/substrate.git', branch = 'polkadot-v0.9.14' } frame-benchmarking = { default-features = false, optional = true, git = 'https://github.com/paritytech/substrate.git', branch = 'polkadot-v0.9.14' } +sp-core = { default-features = false, git = 'https://github.com/paritytech/substrate.git', branch = 'polkadot-v0.9.14' } up-sponsorship = { version = "0.1.0", default-features = false, git = "https://github.com/UniqueNetwork/pallet-sponsoring", branch = 'polkadot-v0.9.14' } log = { version = "0.4.14", default-features = false } [dev-dependencies] -sp-core = { default-features = false, git = 'https://github.com/paritytech/substrate.git', branch = 'polkadot-v0.9.14' } substrate-test-utils = { git = 'https://github.com/paritytech/substrate.git', branch = 'polkadot-v0.9.14' } [features] @@ -38,6 +38,7 @@ "up-sponsorship/std", "sp-io/std", "sp-std/std", + "sp-core/std", "log/std", ] runtime-benchmarks = [ --- a/pallets/scheduler/src/lib.rs +++ b/pallets/scheduler/src/lib.rs @@ -60,6 +60,8 @@ use sp_runtime::{ RuntimeDebug, traits::{Zero, One, BadOrigin, Saturating}, + transaction_validity::TransactionValidityError, + DispatchErrorWithPostInfo, }; use frame_support::{ decl_module, decl_storage, decl_event, decl_error, @@ -69,23 +71,13 @@ schedule::{self, DispatchTime}, OriginTrait, EnsureOrigin, IsType, }, - weights::{GetDispatchInfo, Weight}, + weights::{GetDispatchInfo, Weight, PostDispatchInfo}, }; use frame_system::{self as system, ensure_signed}; pub use weights::WeightInfo; +use up_sponsorship::SponsorshipHandler; use scale_info::TypeInfo; -use sp_runtime::ApplyExtrinsicResult; -use sp_runtime::traits::{IdentifyAccount, Verify}; -use sp_runtime::{MultiSignature}; - -pub trait ApplyExtrinsic { - fn apply_extrinsic(signer: Address, function: C) -> ApplyExtrinsicResult; -} - -/// The address format for describing accounts. -pub type Signature = MultiSignature; -pub type Address = sp_runtime::MultiAddress; -pub type AccountId = <::Signer as IdentifyAccount>::AccountId; +use sp_core::H160; /// Our pallet's configuration trait. All our types and constants go in here. If the /// pallet is dependent on specific other pallets, then their configuration traits @@ -122,10 +114,38 @@ /// Not strictly enforced, but used for weight estimation. type MaxScheduledPerBlock: Get; + /// Sponsoring function. + type SponsorshipHandler: SponsorshipHandler::Call>; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; - type Executor: ApplyExtrinsic<::Call>; + /// The helper type used for custom transaction fee logic. + type CallExecutor: DispatchCall; +} + +/// A Scheduler-Runtime interface for finer payment handling. +pub trait DispatchCall { + /// The type that encodes information that can be passed from pre_dispatch to post-dispatch. + type Pre: Default + Codec + Clone + TypeInfo; + + /// Resolve the call dispatch, including any post-dispatch operations. + fn dispatch_call( + pre_dispatch: Self::Pre, + function: ::Call, + ) -> Result< + Result>, + TransactionValidityError, + >; + + /// Prepare for the scheduled call (e.g. by withdrawing the fee in advance). + fn pre_dispatch( + signer: T::AccountId, + function: ::Call, + ) -> Result; + + /// Perform a clean-up after a cancelled call (e.g. by returning the remaining fee). + fn cancel_dispatch(pre_dispatch: Self::Pre) -> Result<(), TransactionValidityError>; } pub const MAX_TASK_ID_LENGTH_IN_BYTES: u8 = 16; @@ -139,19 +159,19 @@ /// The location of a scheduled task that can be used to remove it. pub type TaskAddress = (BlockNumber, u32); -#[cfg_attr(any(feature = "std", test), derive(PartialEq, Eq))] +/*#[cfg_attr(any(feature = "std", test), derive(PartialEq, Eq))] todo remove completely? #[derive(Clone, RuntimeDebug, Encode, Decode)] struct ScheduledV1 { maybe_id: Option>, priority: schedule::Priority, call: Call, maybe_periodic: Option>, -} +}*/ /// Information regarding an item to be executed in the future. #[cfg_attr(any(feature = "std", test), derive(PartialEq, Eq))] #[derive(Clone, RuntimeDebug, Encode, Decode, TypeInfo)] -pub struct ScheduledV2 { +pub struct Scheduled { /// The unique identity for this task, if there is one. maybe_id: Option>, /// This task's priority. @@ -162,17 +182,19 @@ maybe_periodic: Option>, /// The origin to dispatch the call. origin: PalletsOrigin, + /// The information regarding the call's preparation for dispatch, in particular the fee, to be sent to post-dispatch. + pre_dispatch: PreDispatch, _phantom: PhantomData, } /// The current version of Scheduled struct. -pub type Scheduled = - ScheduledV2; +/*pub type Scheduled = + ScheduledV2;*/ // A value placed in storage that represents the current version of the Scheduler storage. // This value is used by the `on_runtime_upgrade` logic to determine whether we run // storage migration logic. -#[derive(Encode, Decode, Clone, Copy, PartialEq, Eq, RuntimeDebug, TypeInfo)] +/*#[derive(Encode, Decode, Clone, Copy, PartialEq, Eq, RuntimeDebug, TypeInfo)] todo remove completely? enum Releases { V1, V2, @@ -182,7 +204,7 @@ fn default() -> Self { Releases::V1 } -} +}*/ #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] pub struct CallSpec { @@ -194,18 +216,24 @@ trait Store for Module as Scheduler { /// Items to be executed, indexed by the block number that they should be executed on. pub Agenda: map hasher(twox_64_concat) T::BlockNumber - => Vec::Call, T::BlockNumber, T::PalletsOrigin, T::AccountId>>>; + => Vec::Call, + T::BlockNumber, + T::PalletsOrigin, + T::AccountId, + <::CallExecutor as DispatchCall>::Pre + >>>; - pub SpecAgenda: map hasher(twox_64_concat) T::BlockNumber - => Vec>; + /*pub SpecAgenda: map hasher(twox_64_concat) T::BlockNumber todo remove completely? + => Vec>;*/ /// Lookup from identity to the block number and index of the task. Lookup: map hasher(twox_64_concat) Vec => Option>; - /// Storage version of the pallet. - /// - /// New networks start with last version. - StorageVersion build(|_| Releases::V2): Releases; + // / Storage version of the pallet. + // / + // / New networks start with last version. + //StorageVersion build(|_| Releases::V2): Releases; todo remove completely? } } @@ -418,19 +446,25 @@ Some((order, index, *cumulative_weight, s)) }) .filter_map(|(order, index, cumulative_weight, mut s)| { - // We allow a scheduled call if any is true: + // We allow a scheduled call if: // - It's priority is `HARD_DEADLINE` - // - It does not push the weight past the limit. + // - It does not push the weight past the limit + // or, alternatively, // - It is the first item in the schedule - if s.priority <= schedule::HARD_DEADLINE || cumulative_weight <= limit || order == 0 { + if s.priority <= schedule::HARD_DEADLINE && cumulative_weight <= limit || order == 0 { + let r = T::CallExecutor::dispatch_call(s.pre_dispatch.clone(), s.call.clone()); + + if let Err(_) = r { + log::warn!( + target: "runtime::scheduler", + "Warning: Scheduler has failed to execute a post-dispatch transaction. \ + This block might have become invalid.", + ); + // todo possibly force a skip/return here, do something with the error + } + + let r = r.unwrap(); // todo dangerous! but it shouldn't come to that, and if it does, the error is critical, is it safe to panic? - // let origin = <::Origin as From>::from( - // s.origin.clone() - // ).into(); - // let sender = ensure_signed(origin).unwrap_or_default(); - // let who_will_pay = T::SponsorshipHandler::get_sponsor(&sender, &s.call).unwrap_or(sender); - // let sponsor = T::PalletsOrigin::from(system::RawOrigin::Signed(who_will_pay)); - // let r = s.call.clone().dispatch(sponsor.into()); let maybe_id = s.maybe_id.clone(); if let Some((period, count)) = s.maybe_periodic { if count > 1 { @@ -451,7 +485,7 @@ Self::deposit_event(RawEvent::Dispatched( (now, index), maybe_id, - Ok(())// r.map(|_| ()).map_err(|e| e.error) + r.map(|_| ()).map_err(|e| e.error) )); total_weight = cumulative_weight; None @@ -500,13 +534,19 @@ let sender = ensure_signed( <::Origin as From>::from(origin.clone()).into(), ) - .unwrap_or_default(); - let who_will_pay = sender; // T::SponsorshipHandler::get_sponsor(&sender, &call).unwrap_or(sender); - let sponsor = T::PalletsOrigin::from(system::RawOrigin::Signed(who_will_pay)); - let r = call.clone().dispatch(sponsor.into()); + .unwrap_or_default(); // todo sponsoring doesn't work with the line below -- found sponsoring is already checked for in transaction_payment + //let who_will_pay = T::SponsorshipHandler::get_sponsor(&sender, &call).unwrap_or(sender); + //let sponsor = T::PalletsOrigin::from(system::RawOrigin::Signed(who_will_pay.clone())); + //let r = call.clone().dispatch(sponsor.into()); + + let pre_dispatch = match T::CallExecutor::pre_dispatch(sender.clone(), call.clone()) { + Ok(pre_dispatch) => pre_dispatch, + Err(_) => return Err(Error::::FailedToSchedule.into()), + }; - //T::PaymentHandler::apply(); - //T::PaymentHandler::validate(sponsor.into(), call.clone(), ); todo dispatch extrinsic here + // todo continually pre-dispatch according to maybe_periodic -- but it's called only once? + // no, reschedule and cancel rely on scheduled being only the next instance + // actually can count with OnChargePayment, so no need to run around. expand Pre with unspent_total_fee and single_fee, deduct single_fee // sanitize maybe_periodic let maybe_periodic = maybe_periodic @@ -514,12 +554,21 @@ // Limit the repetitions to 100 calls and remove one from the number of repetitions since we will schedule one now. .map(|(p, c)| (p, cmp::min(c, PERIODIC_CALLS_LIMIT) - 1)); + if let Some(periodic) = maybe_periodic { + for _i in 0..=periodic.1 { + // displace to runtime and count the total required fee, then deduct. find a way to use OnChargePayment + // send constants and priority to runtime for fee computation, too. should create constants here or just declare them in runtime? + // roadblock - they will return on post_dispatch -- oh! tips? + } + } + let s = Some(Scheduled { maybe_id, priority, call, maybe_periodic, origin, + pre_dispatch, _phantom: PhantomData::::default(), }); Agenda::::append(when, s); @@ -557,7 +606,7 @@ let scheduled = Agenda::::try_mutate(when, |agenda| { agenda.get_mut(index as usize).map_or( Ok(None), - |s| -> Result>, DispatchError> { + |s| -> Result>, DispatchError> { if let (Some(ref o), Some(ref s)) = (origin, s.borrow()) { if *o != s.origin { return Err(BadOrigin.into()); @@ -570,8 +619,14 @@ if let Some(s) = scheduled { if let Some(id) = s.maybe_id { Lookup::::remove(id); - // todo add back money / displace to another function for consistency } + if let Err(_) = T::CallExecutor::cancel_dispatch(s.pre_dispatch.clone()) { + log::warn!( + target: "runtime::scheduler", + "Warning: Scheduler has failed to execute a post-dispatch transaction. \ + This block might have become invalid.", + ); + } // maybe do something with the error? Self::deposit_event(RawEvent::Canceled(when, index)); Ok(()) } else { @@ -643,12 +698,19 @@ if *o != s.origin { return Err(BadOrigin.into()); } + if let Err(_) = T::CallExecutor::cancel_dispatch(s.pre_dispatch.clone()) + { + log::warn!( + target: "runtime::scheduler", + "Warning: Scheduler has failed to execute a post-dispatch transaction. \ + This block might have become invalid.", + ); + } // maybe do something with the error? } *s = None; } Ok(()) })?; - // todo add money back / displace to another function Self::deposit_event(RawEvent::Canceled(when, index)); Ok(()) } else { @@ -842,11 +904,32 @@ } } - pub struct DummyExecutor; // todo do something with naming and function body - impl ApplyExtrinsic for DummyExecutor { - fn apply_extrinsic(_signer: Address, _function: C) -> ApplyExtrinsicResult { + pub struct DummyExecutor; + impl + DispatchCall for DummyExecutor + { + type Pre = (); + + fn dispatch_call( + _pre: Self::Pre, + _call: ::Call, + ) -> Result< + Result>, + TransactionValidityError, + > { todo!() } + + fn pre_dispatch( + _signer: ::AccountId, + _call: ::Call, + ) -> Result { + todo!() + } + + fn cancel_dispatch(_pre: Self::Pre) -> Result<(), TransactionValidityError> { + todo!() + } } parameter_types! { @@ -898,7 +981,8 @@ type MaximumWeight = MaximumSchedulerWeight; type ScheduleOrigin = EnsureOneOf, EnsureSignedBy>; type MaxScheduledPerBlock = MaxScheduledPerBlock; + type SponsorshipHandler = (); type WeightInfo = (); - type Executor = DummyExecutor; + type CallExecutor = DummyExecutor; } } --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -17,6 +17,8 @@ use sp_api::impl_runtime_apis; use sp_core::{crypto::KeyTypeId, OpaqueMetadata, H256, U256, H160}; use sp_runtime::DispatchError; +use fp_self_contained::*; +use sp_runtime::traits::{SignedExtension, Member}; // #[cfg(any(feature = "std", test))] // pub use sp_runtime::BuildStorage; @@ -54,7 +56,7 @@ WeightToFeePolynomial, WeightToFeeCoefficient, WeightToFeeCoefficients, }, }; -use pallet_unq_scheduler::ApplyExtrinsic; +use pallet_unq_scheduler::DispatchCall; use up_data_structs::*; // use pallet_contracts::weights::WeightInfo; // #[cfg(any(feature = "std", test))] @@ -66,6 +68,7 @@ traits::{BaseArithmetic, Unsigned}, }; use smallvec::smallvec; +use scale_info::TypeInfo; use codec::{Encode, Decode}; use pallet_evm::{Account as EVMAccount, FeeCalculator, GasWeightMapping, OnMethodCall}; use fp_rpc::TransactionStatus; @@ -73,6 +76,7 @@ use sp_runtime::{ traits::{BlockNumberProvider, Dispatchable, PostDispatchInfoOf}, transaction_validity::TransactionValidityError, + DispatchErrorWithPostInfo, }; // pub use pallet_timestamp::Call as TimestampCall; @@ -805,30 +809,114 @@ pub const MaxScheduledPerBlock: u32 = 50; } -type EvmSponsorshipHandler = ( - pallet_unique::UniqueEthSponsorshipHandler, - pallet_evm_contract_helpers::HelpersContractSponsoring, -); -type SponsorshipHandler = ( - pallet_unique::UniqueSponsorshipHandler, - //pallet_contract_helpers::ContractSponsorshipHandler, - pallet_evm_transaction_payment::BridgeSponsorshipHandler, -); +type ChargeTransactionPayment = pallet_charge_transaction::ChargeTransactionPayment; + +/*fn get_signed_extra(from: ::AccountId) -> SignedExtra { + ( + frame_system::CheckSpecVersion::::new(), + frame_system::CheckGenesis::::new(), + frame_system::CheckEra::::from(Era::Immortal), + frame_system::CheckNonce::::from(frame_system::Pallet::::account_nonce( + from, + )), + frame_system::CheckWeight::::new(), + ChargeTransactionPayment::new(0), + ) +}*/ -// pub fn sign(xt: CheckedExtrinsic) -> UncheckedExtrinsic { -// node_testing::keyring::sign(xt, SPEC_VERSION, TRANSACTION_VERSION, GENESIS_HASH) -// } +#[derive(Default, Encode, Decode, Clone, TypeInfo)] +pub struct SchedulerPreDispatch { + tip: Balance, + signer: AccountId, + fee: Option, +} pub struct SchedulerPaymentExecutor; -impl ApplyExtrinsic for SchedulerPaymentExecutor { - fn apply_extrinsic(signer: Address, function: C) -> ApplyExtrinsicResult { - /*let extrinsic = sign(fp_self_contained::CheckedExtrinsic { - signed: fp_self_contained::CheckedSignature::SelfContained(None), - function, - }); +impl + DispatchCall for SchedulerPaymentExecutor +where + ::Call: Member + + Dispatchable + + SelfContainedCall + + GetDispatchInfo + + From>, + SelfContainedSignedInfo: Send + Sync + 'static, + Call: From<::Call> + + From<::Call> + + SelfContainedCall, + sp_runtime::AccountId32: From<::AccountId>, +{ + type Pre = SchedulerPreDispatch; + + fn dispatch_call( + //signer: ::AccountId, + pre_dispatch: Self::Pre, + call: ::Call, + ) -> Result< + Result>, + TransactionValidityError, + > { + /*let dispatch_info = call.get_dispatch_info(); + + let extrinsic = fp_self_contained::CheckedExtrinsic::< + AccountId, + Call, + SignedExtra, + SelfContainedSignedInfo, + > { + signed: CheckedSignature::::Signed( + signer.clone().into(), + get_signed_extra(signer.clone().into()), + ), + function: call.clone().into(), + }; + + extrinsic.apply::(&dispatch_info, 0)*/ + let dispatch_info = call.get_dispatch_info(); + let pre = ( + pre_dispatch.tip, + pre_dispatch.signer.clone().into(), + pre_dispatch.fee.map(|fee| NegativeImbalance::new(fee)), + ); + + let maybe_who = Some(pre_dispatch.signer.clone().into()); + let res = Call::from(call.clone()).dispatch(Origin::from(maybe_who)); + let post_info = match res { + Ok(dispatch_info) => dispatch_info, + Err(err) => err.post_info, + }; + SignedExtra::post_dispatch( + ((), (), (), (), (), pre), + &dispatch_info, + &post_info, + 0, + &res.map(|_| ()).map_err(|e| e.error), + )?; + Ok(res) + } + + fn pre_dispatch( + signer: ::AccountId, + call: ::Call, + ) -> Result { + let dispatch_info = call.get_dispatch_info(); + //::OnChargeTransaction::withdraw_fee(); + let fee_charger = ChargeTransactionPayment::new( + // Linear scaling of the fee tip if the priority is high enough + 0, //if priority > HARD_DEADLINE { 0 } else { (HARD_DEADLINE - priority + 1) / HARD_DEADLINE * PRIORITY_TIP_MULTIPLIER * total_fee } + ); + let pre = fee_charger.pre_dispatch(&signer.into(), &call.into(), &dispatch_info, 0)?; + + Ok(SchedulerPreDispatch { + tip: pre.0, + signer: pre.1, + fee: pre.2.map(|imbalance| imbalance.peek()), + }) + } - Executive::apply_extrinsic(extrinsic)*/ + fn cancel_dispatch(pre_dispatch: Self::Pre) -> Result<(), TransactionValidityError> { todo!() + // simply call post_dispatch with 0 actual fee and the whole remaining, already withdrawn, fee } } @@ -840,10 +928,22 @@ type MaximumWeight = MaximumSchedulerWeight; type ScheduleOrigin = EnsureSigned; type MaxScheduledPerBlock = MaxScheduledPerBlock; + type SponsorshipHandler = SponsorshipHandler; type WeightInfo = (); - type Executor = SchedulerPaymentExecutor; + type CallExecutor = SchedulerPaymentExecutor; } +type EvmSponsorshipHandler = ( + pallet_unique::UniqueEthSponsorshipHandler, + pallet_evm_contract_helpers::HelpersContractSponsoring, +); + +type SponsorshipHandler = ( + pallet_unique::UniqueSponsorshipHandler, + //pallet_contract_helpers::ContractSponsorshipHandler, + pallet_evm_transaction_payment::BridgeSponsorshipHandler, +); + impl pallet_evm_transaction_payment::Config for Runtime { type EvmSponsorshipHandler = EvmSponsorshipHandler; type Currency = Balances; @@ -965,7 +1065,7 @@ frame_system::CheckEra, frame_system::CheckNonce, frame_system::CheckWeight, - pallet_charge_transaction::ChargeTransactionPayment, + ChargeTransactionPayment, //pallet_contract_helpers::ContractHelpersExtension, ); /// Unchecked extrinsic type as expected by this runtime. --- a/tests/src/scheduler.test.ts +++ b/tests/src/scheduler.test.ts @@ -3,22 +3,41 @@ // file 'LICENSE', which is part of this source code package. // -import chai from 'chai'; +import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import privateKey from './substrate/privateKey'; -import usingApi from './substrate/substrate-api'; import { + default as usingApi, + submitTransactionAsync, + submitTransactionExpectFailAsync, +} from './substrate/substrate-api'; +import { createItemExpectSuccess, createCollectionExpectSuccess, scheduleTransferExpectSuccess, + scheduleTransferAndWaitExpectSuccess, setCollectionSponsorExpectSuccess, confirmSponsorshipExpectSuccess, + findUnusedAddress, + UNIQUE, + enablePublicMintingExpectSuccess, + addToAllowListExpectSuccess, + waitNewBlocks, + normalizeAccountId, + getTokenOwner, + getGenericResult, + scheduleTransferFundsPeriodicExpectSuccess, + setCollectionLimitsExpectSuccess, + getDetailedCollectionInfo, + getFreeBalance, + confirmSponsorshipByKeyExpectSuccess, } from './util/helpers'; import {IKeyringPair} from '@polkadot/types/types'; +import {getBalanceSingle} from './substrate/get-balance'; chai.use(chaiAsPromised); -describe('Integration Test scheduler base transaction', () => { +describe('Scheduling token and balance transfers', () => { let alice: IKeyringPair; let bob: IKeyringPair; @@ -29,7 +48,7 @@ }); }); - it('User can transfer owned token with delay (scheduler)', async () => { + it('Can schedule a transfer of an owned token with delay', async () => { await usingApi(async () => { // nft const nftCollectionId = await createCollectionExpectSuccess(); @@ -37,7 +56,219 @@ await setCollectionSponsorExpectSuccess(nftCollectionId, alice.address); await confirmSponsorshipExpectSuccess(nftCollectionId); - await scheduleTransferExpectSuccess(nftCollectionId, newNftTokenId, alice, bob, 1, 4); + await scheduleTransferAndWaitExpectSuccess(nftCollectionId, newNftTokenId, alice, bob, 1, 4); + }); + }); + + it('Can transfer funds periodically', async () => { + await usingApi(async (api) => { + const waitForBlocks = 4; + const period = 2; + await scheduleTransferFundsPeriodicExpectSuccess(1n * UNIQUE, alice, bob, waitForBlocks, period, 2); + const bobsBalanceBefore = await getBalanceSingle(api, bob.address); + + // discounting already waited-for operations + await waitNewBlocks(waitForBlocks - 2); + const bobsBalanceAfterFirst = await getBalanceSingle(api, bob.address); + expect(bobsBalanceAfterFirst > bobsBalanceBefore).to.be.true; + + await waitNewBlocks(period); + const bobsBalanceAfterSecond = await getBalanceSingle(api, bob.address); + expect(bobsBalanceAfterSecond > bobsBalanceAfterFirst).to.be.true; + }); + }); + + it('Can sponsor scheduling a transaction', async () => { + const collectionId = await createCollectionExpectSuccess(); + await setCollectionSponsorExpectSuccess(collectionId, bob.address); + await confirmSponsorshipExpectSuccess(collectionId, '//Bob'); + + await usingApi(async () => { + const tokenId = await createItemExpectSuccess(alice, collectionId, 'NFT', alice.address); + + const aliceBalanceBefore = await getFreeBalance(alice); + // no need to wait to check, fees must be deducted on scheduling, immediately + await scheduleTransferExpectSuccess(collectionId, tokenId, alice, bob, 0, 4); + const aliceBalanceAfter = await getFreeBalance(alice); + expect(aliceBalanceAfter == aliceBalanceBefore).to.be.true; + }); + }); + + /*it('Can\'t schedule a transaction with no funds', async () => { + await usingApi(async (api) => { + // Find an empty, unused account + const zeroBalance = await findUnusedAddress(api); + + const collectionId = await createCollectionExpectSuccess(); + const tokenId = await createItemExpectSuccess(alice, collectionId, 'NFT'); + + await transferExpectSuccess(collectionId, tokenId, alice, zeroBalance); + + await scheduleTransferAndWaitExpectSuccess(collectionId, tokenId, zeroBalance, alice, 1, 4); + }); + });*/ + + it('Schedules and dispatches a transaction even if the caller has no funds at the time of the dispatch', async () => { + await usingApi(async (api) => { + // Find an empty, unused account + const zeroBalance = await findUnusedAddress(api); + + const collectionId = await createCollectionExpectSuccess(); + + // Add zeroBalance address to allow list + await enablePublicMintingExpectSuccess(alice, collectionId); + await addToAllowListExpectSuccess(alice, collectionId, zeroBalance.address); + + // Grace zeroBalance with money, enough to cover future transactions + const balanceTx = api.tx.balances.transfer(zeroBalance.address, 1n * UNIQUE); + await submitTransactionAsync(alice, balanceTx); + + // Mint a fresh NFT + const tokenId = await createItemExpectSuccess(zeroBalance, collectionId, 'NFT'); + + // Schedule transfer of the NFT a few blocks ahead + const waitForBlocks = 5; + await scheduleTransferExpectSuccess(collectionId, tokenId, zeroBalance, alice, 1, waitForBlocks); + + // Get rid of the account's funds before the scheduled transaction takes place + const emptyBalanceTx = api.tx.balances.setBalance(zeroBalance.address, 0, 0); // do not null reserved? + const sudoTx = api.tx.sudo.sudo(emptyBalanceTx as any); + const events = await submitTransactionAsync(alice, sudoTx); + expect(getGenericResult(events).success).to.be.true; + + // Wait for a certain number of blocks, discarding the ones that already happened while accepting the late transactions + await waitNewBlocks(waitForBlocks - 3); + + expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(alice.address)); + }); + }); + + /*it.skip('Going bankrupt after sponsoring a scheduled transaction does not nullify the transaction', async () => { + await usingApi(async (api) => { + // Find two empty, unused accounts + const zeroBalance = await findUnusedAddress(api); + const zeroBalanceSponsor = await findUnusedAddress(api); + + const collectionId = await createCollectionExpectSuccess(); + + // Grace these with money, enough to cover future transactions + const balanceTx = api.tx.balances.transfer(zeroBalance.address, 1n * UNIQUE); + await submitTransactionAsync(alice, balanceTx); + + // Grace these with money, enough to cover future transactions + const balanceSponsorTx = api.tx.balances.transfer(zeroBalanceSponsor.address, 1n * UNIQUE); + await submitTransactionAsync(alice, balanceSponsorTx); + + // Set a collection sponsor + await setCollectionSponsorExpectSuccess(collectionId, zeroBalanceSponsor.address); + await confirmSponsorshipExpectSuccess(collectionId); + + // Add zeroBalance address to allow list + await enablePublicMintingExpectSuccess(alice, collectionId); + await addToAllowListExpectSuccess(alice, collectionId, zeroBalance.address); + + // Mint a fresh NFT + const tokenId = await createItemExpectSuccess(zeroBalance, collectionId, 'NFT'); + + // Schedule transfer of the NFT a few blocks ahead + // const waitForBlocks = 4; + await scheduleTransferExpectSuccess(collectionId, tokenId, zeroBalance, alice, 1, 4); + //await waitAfterScheduleExpectSuccess(collectionId, tokenId, alice, 3); + + // Get rid of the account's funds before the scheduled transaction takes place + const emptyBalanceSponsorTx = api.tx.balances.setBalance(zeroBalanceSponsor.address, 0, 0); + const sudoTx = api.tx.sudo.sudo(emptyBalanceSponsorTx as any); + const events = await submitTransactionAsync(alice, sudoTx); + expect(getGenericResult(events).success).to.be.true; + + // Wait for a certain number of blocks, save for the ones that already happened while accepting the late transactions + await waitNewBlocks(4); + + expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(alice.address)); + }); + });*/ + + it('Sponsor going bankrupt does not impact a scheduled transaction', async () => { + const collectionId = await createCollectionExpectSuccess(); + + await usingApi(async (api) => { + const zeroBalance = await findUnusedAddress(api); + + /*await setCollectionLimitsExpectSuccess(alice, nftCollectionId, { + sponsoredDataRateLimit: 2, + });*/ + //console.log(await getDetailedCollectionInfo(api, nftCollectionId)); + const balanceTx = api.tx.balances.transfer(zeroBalance.address, 1n * UNIQUE); + await submitTransactionAsync(alice, balanceTx); + + await setCollectionSponsorExpectSuccess(collectionId, zeroBalance.address); + await confirmSponsorshipByKeyExpectSuccess(collectionId, zeroBalance); + + const tokenId = await createItemExpectSuccess(alice, collectionId, 'NFT', alice.address); + + await scheduleTransferExpectSuccess(collectionId, tokenId, alice, zeroBalance, 1, 5); + + const emptyBalanceSponsorTx = api.tx.balances.setBalance(zeroBalance.address, 0, 0); + const sudoTx = api.tx.sudo.sudo(emptyBalanceSponsorTx as any); + const events = await submitTransactionAsync(alice, sudoTx); + expect(getGenericResult(events).success).to.be.true; + + // Wait for a certain number of blocks, save for the ones that already happened while accepting the late transactions + await waitNewBlocks(2); + + expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(zeroBalance.address)); + }); + }); +}); + +describe.skip('Scheduling EVM smart contracts', () => { + let alice: IKeyringPair; + let bob: IKeyringPair; + + before(async() => { + await usingApi(async () => { + alice = privateKey('//Alice'); + bob = privateKey('//Bob'); + }); + }); + + // todo contract testing + it.skip('NFT: Sponsoring of transfers is rate limited', async () => { + const collectionId = await createCollectionExpectSuccess(); + await setCollectionSponsorExpectSuccess(collectionId, bob.address); + await confirmSponsorshipExpectSuccess(collectionId, '//Bob'); + + await usingApi(async (api) => { + // Find unused address + const zeroBalance = await findUnusedAddress(api); + + // Mint token for alice + const itemId = await createItemExpectSuccess(alice, collectionId, 'NFT', alice.address); + + // Transfer this token from Alice to unused address and back + // Alice to Zero gets sponsored + const aliceToZero = api.tx.unique.transfer(normalizeAccountId(zeroBalance.address), collectionId, itemId, 0); + const events1 = await submitTransactionAsync(alice, aliceToZero); + const result1 = getGenericResult(events1); + + // Second transfer should fail + const sponsorBalanceBefore = (await api.query.system.account(bob.address)).data.free.toBigInt(); + const zeroToAlice = api.tx.unique.transfer(normalizeAccountId(alice.address), collectionId, itemId, 0); + const badTransaction = async function () { + await submitTransactionExpectFailAsync(zeroBalance, zeroToAlice); + }; + await expect(badTransaction()).to.be.rejectedWith('Inability to pay some fees'); + const sponsorBalanceAfter = (await api.query.system.account(bob.address)).data.free.toBigInt(); + + // Try again after Zero gets some balance - now it should succeed + const balancetx = api.tx.balances.transfer(zeroBalance.address, 1n * UNIQUE); + await submitTransactionAsync(alice, balancetx); + const events2 = await submitTransactionAsync(zeroBalance, zeroToAlice); + const result2 = getGenericResult(events2); + + expect(result1.success).to.be.true; + expect(result2.success).to.be.true; + expect(sponsorBalanceAfter).to.be.equal(sponsorBalanceBefore); }); }); }); --- a/tests/src/util/helpers.ts +++ b/tests/src/util/helpers.ts @@ -481,10 +481,16 @@ } export async function confirmSponsorshipExpectSuccess(collectionId: number, senderSeed = '//Alice') { + await usingApi(async () => { + const sender = privateKey(senderSeed); + await confirmSponsorshipByKeyExpectSuccess(collectionId, sender); + }); +} + +export async function confirmSponsorshipByKeyExpectSuccess(collectionId: number, sender: IKeyringPair) { await usingApi(async (api) => { // Run the transaction - const sender = privateKey(senderSeed); const tx = api.tx.unique.confirmSponsorship(collectionId); const events = await submitTransactionAsync(sender, tx); const result = getGenericResult(events); @@ -792,7 +798,7 @@ } /* eslint no-async-promise-executor: "off" */ -async function getBlockNumber(api: ApiPromise): Promise { +export async function getBlockNumber(api: ApiPromise): Promise { return new Promise(async (resolve) => { const unsubscribe = await api.rpc.chain.subscribeNewHeads((head) => { unsubscribe(); @@ -822,6 +828,31 @@ } export async function +scheduleTransferAndWaitExpectSuccess( + collectionId: number, + tokenId: number, + sender: IKeyringPair, + recipient: IKeyringPair, + value: number | bigint = 1, + blockSchedule: number, +) { + await usingApi(async (api: ApiPromise) => { + await scheduleTransferExpectSuccess(collectionId, tokenId, sender, recipient, value, blockSchedule); + + const recipientBalanceBefore = (await api.query.system.account(recipient.address)).data.free.toBigInt(); + console.log(await getFreeBalance(sender)); + + // sleep for n + 1 blocks + await waitNewBlocks(blockSchedule + 1); + + const recipientBalanceAfter = (await api.query.system.account(recipient.address)).data.free.toBigInt(); + + expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(recipient.address)); + expect(recipientBalanceAfter).to.be.equal(recipientBalanceBefore); + }); +} + +export async function scheduleTransferExpectSuccess( collectionId: number, tokenId: number, @@ -838,19 +869,36 @@ const transferTx = api.tx.unique.transfer(normalizeAccountId(recipient.address), collectionId, tokenId, value); const scheduleTx = api.tx.scheduler.schedule(expectedBlockNumber, null, 0, transferTx as any); - await submitTransactionAsync(sender, scheduleTx); + const events = await submitTransactionAsync(sender, scheduleTx); + expect(getGenericResult(events).success).to.be.true; - const recipientBalanceBefore = (await api.query.system.account(recipient.address)).data.free.toBigInt(); + expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(sender.address)); + }); +} - expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(sender.address)); +export async function +scheduleTransferFundsPeriodicExpectSuccess( + amount: bigint, + sender: IKeyringPair, + recipient: IKeyringPair, + blockSchedule: number, + period: number, + repetitions: number, +) { + await usingApi(async (api: ApiPromise) => { + const blockNumber: number | undefined = await getBlockNumber(api); + const expectedBlockNumber = blockNumber + blockSchedule; - // sleep for 4 blocks - await waitNewBlocks(blockSchedule + 1); + const balanceBefore = await getFreeBalance(recipient); + + expect(blockNumber).to.be.greaterThan(0); + const transferTx = api.tx.balances.transfer(recipient.address, amount); + const scheduleTx = api.tx.scheduler.schedule(expectedBlockNumber, [period, repetitions], 0, transferTx as any); - const recipientBalanceAfter = (await api.query.system.account(recipient.address)).data.free.toBigInt(); + const events = await submitTransactionAsync(sender, scheduleTx); + expect(getGenericResult(events).success).to.be.true; - expect(await getTokenOwner(api, collectionId, tokenId)).to.be.deep.equal(normalizeAccountId(recipient.address)); - expect(recipientBalanceAfter).to.be.equal(recipientBalanceBefore); + expect(await getFreeBalance(recipient)).to.be.equal(balanceBefore); }); }