5

I am new to both substrate and rust so trying to educate myself. I've the read documentation for both and am now going through the various tutorials and guides. I'm currently on the Basic Storage Migration Guide but trying to complete it using the #[pallet::storage] notation instead of decl_storage! which I understand is no longer considered best practise. The guide uses the Nicks pallet as an example, updating it form a single name to a first and last name. I'm trying to do the same.

I've added a migration module with a deprecated pallet (haven't added the migration function yet but this will come later), crated a struct holding a BoundedVec for the first name and Option for the last name, updated the storage and updated the various functions to accordingly. I've derived Encode, Decode, MaxEncodedLen and TypeInfo for my struct and am using T::MaxLength to define the length of the BoundedVecs:

    // This file is part of Substrate.
    
    // Copyright (C) 2019-2022 Parity Technologies (UK) Ltd.
    // SPDX-License-Identifier: Apache-2.0
    
    // Licensed under the Apache License, Version 2.0 (the "License");
    // you may not use this file except in compliance with the License.
    // You may obtain a copy of the License at
    //
    //  http://www.apache.org/licenses/LICENSE-2.0
    //
    // Unless required by applicable law or agreed to in writing, software
    // distributed under the License is distributed on an "AS IS" BASIS,
    // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    // See the License for the specific language governing permissions and
    // limitations under the License.
    
    //! # Nicks Pallet
    //!
    //! - [`Config`]
    //! - [`Call`]
    //!
    //! ## Overview
    //!
    //! Nicks is an example pallet for keeping track of account names on-chain. It makes no effort to
    //! create a name hierarchy, be a DNS replacement or provide reverse lookups. Furthermore, the
    //! weights attached to this pallet's dispatchable functions are for demonstration purposes only and
    //! have not been designed to be economically secure. Do not use this pallet as-is in production.
    //!
    //! ## Interface
    //!
    //! ### Dispatchable Functions
    //!
    //! * `set_name` - Set the associated name of an account; a small deposit is reserved if not already
    //!   taken.
    //! * `clear_name` - Remove an account's associated name; the deposit is returned.
    //! * `kill_name` - Forcibly remove the associated name; the deposit is lost.
    
    #![cfg_attr(not(feature = "std"), no_std)]
    
    use frame_support::traits::{Currency, OnUnbalanced, ReservableCurrency};
    pub use pallet::*;
    use sp_runtime::traits::{StaticLookup, Zero};
    use sp_std::prelude::*;
    
    type AccountIdOf<T> = <T as frame_system::Config>::AccountId;
    type BalanceOf<T> = <<T as Config>::Currency as Currency<AccountIdOf<T>>>::Balance;
    type NegativeImbalanceOf<T> =
        <<T as Config>::Currency as Currency<AccountIdOf<T>>>::NegativeImbalance;
    
    #[frame_support::pallet]
    pub mod pallet {
        use super::*;
        use frame_support::pallet_prelude::*;
        use frame_system::pallet_prelude::*;
    
        #[pallet::config]
        pub trait Config: frame_system::Config {
            /// The overarching event type.
            type Event: From<Event<Self>> + IsType<<Self as frame_system::Config>::Event>;
    
            /// The currency trait.
            type Currency: ReservableCurrency<Self::AccountId>;
    
            /// Reservation fee.
            #[pallet::constant]
            type ReservationFee: Get<BalanceOf<Self>>;
    
            /// What to do with slashed funds.
            type Slashed: OnUnbalanced<NegativeImbalanceOf<Self>>;
    
            /// The origin which may forcibly set or remove a name. Root can always do this.
            type ForceOrigin: EnsureOrigin<Self::Origin>;
    
            /// The minimum length a name may be.
            #[pallet::constant]
            type MinLength: Get<u32>;
    
            /// The maximum length a name may be.
            #[pallet::constant]
            type MaxLength: Get<u32>;
        }
    
        #[pallet::event]
        #[pallet::generate_deposit(pub(super) fn deposit_event)]
        pub enum Event<T: Config> {
            /// A name was set.
            NameSet { who: T::AccountId },
            /// A name was forcibly set.
            NameForced { target: T::AccountId },
            /// A name was changed.
            NameChanged { who: T::AccountId },
            /// A name was cleared, and the given balance returned.
            NameCleared { who: T::AccountId, deposit: BalanceOf<T> },
            /// A name was removed and the given balance slashed.
            NameKilled { target: T::AccountId, deposit: BalanceOf<T> },
        }
    
        /// Error for the nicks pallet.
        #[pallet::error]
        pub enum Error<T> {
            /// A name is too short.
            TooShort,
            /// A name is too long.
            TooLong,
            /// An account isn't named.
            Unnamed,
        }
    
        #[derive(Encode, Decode, MaxEncodedLen, TypeInfo)]
        pub struct Nickname<T: Config> {
            first: BoundedVec<u8, T::MaxLength>,
            last: Option<BoundedVec<u8, T::MaxLength>>,
        }
    
        impl<T: Config> Nickname<T> {
            fn max_encoded_len() -> usize {
                let maxLength = T::MaxLength::get() as usize;
                maxLength * 2
            }
        }
    
        /// The lookup table for names.
        #[pallet::storage]
        pub(super) type NameOf<T: Config> =
            StorageMap<_, Twox64Concat, T::AccountId, (Nickname<T>, BalanceOf<T>)>;
    
        #[pallet::pallet]
        #[pallet::generate_store(pub(super) trait Store)]
        pub struct Pallet<T>(_);
    
        #[pallet::call]
        impl<T: Config> Pallet<T> {
            /// Set an account's name. The name should be a UTF-8-encoded string by convention, though
            /// we don't check it.
            ///
            /// The name may not be more than `T::MaxLength` bytes, nor less than `T::MinLength` bytes.
            ///
            /// If the account doesn't already have a name, then a fee of `ReservationFee` is reserved
            /// in the account.
            ///
            /// The dispatch origin for this call must be _Signed_.
            ///
            /// # <weight>
            /// - O(1).
            /// - At most one balance operation.
            /// - One storage read/write.
            /// - One event.
            /// # </weight>
            
            #[pallet::weight(50_000_000)]
            pub fn set_name(origin: OriginFor<T>, first: Vec<u8>, last: Vec<u8>) -> DispatchResult {
                let sender = ensure_signed(origin)?;
    
                let bounded_first: BoundedVec<_, _> =
                    first.try_into().map_err(|()| Error::<T>::TooLong)?;
                ensure!(bounded_first.len() >= T::MinLength::get() as usize, Error::<T>::TooShort);
                let bounded_last: BoundedVec<_, _> =
                    last.try_into().map_err(|()| Error::<T>::TooLong)?;
                ensure!(bounded_last.len() >= T::MinLength::get() as usize, Error::<T>::TooShort);
    
                let bounded_last: Option<BoundedVec<u8, T::MaxLength>> = Some(bounded_last);
    
                let deposit = if let Some((_, deposit)) = <NameOf<T>>::get(&sender) {
                    Self::deposit_event(Event::<T>::NameChanged { who: sender.clone() });
                    deposit
                } else {
                    let deposit = T::ReservationFee::get();
                    T::Currency::reserve(&sender, deposit.clone())?;
                    Self::deposit_event(Event::<T>::NameSet { who: sender.clone() });
                    deposit
                };
    
                <NameOf<T>>::insert(&sender, (Nickname{first: bounded_first, last: bounded_last}, deposit));
                Ok(())
            }
    
            /// Clear an account's name and return the deposit. Fails if the account was not named.
            ///
            /// The dispatch origin for this call must be _Signed_.
            ///
            /// # <weight>
            /// - O(1).
            /// - One balance operation.
            /// - One storage read/write.
            /// - One event.
            /// # </weight>
            #[pallet::weight(70_000_000)]
            pub fn clear_name(origin: OriginFor<T>) -> DispatchResult {
                let sender = ensure_signed(origin)?;
    
                let deposit = <NameOf<T>>::take(&sender).ok_or(Error::<T>::Unnamed)?.1;
    
                let err_amount = T::Currency::unreserve(&sender, deposit.clone());
                debug_assert!(err_amount.is_zero());
    
                Self::deposit_event(Event::<T>::NameCleared { who: sender, deposit });
                Ok(())
            }
    
            /// Remove an account's name and take charge of the deposit.
            ///
            /// Fails if `target` has not been named. The deposit is dealt with through `T::Slashed`
            /// imbalance handler.
            ///
            /// The dispatch origin for this call must match `T::ForceOrigin`.
            ///
            /// # <weight>
            /// - O(1).
            /// - One unbalanced handler (probably a balance transfer)
            /// - One storage read/write.
            /// - One event.
            /// # </weight>
            #[pallet::weight(70_000_000)]
            pub fn kill_name(
                origin: OriginFor<T>,
                target: <T::Lookup as StaticLookup>::Source,
            ) -> DispatchResult {
                T::ForceOrigin::ensure_origin(origin)?;
    
                // Figure out who we're meant to be clearing.
                let target = T::Lookup::lookup(target)?;
                // Grab their deposit (and check that they have one).
                let deposit = <NameOf<T>>::take(&target).ok_or(Error::<T>::Unnamed)?.1;
                // Slash their deposit from them.
                T::Slashed::on_unbalanced(T::Currency::slash_reserved(&target, deposit.clone()).0);
    
                Self::deposit_event(Event::<T>::NameKilled { target, deposit });
                Ok(())
            }
    
            /// Set a third-party account's name with no deposit.
            ///
            /// No length checking is done on the name.
            ///
            /// The dispatch origin for this call must match `T::ForceOrigin`.
            ///
            /// # <weight>
            /// - O(1).
            /// - At most one balance operation.
            /// - One storage read/write.
            /// - One event.
            /// # </weight>
            #[pallet::weight(70_000_000)]
            pub fn force_name(
                origin: OriginFor<T>,
                target: <T::Lookup as StaticLookup>::Source,
                first: Vec<u8>,
                last: Vec<u8>,
            ) -> DispatchResult {
                T::ForceOrigin::ensure_origin(origin)?;
    
                let bounded_first: BoundedVec<_, _> =
                    first.try_into().map_err(|()| Error::<T>::TooLong)?;
                ensure!(bounded_first.len() >= T::MinLength::get() as usize, Error::<T>::TooShort);
                let bounded_last: BoundedVec<_, _> =
                    last.try_into().map_err(|()| Error::<T>::TooLong)?;
                ensure!(bounded_last.len() >= T::MinLength::get() as usize, Error::<T>::TooShort);
    
                let bounded_last: Option<BoundedVec<u8, T::MaxLength>> = Some(bounded_last);
    
                let target = T::Lookup::lookup(target)?;
                let deposit = <NameOf<T>>::get(&target).map(|x| x.1).unwrap_or_else(Zero::zero);
                <NameOf<T>>::insert(&target, (Nickname{first: bounded_first, last: bounded_last}, deposit));
    
                Self::deposit_event(Event::<T>::NameForced { target });
                Ok(())
            }
            
        }
    
        pub mod migration {
            use super::*;
    
            #[frame_support::pallet]
            pub mod deprecated_pallet {
                use super::*;
    
                #[pallet::config]
                pub trait Config: frame_system::Config + pallet::Config {
    
                }
    
                #[pallet::pallet]
                #[pallet::generate_store(pub(super) trait Store)]
                pub struct Pallet<T>(_);
    
                #[pallet::storage]
                pub(super) type NameOf<T: Config> =
                    StorageMap<_, Twox64Concat, T::AccountId, (BoundedVec<u8, T::MaxLength>, BalanceOf<T>)>;
    
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
        use crate as pallet_nicks;
    
        use frame_support::{
            assert_noop, assert_ok, ord_parameter_types, parameter_types,
            traits::{ConstU32, ConstU64},
        };
        use frame_system::EnsureSignedBy;
        use sp_core::H256;
        use sp_runtime::{
            testing::Header,
            traits::{BadOrigin, BlakeTwo256, IdentityLookup},
        };
    
        type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic<Test>;
        type Block = frame_system::mocking::MockBlock<Test>;
    
        frame_support::construct_runtime!(
            pub enum Test where
                Block = Block,
                NodeBlock = Block,
                UncheckedExtrinsic = UncheckedExtrinsic,
            {
                System: frame_system,
                Balances: pallet_balances,
                Nicks: pallet_nicks,
            }
        );
    
        parameter_types! {
            pub BlockWeights: frame_system::limits::BlockWeights =
                frame_system::limits::BlockWeights::simple_max(1024);
        }
        impl frame_system::Config for Test {
            type BaseCallFilter = frame_support::traits::Everything;
            type BlockWeights = ();
            type BlockLength = ();
            type DbWeight = ();
            type Origin = Origin;
            type Index = u64;
            type BlockNumber = u64;
            type Hash = H256;
            type Call = Call;
            type Hashing = BlakeTwo256;
            type AccountId = u64;
            type Lookup = IdentityLookup<Self::AccountId>;
            type Header = Header;
            type Event = Event;
            type BlockHashCount = ConstU64<250>;
            type Version = ();
            type PalletInfo = PalletInfo;
            type AccountData = pallet_balances::AccountData<u64>;
            type OnNewAccount = ();
            type OnKilledAccount = ();
            type SystemWeightInfo = ();
            type SS58Prefix = ();
            type OnSetCode = ();
            type MaxConsumers = ConstU32<16>;
        }
    
        impl pallet_balances::Config for Test {
            type MaxLocks = ();
            type MaxReserves = ();
            type ReserveIdentifier = [u8; 8];
            type Balance = u64;
            type Event = Event;
            type DustRemoval = ();
            type ExistentialDeposit = ConstU64<1>;
            type AccountStore = System;
            type WeightInfo = ();
        }
    
        ord_parameter_types! {
            pub const One: u64 = 1;
        }
        impl Config for Test {
            type Event = Event;
            type Currency = Balances;
            type ReservationFee = ConstU64<2>;
            type Slashed = ();
            type ForceOrigin = EnsureSignedBy<One, u64>;
            type MinLength = ConstU32<3>;
            type MaxLength = ConstU32<16>;
        }
    
        fn new_test_ext() -> sp_io::TestExternalities {
            let mut t = frame_system::GenesisConfig::default().build_storage::<Test>().unwrap();
            pallet_balances::GenesisConfig::<Test> { balances: vec![(1, 10), (2, 10)] }
                .assimilate_storage(&mut t)
                .unwrap();
            t.into()
        }
    
        #[test]
        fn kill_name_should_work() {
            new_test_ext().execute_with(|| {
                assert_ok!(Nicks::set_name(Origin::signed(2), b"Dave".to_vec()));
                assert_eq!(Balances::total_balance(&2), 10);
                assert_ok!(Nicks::kill_name(Origin::signed(1), 2));
                assert_eq!(Balances::total_balance(&2), 8);
                assert_eq!(<NameOf<Test>>::get(2), None);
            });
        }
    
        #[test]
        fn force_name_should_work() {
            new_test_ext().execute_with(|| {
                assert_noop!(
                    Nicks::set_name(Origin::signed(2), b"Dr. David Brubeck, III".to_vec()),
                    Error::<Test>::TooLong,
                );
    
                assert_ok!(Nicks::set_name(Origin::signed(2), b"Dave".to_vec()));
                assert_eq!(Balances::reserved_balance(2), 2);
                assert_noop!(
                    Nicks::force_name(Origin::signed(1), 2, b"Dr. David Brubeck, III".to_vec()),
                    Error::<Test>::TooLong,
                );
                assert_ok!(Nicks::force_name(Origin::signed(1), 2, b"Dr. Brubeck, III".to_vec()));
                assert_eq!(Balances::reserved_balance(2), 2);
                let (name, amount) = <NameOf<Test>>::get(2).unwrap();
                assert_eq!(name, b"Dr. Brubeck, III".to_vec());
                assert_eq!(amount, 2);
            });
        }
    
        #[test]
        fn normal_operation_should_work() {
            new_test_ext().execute_with(|| {
                assert_ok!(Nicks::set_name(Origin::signed(1), b"Gav".to_vec()));
                assert_eq!(Balances::reserved_balance(1), 2);
                assert_eq!(Balances::free_balance(1), 8);
                assert_eq!(<NameOf<Test>>::get(1).unwrap().0, b"Gav".to_vec());
    
                assert_ok!(Nicks::set_name(Origin::signed(1), b"Gavin".to_vec()));
                assert_eq!(Balances::reserved_balance(1), 2);
                assert_eq!(Balances::free_balance(1), 8);
                assert_eq!(<NameOf<Test>>::get(1).unwrap().0, b"Gavin".to_vec());
    
                assert_ok!(Nicks::clear_name(Origin::signed(1)));
                assert_eq!(Balances::reserved_balance(1), 0);
                assert_eq!(Balances::free_balance(1), 10);
            });
        }
    
        #[test]
        fn error_catching_should_work() {
            new_test_ext().execute_with(|| {
                assert_noop!(Nicks::clear_name(Origin::signed(1)), Error::<Test>::Unnamed);
    
                assert_noop!(
                    Nicks::set_name(Origin::signed(3), b"Dave".to_vec()),
                    pallet_balances::Error::<Test, _>::InsufficientBalance
                );
    
                assert_noop!(
                    Nicks::set_name(Origin::signed(1), b"Ga".to_vec()),
                    Error::<Test>::TooShort
                );
                assert_noop!(
                    Nicks::set_name(Origin::signed(1), b"Gavin James Wood, Esquire".to_vec()),
                    Error::<Test>::TooLong
                );
                assert_ok!(Nicks::set_name(Origin::signed(1), b"Dave".to_vec()));
                assert_noop!(Nicks::kill_name(Origin::signed(2), 1), BadOrigin);
                assert_noop!(Nicks::force_name(Origin::signed(2), 1, b"Whatever".to_vec()), BadOrigin);
            });
        }
    }

When I try to compile this I get:

error[E0277]: the trait bound `T: parity_scale_codec::MaxEncodedLen` is not satisfied    --> pallets/nicks/src/lib.rs:128:12
    | 128 |     #[pallet::pallet]
    |               ^^^^^^ the trait `parity_scale_codec::MaxEncodedLen` is not implemented for `T`
    | note: required because of the requirements on the impl of `parity_scale_codec::MaxEncodedLen` for `Nickname<T>`    --> pallets/nicks/src/lib.rs:110:27
    | 110 |     #[derive(Encode, Decode, MaxEncodedLen, TypeInfo)]
    |                              ^^^^^^^^^^^^^ 111 |     pub struct Nickname<T: Config> {
    |                ^^^^^^^^^^^^^^^^^^^
    = note: 1 redundant requirement hidden
    = note: required because of the requirements on the impl of `parity_scale_codec::MaxEncodedLen` for `(Nickname<T>, <<T as pallet::Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance)`
    = note: required because of the requirements on the impl of `StorageInfoTrait` for `frame_support::pallet_prelude::StorageMap<pallet::_GeneratedPrefixForStorageNameOf<T>, frame_support::Twox64Concat, <T as frame_system::Config>::AccountId, (Nickname<T>, <<T as pallet::Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance)>`
    = note: this error originates in the derive macro `MaxEncodedLen` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider further restricting this bound
    | 128 |     #[pallet::pallet + parity_scale_codec::MaxEncodedLen]
    |                      +++++++++++++++++++++++++++++++++++

error[E0277]: the trait bound `T: scale_info::TypeInfo` is not satisfied    --> pallets/nicks/src/lib.rs:124:12
    | 124 |     #[pallet::storage]
    |               ^^^^^^^ the trait `scale_info::TypeInfo` is not implemented for `T`
    | note: required because of the requirements on the impl of `scale_info::TypeInfo` for `Nickname<T>`    --> pallets/nicks/src/lib.rs:110:42
    | 110 |     #[derive(Encode, Decode, MaxEncodedLen, TypeInfo)]
    |                                             ^^^^^^^^ 111 |     pub struct Nickname<T: Config> {
    |                ^^^^^^^^^^^^^^^^^^^
    = note: 1 redundant requirement hidden
    = note: required because of the requirements on the impl of `scale_info::TypeInfo` for `(Nickname<T>, <<T as pallet::Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance)`
    = note: required because of the requirements on the impl of `StaticTypeInfo` for `(Nickname<T>, <<T as pallet::Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance)`
    = note: required because of the requirements on the impl of `StorageEntryMetadataBuilder` for `frame_support::pallet_prelude::StorageMap<pallet::_GeneratedPrefixForStorageNameOf<T>, frame_support::Twox64Concat, <T as frame_system::Config>::AccountId, (Nickname<T>, <<T as pallet::Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance)>`
    = note: this error originates in the derive macro `TypeInfo` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider restricting type parameter `T`
    | 51  | #[frame_support::pallet]: scale_info::TypeInfo
    |                         ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0277`. error: could not compile `pallet-nicks` due to 2 previous errors warning: build failed, waiting for other jobs to finish... error: build failed

I understand that it is telling me that T needs to implement MaxEncodedLen and TypeInfo but I can't figure out how to do it. I've tried many different permutatations from using Vec instead of BoundedVec to implementing the suggestions from the compiler, but whatever I do generates errors of one kind or another. So I've reached the point where it's time to ask for help!

I'm sure this is a newbie question that somebody with more experience resolve in seconds.

Thanks in advance for your help.

4
  • Does this solve your problem? substrate.stackexchange.com/questions/619/…
    – Shawn Tabrizi
    Apr 5, 2022 at 17:51
  • @ShawnTabrizi, Great, yes solution 1 from that link, using codec(mel_bound()) and scale_info(skip_type_params(T)) macros, solved my problem. Out of interest, how would you get solution 2 to work in my case? I tried it but couldn't find a way for the BoundedVec to have a size known at compile time without using T::MaxLength. For my understanding it would be good to know if that can be implemented somehow. Thanks for your help!
    – jpataylor
    Apr 6, 2022 at 14:23
  • You need a MaxLength for BoundedVec. There is no way around that, so I am not sure what exactly you are asking.
    – Shawn Tabrizi
    Apr 6, 2022 at 14:42
  • That's what I was thinking so, in conclusion, using codec(mel_bound()) and scale_info(skip_type_params(T)) macros, is the only solution. Thanks for your help.
    – jpataylor
    Apr 7, 2022 at 12:14

2 Answers 2

2

in your code you are trying to replace an expected BoundedVec<u8, T::MaxLength> with your NickName's struct.

        #[derive(Encode, Decode, MaxEncodedLen, TypeInfo)]
        pub struct Nickname<T: Config> {
            first: BoundedVec<u8, T::MaxLength>,
            last: Option<BoundedVec<u8, T::MaxLength>>,
        }
    
        impl<T: Config> Nickname<T> {
            fn max_encoded_len() -> usize {
                let maxLength = T::MaxLength::get() as usize;
                maxLength * 2
            }
        }
    
        /// The lookup table for names.
        #[pallet::storage]
        pub(super) type NameOf<T: Config> =
            StorageMap<_, Twox64Concat, T::AccountId, (Nickname<T>, BalanceOf<T>)>;

I suggest you review the latest Nicks pallet code and replace your #[pallet::storage] macro with the proper according changes: https://github.com/paritytech/substrate/blob/master/frame/nicks/src/lib.rs

Regards,

2
  • Hi Pablo, Thanks for your help. The pallet code you recommend was where I started. I am trying to update from that to a pallet which takes a first and last name instead of just one name. As is described in link. Any idea how to do that using the #[pallet::storage] syntax?
    – jpataylor
    Apr 6, 2022 at 9:49
  • No worries. I recommend creating a new question specifying you needs better. Apr 6, 2022 at 14:21
0

@ShawnTabrizi answered the question perfectly in the comments. Adding it here as an answer to make it easier for others to find. All credit goes to @ShawnTabrizi. The solution is to add codec(mel_bound()) and scale_info(skip_type_params(T)) macros to the struct that needs to be stored when the struct includes BoundedVecs. Because BoundedVecs need a MaxLength parameter, T can not be avoided, the compiler mistakenly believes that T therefore needs to implement MaxEncodedLen and TypeInfo. The macros above tell the compiler that it doesn't. More information here: How to fix `parity_scale_codec::MaxEncodedLen` is not implemented for `T`?

1
  • 4
    I think it would be better to summarize the answer first, and then, in a footnote give credit to Shawn, otherwise it looks like you're @ messaging them instead of answering the question.
    – Dcompoze
    Apr 7, 2022 at 15:45

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.