Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(pallet-nfts): optimization #387

Open
wants to merge 81 commits into
base: main
Choose a base branch
from
Open

Conversation

chungquantin
Copy link
Collaborator

@chungquantin chungquantin commented Nov 19, 2024

Edited: 09/12/2024

DESCRIPTION

This pull request introduces new storage items to the pallet-nfts to optimize the performance for the use case of the Pop API contract library implementation.

DESIGN DECISION

New configuration parameters

  • CollectionApprovalDeposit: The basic amount of funds that must be reserved for collection approvals.

This is held for an additional storage item whose value size is sizeof((Option<BlockNumber>, Balance)) bytes and whose key size is sizeof((CollectionId, AccountId, AccountId)) bytes.

CollectionApprovalDeposit will be reserved from the owner on collection approval created. The same amount of reserved CollectionApprovalDeposit will be unreserved back to the owner on collection approval cancelled.

pub const NftsCollectionApprovalDeposit: Balance = deposit(1, 100);

How the bytes are calculated?

Key

  • Blake128Concat + CollectionId (u32) = 16 + 4
  • Blake128Concat + Account = 16 + 32
  • Blake128Concat + Account = 16 + 32

Value

  • Option = 1 + 8
  • Balance = 8

Key = 16 + 4 + 16 + 32 + 16 +32 = 116 bytes
Value = 1 + 8 + 8 = 17 bytes

Total bytes = 133 bytes

New storage items and related changes

  • AccountBalance: Keep track of the total number of collection items an account has. This storage item need to be updated on collection item transferred (fn do_transfer()), burnt (fn do_burn()) and minted (fn do_mint()).

Reason for the storage item?

A custom storage map has to be created because the owned_in_collection method in pallet-nfts is not optimised for the frequently used method PSP34::balance_of. The method has to read every account that is owned instead of a single read for the amount.

  • CollectionApprovals: Keep track of the collection approval status for a delegated account.

Reason for the storage item?

Inspired by Aleph Zero | PSP34 and Unique Network | pallet-nonfungibles.

First, no api or storage read is currently available to support the method PSP34::allowance. The scenario where item == None, it needs to return whether the operator is approved for all items within a given collection. Without the CollectionApprovals storage map this would require a storage read per item the owner owns in the collection.

Changes made to dispatchable functions

Introducing new dispatchable functions and update the pallet call indices of most of functions:

  • approve_collection_transfer: Approve collection items owned by the origin to be transferred by a delegated third-party account. This function reserves the required deposit CollectionApprovalDeposit from the origin account.
  • force_approve_collection_transfer: Force-approve collection items owned by the specified owner to be transferred by a delegated third-party account. This function reserves the required deposit CollectionApprovalDeposit from the origin account.
  • cancel_collection_approval: Cancel one of the collection approvals.
  • force_cancel_collection_approval: Force-cancel one of the collection approvals granted by the specified owner account. Returning the reserved funds to the delegate.
  • clear_all_collection_approvals: Cancel all the collection approvals. Returning the reserved funds to the delegate.
  • force_clear_all_collection_approvals: Force-cancel all the collection approvals granted by the specified owner account. Returning the reserved funds to the delegate.

check_collection_approval

// New method added.
fn check_collection_approval(collection: &T::CollectionId, account: &T::AccountId, delegate: &T::AccountId) -> DispatchResult 

Checks whether the delegate has the necessary allowance to transfer items in the collection that are owned by the account.

check_approval

// New method added.
fn check_approval(collection: &T::CollectionId, maybe_item: &Option<T::ItemId>, account: &T::AccountId, delegate: &T::AccountId) -> DispatchResult

Checks whether the delegate has the necessary allowance to transfer items within the collection or a specific item in the collection. If the delegate has an approval to transfer all items in the collection that are owned by the account, they can transfer every item without requiring explicit approval for that item.

  • If Item = None
Collection Approval Item Approval Status
True False True
True True True
False True False
False False False
  • If Item = Some
Collection Approval Item Approval Status
True False True
True True True
False True True
False False False

do_approve_collection_transfer

// New method added.
fn do_approve_collection_transfer(origin: T::AccountId, collection: T::CollectionId, delegate: T::AccountId, maybe_deadline: Option<BlockNumberFor<T>>) -> DispatchResult

NOTE: Weight diff before and after removing the CollectionApprovalCount

Store the new approval with deadline. Approving a delegate to transfer items owned by the signed origin in the collection will reserve some deposit amount (configured via T::CollectionApprovalDeposit) from the origin. With the reserved deposit, we don't need to worry about the unbounded storage map and the depositor is incentivised to remove the collection approval to unblock the collection from destruction.

do_cancel_collection_approval

pub(crate) fn do_cancel_collection_approval(origin: T::AccountId, collection: T::CollectionId, delegate: T::AccountId) -> DispatchResult 

NOTE: Weight diff before and after removing the CollectionApprovalCount

Cancels the transfer of items in the collection that owned by the origin to a delegate. This method will remove the collection approval granted to a delegate and unreserve the deposited fund to the delegate.

do_clear_all_collection_approvals

NOTE: Weight diff before and after removing the CollectionApprovalCount

// New method added.
fn do_clear_all_collection_approvals(origin: T::AccountId, collection: T::CollectionId, limit: u32) -> Result<u32, DispatchError> 

This function is used to clear limit collection approvals for the collection items of owner. After clearing all approvals, the deposit of each collection approval is returned to the owner account and the ApprovalsCancelled event is emitted.

ApprovalsCancelled is a new event type emitted when multiple approvals of a collection or item were cancelled.

Weight of this method is calculated by the provided limit.

do_destroy_collection

// Changes made to the existing methods.
pub fn do_destroy_collection(collection: T::CollectionId, witness: DestroyWitness, maybe_check_owner: Option<T::AccountId>) -> Result<DestroyWitness, DispatchError>;

NOTE: Weight diff before and after removing the CollectionApprovalCount

To destroy a collection, all collection approvals must be removed first. Destroying a collection can only be called when there is no collection approval exists. If yes, requires the accounts that granted those collection approvals to remove all them first through new methods clear_all_collection_approvals or cancel_collection_approval. These methods unreserve the deposited funds back to the origin on called.

Introducing new error types

  • NoItemOwned: Account owns zero item in the collection.
  • DelegateApprovalConflict: Collection approval and item approval conflicts.
    • Thrown in do_cancel_approval() if there is an existing collection approval with key (collection, account, delegate).
    • Thrown in do_clear_all_transfer_approvals() if there are collection approvals exist. All collection approvals must be removed first before the method can be called.
  • CollectionApprovalsExist: There are collection approvals exist.
    • Thrown in do_destroy_collection() if there are collection approvals. All collection approvals must be removed first before the method can be called.

@chungquantin chungquantin self-assigned this Nov 19, 2024
@chungquantin chungquantin changed the title feat(nfts): adding new storage items to optimize performance feat(pallet-nfts): adding new storage items to optimize performance Nov 19, 2024
@codecov-commenter
Copy link

codecov-commenter commented Nov 19, 2024

Codecov Report

Attention: Patch coverage is 75.84270% with 430 lines in your changes missing coverage. Please review.

Project coverage is 70.44%. Comparing base (3476994) to head (6ef9b32).

Files with missing lines Patch % Lines
pallets/nfts/src/weights.rs 4.50% 403 Missing ⚠️
pallets/nfts/src/lib.rs 68.88% 4 Missing and 10 partials ⚠️
pallets/nfts/src/common_functions.rs 33.33% 6 Missing ⚠️
pallets/nfts/src/features/approvals.rs 96.72% 1 Missing and 3 partials ⚠️
pallets/nfts/src/benchmarking.rs 0.00% 1 Missing ⚠️
pallets/nfts/src/features/create_delete_item.rs 88.88% 0 Missing and 1 partial ⚠️
pallets/nfts/src/features/transfer.rs 88.88% 0 Missing and 1 partial ⚠️
@@            Coverage Diff             @@
##             main     #387      +/-   ##
==========================================
+ Coverage   68.41%   70.44%   +2.02%     
==========================================
  Files          70       70              
  Lines       11838    13109    +1271     
  Branches    11838    13109    +1271     
==========================================
+ Hits         8099     9234    +1135     
- Misses       3482     3603     +121     
- Partials      257      272      +15     
Files with missing lines Coverage Δ
...lets/nfts/src/features/create_delete_collection.rs 84.88% <100.00%> (+0.54%) ⬆️
pallets/nfts/src/mock.rs 100.00% <ø> (ø)
pallets/nfts/src/tests.rs 99.90% <100.00%> (+0.02%) ⬆️
runtime/devnet/src/lib.rs 5.53% <ø> (ø)
pallets/nfts/src/benchmarking.rs 85.79% <0.00%> (ø)
pallets/nfts/src/features/create_delete_item.rs 89.01% <88.88%> (-0.01%) ⬇️
pallets/nfts/src/features/transfer.rs 84.05% <88.88%> (+0.33%) ⬆️
pallets/nfts/src/features/approvals.rs 95.09% <96.72%> (+1.99%) ⬆️
pallets/nfts/src/common_functions.rs 71.42% <33.33%> (-8.58%) ⬇️
pallets/nfts/src/lib.rs 71.51% <68.88%> (-0.59%) ⬇️
... and 1 more

@chungquantin chungquantin linked an issue Nov 19, 2024 that may be closed by this pull request
20 tasks
Copy link
Collaborator

@Daanvdplas Daanvdplas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress but there are things that need more consideration. As for the comments that need discussion and decision making, please lead this effort. I would advise to tackle them one by one, separately, so that it is easiest for other team members to understand the problem. What is always hugely helpful is to research yourself and find all the possible solutions. Then provide the best solutions to the team to make a decision as effective as possible.

Besides my comments in the code you also have to reconsider the destroy process. Right now we are removing the AccountBalance and Allowances when destroying the collection. This should be done earlier in the burning process. One potential solution which you'd have to research more is burning is only possible when there is no allowance set for the item. As for the account balance, this should already be 0 as all the items should already be burned before destroying the collection. Note the changes you made for the curious implementation, if my suggestion is correct these have to be removed again.

Moreover, we might want to consider to change the destroy process like done in pallet assets. This needs discussion and decision making with the team as well.

Finally, I would really appreciate if we could separate this PR in three PRs:

  1. AccountBalance
  2. Allowances
  3. destroy

This will be much more effective. Another thing to look for; there are a lot of clippy warnings which has to be resolved.

pallets/nfts/src/common_functions.rs Outdated Show resolved Hide resolved
pallets/nfts/src/features/approvals.rs Outdated Show resolved Hide resolved
pallets/nfts/src/features/approvals.rs Show resolved Hide resolved
pallets/nfts/src/lib.rs Outdated Show resolved Hide resolved
pallets/nfts/src/features/transfer.rs Outdated Show resolved Hide resolved
@chungquantin
Copy link
Collaborator Author

Moreover, we might want to consider to change the destroy process like done in pallet assets

Why do I think we should not go with the implementation of destroy similar to pallet_assets? This requires us to make more changes to the audited pallet destroy_collection method, and also the benchmarking and the test. So I tried to keep the changes as minimal as I can

@chungquantin chungquantin changed the base branch from main to chungquantin/fix-nfts_clippy November 21, 2024 08:31
delegate.clone(),
None
),
Error::<Test>::NoItemOwned
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Test error thrown NoItemOwned if force-approving unknown collection.

@@ -693,6 +736,24 @@ pub mod pallet {
CollectionNotEmpty,
/// The witness data should be provided.
WitnessRequired,
/// The account owns zero items in the collection.
NoItemOwned,
Copy link
Collaborator Author

@chungquantin chungquantin Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delegate.clone(),
None
),
Error::<Test>::NoItemOwned
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Test error thrown NoItemOwned if force-approving a collection with zero items.

item: 42,
owner: account(2),
collection: collection_id,
item: Some(item_id),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Test AllApprovalsCancelled emitted on clear_all_transfer_approvals

owner: T::AccountId,
},
/// All approvals of a collection or item were cancelled.
AllApprovalsCancelled {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Test in:

CollectionApprovals::<Test>::iter_prefix((collection_id, owner.clone())).count(),
2
);
assert!(!events().contains(&Event::<Test>::ApprovalsCancelled {
Copy link
Collaborator Author

@chungquantin chungquantin Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: No event ApprovalsCancelled emitted if limit = 0 provided to clear_collection_approvals

clear_collection_approvals(origin.clone(), maybe_owner.clone(), collection_id, 10),
Ok(Some(WeightOf::<Test>::clear_collection_approvals(1)).into())
);
assert!(events().contains(&Event::<Test>::ApprovalsCancelled {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: ApprovalsCancelled emitted on clear_collection_approvals called.

/// All approvals of an item got cancelled.
AllApprovalsCancelled { collection: T::CollectionId, item: T::ItemId, owner: T::AccountId },
/// Multiple approvals of a collection or item were cancelled.
ApprovalsCancelled {
Copy link
Collaborator Author

@chungquantin chungquantin Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Tested in:

@chungquantin chungquantin force-pushed the chungquantin/feat-nfts branch from c756aab to 769f50a Compare December 12, 2024 02:20
clear_collection_approvals(origin.clone(), maybe_owner.clone(), collection_id, 10),
Ok(Some(WeightOf::<Test>::clear_collection_approvals(0)).into())
);
assert!(!events().contains(&Event::<Test>::ApprovalsCancelled {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Emitting no event ApprovalsCancelled if zero approvals removed.

item_id,
delegate.clone()
));
assert!(events().contains(&Event::<Test>::ApprovalCancelled {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Test emitting event ApprovalCancelled on cancel_approval successfully called.

ApprovalCancelled {
collection: T::CollectionId,
item: T::ItemId,
item: Option<T::ItemId>,
Copy link
Collaborator Author

@chungquantin chungquantin Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Tested in:

delegate.clone()
));
assert_eq!(Balances::reserved_balance(&item_owner), 0);
assert!(events().contains(&Event::<Test>::ApprovalCancelled {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Test emitting event ApprovalCancelled on cancel_collection_approval successfully called.

CollectionApprovals::<T, I>::try_mutate_exists(
(&collection, &owner, &delegate),
|maybe_approval| -> DispatchResult {
let deposit_required = T::CollectionApprovalDeposit::get();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Reserve deposit CollectionApprovalDeposit on collection approval approved.

// Key: `sizeof((CollectionId, AccountId, AccountId))` bytes.
// Value: `sizeof((Option<BlockNumber>, Balance))` bytes.
#[pallet::constant]
type CollectionApprovalDeposit: Get<DepositBalanceOf<Self, I>>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Used in

assert_eq!(
events().last_chunk::<3>(),
Some(&[
Event::TransferApproved {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Approving multiple accounts emit TransferApproved events

@@ -76,12 +77,11 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {

Self::deposit_event(Event::TransferApproved {
collection,
item,
item: Some(item),
Copy link
Collaborator Author

@chungquantin chungquantin Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -465,21 +498,31 @@ pub mod pallet {
/// a `delegate`.
TransferApproved {
collection: T::CollectionId,
item: T::ItemId,
item: Option<T::ItemId>,
Copy link
Collaborator Author

@chungquantin chungquantin Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delegate.clone(),
None
));
assert!(events().contains(&Event::<Test>::TransferApproved {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Emitting event TransferApproved on approve_collection_transfer_works

fn integrity_test() {
use core::any::TypeId;
assert!(
TypeId::of::<<T as Config<I>>::ItemId>() != TypeId::of::<u64>() &&
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Context for this integration test is to make sure ItemId won't exceed the u32 type. If so, the overflow case can happen because the type used in AccountBalance is u32.

@chungquantin chungquantin force-pushed the chungquantin/feat-nfts branch from 769f50a to fafb988 Compare December 12, 2024 02:39
@chungquantin
Copy link
Collaborator Author

My bad for not using Start Review but single comments. Here are opened review comments:

@chungquantin
Copy link
Collaborator Author

[sc-1606]

@Daanvdplas Daanvdplas self-requested a review December 12, 2024 13:31
Copy link
Collaborator

@Daanvdplas Daanvdplas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few nitpicks and made a refactor myself to fasten the process a little bit: #405

pallets/nfts/README.md Outdated Show resolved Hide resolved
pallets/nfts/src/benchmarking.rs Outdated Show resolved Hide resolved
pallets/nfts/src/benchmarking.rs Show resolved Hide resolved
pallets/nfts/src/benchmarking.rs Outdated Show resolved Hide resolved
pallets/nfts/src/benchmarking.rs Outdated Show resolved Hide resolved
pallets/nfts/src/features/approvals.rs Show resolved Hide resolved
pallets/nfts/src/tests.rs Outdated Show resolved Hide resolved
);

// Force-approve unknown collection, throws error `Error::NoItemOwned`.
assert_noop!(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the point of testing this twice, it is against the whole purpose of merging the tests together?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runtime/devnet/src/config/assets.rs Outdated Show resolved Hide resolved
runtime/testnet/src/config/assets.rs Outdated Show resolved Hide resolved
@chungquantin chungquantin force-pushed the chungquantin/feat-nfts branch from 07c9645 to abc54e8 Compare December 12, 2024 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feat(pop-api): nonfungibles use case
4 participants