From bab3ce23f97c1c52a1a0800c53eaf4c5aa875adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Crnkovi=C4=87?= Date: Fri, 13 Oct 2023 10:36:45 +0200 Subject: [PATCH 01/14] refactor: fetch activity for collection as opposed to individual nft (#217) --- .../Commands/FetchCollectionActivity.php | 53 +++ app/Console/Commands/FetchNftActivity.php | 67 --- app/Console/Kernel.php | 6 + app/Data/Gallery/GalleryNftData.php | 2 +- app/Data/Nfts/NftData.php | 2 +- ...NftTransfer.php => CollectionActivity.php} | 15 +- .../Mnemonic/MnemonicPendingRequest.php | 72 ++- app/Http/Controllers/CollectionController.php | 30 +- app/Http/Controllers/NftController.php | 6 - .../Controllers/RefreshedNftController.php | 4 +- app/Jobs/FetchCollectionActivity.php | 152 ++++++ app/Jobs/FetchNftActivity.php | 120 ----- app/Jobs/RefreshNftMetadata.php | 1 - app/Models/Collection.php | 16 +- app/Models/Nft.php | 8 +- app/Models/NftActivity.php | 4 +- .../Mnemonic/MnemonicWeb3DataProvider.php | 11 +- app/Support/Facades/Mnemonic.php | 4 +- app/Support/Web3NftHandler.php | 5 + config/dashbrd.php | 6 + database/factories/NftActivityFactory.php | 2 - ...en_number_column_to_nft_activity_table.php | 29 ++ ...updated_at_column_to_collections_table.php | 23 + resources/js/Pages/Collections/View.tsx | 4 +- resources/types/generated.d.ts | 25 +- .../Commands/FetchCollectionActivityTest.php | 61 +++ .../Console/Commands/FetchNftActivityTest.php | 39 -- .../Mnemonic/MnemonicPendingRequestTest.php | 71 +-- .../Http/Controllers/NftControllerTest.php | 3 +- .../App/Jobs/FetchCollectionActivityTest.php | 436 ++++++++++++++++++ tests/App/Jobs/FetchNftActivityTest.php | 125 ----- tests/App/Models/CollectionTest.php | 24 +- tests/App/Models/NftActivityTest.php | 15 +- tests/App/Models/NftTest.php | 74 ++- .../Mnemonic/MnemonicWeb3DataProviderTest.php | 19 + 35 files changed, 1022 insertions(+), 512 deletions(-) create mode 100644 app/Console/Commands/FetchCollectionActivity.php delete mode 100644 app/Console/Commands/FetchNftActivity.php rename app/Data/Web3/{Web3NftTransfer.php => CollectionActivity.php} (64%) create mode 100644 app/Jobs/FetchCollectionActivity.php delete mode 100644 app/Jobs/FetchNftActivity.php create mode 100644 database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php create mode 100644 database/migrations/2023_09_11_071352_add_activity_updated_at_column_to_collections_table.php create mode 100644 tests/App/Console/Commands/FetchCollectionActivityTest.php delete mode 100644 tests/App/Console/Commands/FetchNftActivityTest.php create mode 100644 tests/App/Jobs/FetchCollectionActivityTest.php delete mode 100644 tests/App/Jobs/FetchNftActivityTest.php diff --git a/app/Console/Commands/FetchCollectionActivity.php b/app/Console/Commands/FetchCollectionActivity.php new file mode 100644 index 000000000..f1de54d4f --- /dev/null +++ b/app/Console/Commands/FetchCollectionActivity.php @@ -0,0 +1,53 @@ + */ + return $query->where('is_fetching_activity', false) + ->whereNotNull('supply') + ->where('supply', '<=', config('dashbrd.collections_max_cap')); + }; + + $this->forEachCollection(function ($collection) { + Job::dispatch($collection); + }, $queryCallback); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/FetchNftActivity.php b/app/Console/Commands/FetchNftActivity.php deleted file mode 100644 index 139cdcb9e..000000000 --- a/app/Console/Commands/FetchNftActivity.php +++ /dev/null @@ -1,67 +0,0 @@ -option('nft-id'); - - if ($nftId !== null) { - $nft = Nft::findOrFail($nftId); - - $this->handleNft($nft); - } else { - $this->handleOwnedNfts(); - } - - return Command::SUCCESS; - } - - // @TODO enable this line once scalability issue of all NFT activities handled - // private function handleAllNfts(): void - // { - // Nft::latest() - // ->chunk(100, function ($nfts) { - // $nfts->each(fn ($nft) => $this->handleNft($nft)); - // }); - // } - - private function handleOwnedNfts(): void - { - Nft::query() - ->whereNotNull('wallet_id') - ->chunkById(100, function ($nfts) { - $nfts->each(fn ($nft) => $this->handleNft($nft)); - }); - } - - private function handleNft(Nft $nft): void - { - Job::dispatch($nft); - } -} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 6cdb4ec64..837a010dd 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,6 +5,7 @@ namespace App\Console; use App\Console\Commands\FetchCoingeckoTokens; +use App\Console\Commands\FetchCollectionActivity; use App\Console\Commands\FetchCollectionBannerBatch; use App\Console\Commands\FetchCollectionFloorPrice; use App\Console\Commands\FetchCollectionMetadata; @@ -105,6 +106,11 @@ private function scheduleJobsForCollectionsOrGalleries(Schedule $schedule): void ->withoutOverlapping() ->hourly(); + $schedule + ->command(FetchCollectionActivity::class) + ->withoutOverlapping() + ->weeklyOn(Schedule::MONDAY); + $schedule ->command(FetchCollectionFloorPrice::class) ->withoutOverlapping() diff --git a/app/Data/Gallery/GalleryNftData.php b/app/Data/Gallery/GalleryNftData.php index c486cc110..f77182449 100644 --- a/app/Data/Gallery/GalleryNftData.php +++ b/app/Data/Gallery/GalleryNftData.php @@ -58,7 +58,7 @@ public static function fromModel(Nft $nft, bool $ownedByCurrentUser = false): se floorPrice: $collection->floor_price, floorPriceCurrency: $collection->floorPriceToken?->symbol, floorPriceDecimals: $collection->floorPriceToken?->decimals, - lastActivityFetchedAt: $nft->last_activity_fetched_at, + lastActivityFetchedAt: $collection->activity_updated_at, lastViewedAt: $nft->last_viewed_at, ownedByCurrentUser: $ownedByCurrentUser, ); diff --git a/app/Data/Nfts/NftData.php b/app/Data/Nfts/NftData.php index f303739b4..afac2a37c 100644 --- a/app/Data/Nfts/NftData.php +++ b/app/Data/Nfts/NftData.php @@ -37,7 +37,7 @@ public static function fromModel(Nft $nft): self images: NftImagesData::from($nft->images()), wallet: $nft->wallet_id ? NftWalletData::fromModel($nft->wallet) : null, lastViewedAt: $nft->last_viewed_at, - lastActivityFetchedAt: $nft->last_activity_fetched_at, + lastActivityFetchedAt: $nft->collection->activity_updated_at, ); } } diff --git a/app/Data/Web3/Web3NftTransfer.php b/app/Data/Web3/CollectionActivity.php similarity index 64% rename from app/Data/Web3/Web3NftTransfer.php rename to app/Data/Web3/CollectionActivity.php index c274075c4..39aaadb67 100644 --- a/app/Data/Web3/Web3NftTransfer.php +++ b/app/Data/Web3/CollectionActivity.php @@ -8,7 +8,7 @@ use Carbon\Carbon; use Spatie\LaravelData\Data; -class Web3NftTransfer extends Data +class CollectionActivity extends Data { /** * @param array $extraAttributes @@ -19,11 +19,22 @@ public function __construct( public string $sender, public string $recipient, public string $txHash, - public NftTransferType $type, + public string $logIndex, + public ?NftTransferType $type, public Carbon $timestamp, public ?float $totalNative, public ?float $totalUsd, public array $extraAttributes, ) { } + + public function key(): string + { + return implode(':', [ + $this->txHash, + $this->logIndex, + $this->tokenId, + $this->type->value, + ]); + } } diff --git a/app/Http/Client/Mnemonic/MnemonicPendingRequest.php b/app/Http/Client/Mnemonic/MnemonicPendingRequest.php index fde488bd6..45cc01ffa 100644 --- a/app/Http/Client/Mnemonic/MnemonicPendingRequest.php +++ b/app/Http/Client/Mnemonic/MnemonicPendingRequest.php @@ -4,9 +4,9 @@ namespace App\Http\Client\Mnemonic; +use App\Data\Web3\CollectionActivity; use App\Data\Web3\Web3NftCollectionFloorPrice; use App\Data\Web3\Web3NftCollectionTrait; -use App\Data\Web3\Web3NftTransfer; use App\Enums\Chains; use App\Enums\CryptoCurrencyDecimals; use App\Enums\CurrencyCode; @@ -346,24 +346,23 @@ private function fetchCollectionTraits(Chains $chain, string $contractAddress, s /** * @see https://docs.mnemonichq.com/reference/foundationalservice_getnfttransfers * - * @return Collection + * @return Collection */ - public function getNftActivity(Chains $chain, string $contractAddress, string $tokenId, int $limit, Carbon $from = null): Collection + public function getCollectionActivity(Chains $chain, string $contractAddress, int $limit, Carbon $from = null): Collection { $this->chain = MnemonicChain::fromChain($chain); - // grab the ETH token regardless of the chain, because always want to report prices in ETH - $ethToken = Token::whereHas('network', fn ($query) => $query - ->where('chain_id', Chains::ETH->value) - ->where('is_mainnet', true) - )->firstOrFail(); + // Grab the ETH token regardless of the chain, because always want to report prices in ETH... + $ethToken = Token::query() + ->whereHas('network', fn ($query) => $query->where('chain_id', Chains::ETH->value)) + ->where('is_native_token', true) + ->firstOrFail(); $query = [ 'limit' => $limit, // Oldest first 'sortDirection' => 'SORT_DIRECTION_ASC', 'contractAddress' => $contractAddress, - 'tokenId' => $tokenId, ]; // I cant pass the `labelsAny` filter directly to the query array because @@ -382,36 +381,31 @@ public function getNftActivity(Chains $chain, string $contractAddress, string $t /** @var array $data */ $data = self::get(sprintf('/foundational/v1beta2/transfers/nft?%s', $labelsQuery), $query)->json('nftTransfers'); - return collect($data) - // Sometimes the request is returning transfers that are not labeled - // as any of the values we expect, I was, for example getting - // `LABEL_BURN` transfers, so I am filtering them out here. - // In the future we may want to add support for them. - ->filter(fn ($transfer) => $this->extractNftTransferType($transfer['labels']) !== null) - ->map(function ($transfer) use ($chain, $contractAddress, $tokenId, $ethToken) { - $currency = CurrencyCode::USD; - - $blockchainTimestamp = Carbon::parse($transfer['blockchainEvent']['blockTimestamp']); - $prices = $this->extractActivityPrices($chain, $transfer, $currency, $ethToken, $blockchainTimestamp); - - return new Web3NftTransfer( - contractAddress: $contractAddress, - tokenId: $tokenId, - sender: $transfer['sender']['address'], - recipient: $transfer['recipient']['address'], - txHash: $transfer['blockchainEvent']['txHash'], - type: $this->extractNftTransferType($transfer['labels']), - timestamp: $blockchainTimestamp, - totalNative: $prices['native'], - totalUsd: $prices['usd'], - extraAttributes: [ - 'recipient' => Arr::get($transfer, 'recipient'), - 'recipientPaid' => Arr::get($transfer, 'recipientPaid'), - 'sender' => Arr::get($transfer, 'sender'), - 'senderReceived' => Arr::get($transfer, 'senderReceived'), - ] - ); - })->values(); + return collect($data)->map(function ($transfer) use ($chain, $contractAddress, $ethToken) { + $currency = CurrencyCode::USD; + + $blockchainTimestamp = Carbon::parse($transfer['blockchainEvent']['blockTimestamp']); + $prices = $this->extractActivityPrices($chain, $transfer, $currency, $ethToken, $blockchainTimestamp); + + return new CollectionActivity( + contractAddress: $contractAddress, + tokenId: $transfer['tokenId'], + sender: $transfer['sender']['address'], + recipient: $transfer['recipient']['address'], + txHash: $transfer['blockchainEvent']['txHash'], + logIndex: $transfer['blockchainEvent']['logIndex'], + type: $this->extractNftTransferType($transfer['labels']), + timestamp: $blockchainTimestamp, + totalNative: $prices['native'], + totalUsd: $prices['usd'], + extraAttributes: [ + 'recipient' => Arr::get($transfer, 'recipient'), + 'recipientPaid' => Arr::get($transfer, 'recipientPaid'), + 'sender' => Arr::get($transfer, 'sender'), + 'senderReceived' => Arr::get($transfer, 'senderReceived'), + ] + ); + })->values(); } /** diff --git a/app/Http/Controllers/CollectionController.php b/app/Http/Controllers/CollectionController.php index c137730eb..1f3271340 100644 --- a/app/Http/Controllers/CollectionController.php +++ b/app/Http/Controllers/CollectionController.php @@ -202,17 +202,21 @@ public function show(Request $request, Collection $collection): Response $tab = $request->get('tab') === 'activity' ? 'activity' : 'collection'; - $activities = $collection->activities() - ->latest('timestamp') - ->where('type', '!=', NftTransferType::Transfer) - ->paginate($activityPageLimit) - ->appends([ - 'tab' => 'activity', - 'activityPageLimit' => $activityPageLimit, - ]); - - /** @var PaginatedDataCollection */ - $paginated = NftActivityData::collection($activities); + // TODO: enable when we enable the "Activity" tab (https://app.clickup.com/t/862kftp7w)... + // $activities = $collection->activities() + // ->latest('timestamp') + // ->with(['nft' => fn ($q) => $q->where('collection_id', $collection->id)]) + // ->whereHas('nft', fn ($q) => $q->where('collection_id', $collection->id)) + // ->where('type', '!=', NftTransferType::Transfer) + // ->paginate($activityPageLimit) + // ->appends([ + // 'tab' => 'activity', + // 'activityPageLimit' => $activityPageLimit, + // ]); + + // TODO: enable when we enable the "Activity" tab (https://app.clickup.com/t/862kftp7w)... + // /** @var PaginatedDataCollection */ + // $paginated = NftActivityData::collection($activities); $ownedNftIds = $user ? $collection->nfts()->ownedBy($user)->pluck('id') @@ -230,7 +234,9 @@ public function show(Request $request, Collection $collection): Response $currency = $user ? $user->currency() : CurrencyCode::USD; return Inertia::render('Collections/View', [ - 'activities' => new NftActivitiesData($paginated), + // TODO: enable when we enable the "Activity" tab (https://app.clickup.com/t/862kftp7w)... + // 'activities' => new NftActivitiesData($paginated), + 'activities' => null, 'collection' => CollectionDetailData::fromModel($collection, $currency, $user), 'isHidden' => $user && $user->hiddenCollections()->where('id', $collection->id)->exists(), 'previousUrl' => url()->previous() === url()->current() diff --git a/app/Http/Controllers/NftController.php b/app/Http/Controllers/NftController.php index f58738b9c..a6eff5105 100644 --- a/app/Http/Controllers/NftController.php +++ b/app/Http/Controllers/NftController.php @@ -10,7 +10,6 @@ use App\Data\Nfts\NftActivityData; use App\Data\Nfts\NftData; use App\Data\Token\TokenData; -use App\Jobs\FetchNftActivity; use App\Models\Collection; use App\Models\Nft; use App\Models\User; @@ -28,11 +27,6 @@ public function show(Request $request, Collection $collection, Nft $nft): Respon $nativeToken = $collection->network->tokens()->nativeToken()->defaultToken()->first(); - // Dispatch every 3 days... - if (! $nft->last_activity_fetched_at || ! $nft->last_viewed_at || now() > $nft->last_viewed_at->addDays(3)) { - FetchNftActivity::dispatch($nft); - } - $nft->touch('last_viewed_at'); return Inertia::render('Collections/Nfts/View', [ diff --git a/app/Http/Controllers/RefreshedNftController.php b/app/Http/Controllers/RefreshedNftController.php index 575a65466..38b7902ce 100644 --- a/app/Http/Controllers/RefreshedNftController.php +++ b/app/Http/Controllers/RefreshedNftController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers; -use App\Jobs\FetchNftActivity; +use App\Jobs\FetchCollectionActivity; use App\Jobs\RefreshNftMetadata; use App\Models\Collection; use App\Models\Nft; @@ -21,7 +21,7 @@ public function __invoke(Collection $collection, Nft $nft): JsonResponse // It's supposed to be completely opaque to the user what the "refresh" is doing. RefreshNftMetadata::dispatch($collection, $nft)->onQueue(Queues::NFTS); - FetchNftActivity::dispatch($nft); + FetchCollectionActivity::dispatch($collection)->onQueue(Queues::NFTS); return response()->json([ 'success' => true, diff --git a/app/Jobs/FetchCollectionActivity.php b/app/Jobs/FetchCollectionActivity.php new file mode 100644 index 000000000..c7fb03cb8 --- /dev/null +++ b/app/Jobs/FetchCollectionActivity.php @@ -0,0 +1,152 @@ +onQueue(Queues::SCHEDULED_NFTS); + } + + /** + * Execute the job. + */ + public function handle(MnemonicWeb3DataProvider $provider): void + { + if (! config('dashbrd.features.activities') || $this->shouldIgnoreCollection()) { + return; + } + + if ($this->collection->isInvalid()) { + return; + } + + if ($this->collection->is_fetching_activity && ! $this->forced) { + return; + } + + $this->collection->update([ + 'is_fetching_activity' => true, + ]); + + $activities = $provider->getCollectionActivity( + chain: $this->collection->network->chain(), + contractAddress: $this->collection->address, + limit: static::LIMIT, + from: $this->latestActivityTimestamp(), + ); + + if ($activities->isEmpty()) { + $this->collection->update([ + 'is_fetching_activity' => false, + 'activity_updated_at' => now(), + ]); + + return; + } + + $formattedActivities = $activities + // Sometimes the request is returning transfers that are not labeled as any of the values we expect. + // There were times when Mnemonic returned `LABEL_BURN` transfers, so we're filtering them here. + ->reject(fn ($activity) => $activity->type === null) + ->unique->key() + ->map(fn (CollectionActivity $activity) => [ + 'collection_id' => $this->collection->id, + 'token_number' => $activity->tokenId, + 'type' => $activity->type->value, + 'sender' => $activity->sender, + 'recipient' => $activity->recipient, + 'tx_hash' => $activity->txHash, + 'log_index' => $activity->logIndex, + 'timestamp' => $activity->timestamp, + 'total_native' => $activity->totalNative, + 'total_usd' => $activity->totalUsd, + 'extra_attributes' => json_encode($activity->extraAttributes), + ])->toArray(); + + if (count($formattedActivities) === 0) { + $this->collection->update([ + 'is_fetching_activity' => false, + 'activity_updated_at' => now(), + ]); + + return; + } + + DB::transaction(function () use ($formattedActivities, $activities) { + NftActivity::upsert($formattedActivities, uniqueBy: ['tx_hash', 'log_index', 'collection_id', 'token_number', 'type']); + + // If we get the limit it may be that there are more activities to fetch... + if (static::LIMIT === count($activities)) { + self::dispatch($this->collection, forced: true)->afterCommit(); + } else { + $this->collection->update([ + 'is_fetching_activity' => false, + 'activity_updated_at' => now(), + ]); + } + }, attempts: 5); + } + + private function latestActivityTimestamp(): ?Carbon + { + return $this->collection + ->activities() + ->latest('timestamp') + ->value('timestamp'); + } + + private function shouldIgnoreCollection(): bool + { + /** + * @var string[] + */ + $blacklisted = config('dashbrd.activity_blacklist', []); + + return collect($blacklisted) + ->map(fn ($collection) => Str::lower($collection)) + ->contains(Str::lower($this->collection->address)); + } + + public function onFailure(Throwable $exception): void + { + $this->collection->update([ + 'is_fetching_activity' => false, + 'activity_updated_at' => now(), + ]); + } + + public function retryUntil(): DateTime + { + return now()->addMinutes(10); // This is retry PER JOB (i.e. per request)... + } +} diff --git a/app/Jobs/FetchNftActivity.php b/app/Jobs/FetchNftActivity.php deleted file mode 100644 index d78e7cb67..000000000 --- a/app/Jobs/FetchNftActivity.php +++ /dev/null @@ -1,120 +0,0 @@ -onQueue(Queues::SCHEDULED_NFTS); - } - - public function uniqueId(): string - { - return 'fetch-nft-activty:'.$this->nft->id; - } - - /** - * Execute the job. - */ - public function handle(): void - { - $collection = $this->nft->collection; - - Log::info('FetchNftActivity Job: Processing', [ - 'collection_name' => $collection->name, - 'collection_address' => $collection->address, - 'token_number' => $this->nft->token_number, - ]); - - if ($collection->isInvalid()) { - Log::info('FetchNftActivity Job: Ignored, Collection Invalid', [ - 'token_number' => $this->nft->token_number, - 'collection_name' => $collection->name, - 'collection_address' => $collection->address, - ]); - - return; - } - - $limit = 500; - - $latestActivityDate = $this->nft->activities()->latest('timestamp')->first()?->timestamp; - - $tokenId = $this->nft->token_number; - - $chainId = $collection->network->chain_id; - - $contractAddress = $collection->address; - - $nftActivity = Mnemonic::getNftActivity( - chain: Chains::from($chainId), - contractAddress: $contractAddress, - tokenId: $tokenId, - limit: $limit, - from: $latestActivityDate - ); - - $upsertedCount = NftActivity::upsert( - $nftActivity->map(function (Web3NftTransfer $activity) { - return [ - 'nft_id' => $this->nft->id, - 'type' => $activity->type->value, - 'sender' => $activity->sender, - 'recipient' => $activity->recipient, - 'tx_hash' => $activity->txHash, - 'timestamp' => $activity->timestamp, - 'total_native' => $activity->totalNative, - 'total_usd' => $activity->totalUsd, - 'extra_attributes' => json_encode($activity->extraAttributes), - ]; - })->toArray(), - ['tx_hash', 'nft_id', 'type'], - ['sender', 'recipient', 'timestamp', 'total_native', 'total_usd', 'extra_attributes'] - ); - - // If we get the limit it may be that there are more activities to fetch - if ($limit === $nftActivity->count()) { - FetchNftActivity::dispatch($this->nft)->onQueue(Queues::SCHEDULED_WALLET_NFTS); - } - - Log::info('FetchNftActivity Job: Handled', [ - 'collection_name' => $collection->name, - 'collection_address' => $collection->address, - 'network' => $collection->network->id, - 'token_number' => $this->nft->token_number, - 'upserted_activities_count' => $upsertedCount, - 'dispatched_for_more' => $limit === $nftActivity->count(), - ]); - - $this->nft->touch('last_activity_fetched_at'); - } - - public function retryUntil(): DateTime - { - return now()->addHours(2); // This job runs every day so we have some room to allow it to run longer... - } -} diff --git a/app/Jobs/RefreshNftMetadata.php b/app/Jobs/RefreshNftMetadata.php index 143cf0314..523593a51 100644 --- a/app/Jobs/RefreshNftMetadata.php +++ b/app/Jobs/RefreshNftMetadata.php @@ -40,7 +40,6 @@ public function __construct( */ public function handle(AlchemyWeb3DataProvider $provider): void { - if (SpamContract::isSpam($this->collection->address, $this->collection->network)) { Log::info('RefreshNftMetadata Job: Ignored for spam contract', [ 'address' => $this->collection->address, diff --git a/app/Models/Collection.php b/app/Models/Collection.php index b9b544ebc..bbe47b4ee 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Query\Expression; @@ -57,6 +56,8 @@ class Collection extends Model 'minted_block' => 'int', 'minted_at' => 'datetime', 'last_viewed_at' => 'datetime', + 'is_fetching_activity' => 'bool', + 'activity_updated_at' => 'datetime', ]; /** @@ -255,7 +256,7 @@ public function scopeOrderByReceivedDate(Builder $query, Wallet $wallet, string // Get the latest timestamp for each NFT $subselect = sprintf("SELECT timestamp FROM nft_activity - WHERE nft_activity.nft_id = nfts.id AND lower(recipient) = '%s' + WHERE nft_activity.collection_id = nfts.collection_id AND nft_activity.token_number = nfts.token_number AND lower(recipient) = '%s' -- Latest timestamp ORDER BY timestamp desc LIMIT 1", strtolower($wallet->address)); @@ -472,11 +473,11 @@ public static function updateFiatValue(array $collectionIds = []): void } /** - * @return HasManyThrough + * @return HasMany */ - public function activities(): HasManyThrough + public function activities(): HasMany { - return $this->hasManyThrough(NftActivity::class, Nft::class); + return $this->hasMany(NftActivity::class); } public function recentlyViewed(): bool @@ -563,4 +564,9 @@ public function scopeOrderByOldestNftLastFetchedAt(Builder $query): Builder { return $query->orderByRaw('extra_attributes->>\'nft_last_fetched_at\' ASC NULLS FIRST'); } + + public function isSpam(): bool + { + return SpamContract::isSpam($this->address, $this->network); + } } diff --git a/app/Models/Nft.php b/app/Models/Nft.php index 484a0041d..a22a0e159 100644 --- a/app/Models/Nft.php +++ b/app/Models/Nft.php @@ -89,7 +89,9 @@ public function traits(): BelongsToMany */ public function activities(): HasMany { - return $this->hasMany(NftActivity::class); + return $this->hasMany( + NftActivity::class, foreignKey: 'token_number', localKey: 'token_number' + )->where('collection_id', $this->collection_id); } /** @@ -199,7 +201,7 @@ public function scopeOrderByMintDate(Builder $query, string $direction = 'asc'): $select = " SELECT timestamp FROM nft_activity - WHERE nft_activity.nft_id = nfts.id AND type = '".NftTransferType::Mint->value."' + WHERE nft_activity.collection_id = nfts.collection_id AND nft_activity.token_number = nfts.token_number AND type = '".NftTransferType::Mint->value."' ORDER BY timestamp DESC "; @@ -221,7 +223,7 @@ public function scopeOrderByReceivedDate(Builder $query, string $direction = 'as $select = " SELECT timestamp FROM nft_activity - WHERE nft_activity.nft_id = nfts.id AND type = '".NftTransferType::Transfer->value."' + WHERE nft_activity.collection_id = nfts.collection_id AND nft_activity.token_number = nfts.token_number AND type = '".NftTransferType::Transfer->value."' ORDER BY timestamp DESC LIMIT 1 "; diff --git a/app/Models/NftActivity.php b/app/Models/NftActivity.php index 8c38ee61c..5a0ec9a8f 100644 --- a/app/Models/NftActivity.php +++ b/app/Models/NftActivity.php @@ -44,6 +44,8 @@ class NftActivity extends Model */ public function nft(): BelongsTo { - return $this->belongsTo(Nft::class); + return $this->belongsTo(Nft::class, 'token_number', 'token_number')->when( + $this->collection_id !== null, fn ($q) => $q->where('collection_id', $this->collection_id) + ); } } diff --git a/app/Services/Web3/Mnemonic/MnemonicWeb3DataProvider.php b/app/Services/Web3/Mnemonic/MnemonicWeb3DataProvider.php index e08307e04..e1ff2cae5 100644 --- a/app/Services/Web3/Mnemonic/MnemonicWeb3DataProvider.php +++ b/app/Services/Web3/Mnemonic/MnemonicWeb3DataProvider.php @@ -4,6 +4,7 @@ namespace App\Services\Web3\Mnemonic; +use App\Data\Web3\CollectionActivity; use App\Data\Web3\Web3NftCollectionFloorPrice; use App\Data\Web3\Web3NftsChunk; use App\Enums\Chains; @@ -19,7 +20,7 @@ use Carbon\Carbon; use Illuminate\Support\Collection; -final class MnemonicWeb3DataProvider extends AbstractWeb3DataProvider +class MnemonicWeb3DataProvider extends AbstractWeb3DataProvider { use LoadsFromCache; @@ -56,6 +57,14 @@ public function getNftCollectionFloorPrice(Chains $chain, string $contractAddres ); } + /** + * @return Collection + */ + public function getCollectionActivity(Chains $chain, string $contractAddress, int $limit, Carbon $from = null): Collection + { + return Mnemonic::getCollectionActivity($chain, $contractAddress, $limit, $from); + } + public function getBlockTimestamp(Network $network, int $blockNumber): Carbon { throw new NotImplementedException(); diff --git a/app/Support/Facades/Mnemonic.php b/app/Support/Facades/Mnemonic.php index 319d8baab..716a98745 100644 --- a/app/Support/Facades/Mnemonic.php +++ b/app/Support/Facades/Mnemonic.php @@ -4,9 +4,9 @@ namespace App\Support\Facades; +use App\Data\Web3\CollectionActivity; use App\Data\Web3\Web3NftCollectionFloorPrice; use App\Data\Web3\Web3NftCollectionTrait; -use App\Data\Web3\Web3NftTransfer; use App\Enums\Chains; use App\Http\Client\Mnemonic\MnemonicFactory; use App\Models\Network; @@ -22,7 +22,7 @@ * @method static int | null getNftCollectionOwners(Chains $chain, string $contractAddress) * @method static string | null getNftCollectionVolume(Chains $chain, string $contractAddress) * @method static Collection getNftCollectionTraits(Chains $chain, string $contractAddress) - * @method static Collection getNftActivity(Chains $chain, string $contractAddress, string $tokenId, int $limit, ?Carbon $from = null) + * @method static Collection getCollectionActivity(Chains $chain, string $contractAddress, int $limit, ?Carbon $from = null) * * @see App\Http\Client\Mnemonic\MnemonicPendingRequest */ diff --git a/app/Support/Web3NftHandler.php b/app/Support/Web3NftHandler.php index 6b9dc6031..4ce6d5d71 100644 --- a/app/Support/Web3NftHandler.php +++ b/app/Support/Web3NftHandler.php @@ -7,6 +7,7 @@ use App\Data\Web3\Web3NftData; use App\Enums\Features; use App\Jobs\DetermineCollectionMintingDate; +use App\Jobs\FetchCollectionActivity; use App\Jobs\FetchCollectionFloorPrice; use App\Models\Collection as CollectionModel; use App\Models\CollectionTrait; @@ -167,6 +168,10 @@ public function store(Collection $nfts, bool $dispatchJobs = false): void }); if (Feature::active(Features::Collections->value)) { + CollectionModel::where('is_fetching_activity', false)->whereIn('id', $ids)->chunkById(100, function ($collections) { + $collections->each(fn ($collection) => FetchCollectionActivity::dispatch($collection)->onQueue(Queues::NFTS)); + }); + $nftsGroupedByCollectionAddress->filter(fn (Web3NftData $nft) => $nft->mintedAt === null)->each(function (Web3NftData $nft) { DetermineCollectionMintingDate::dispatch($nft)->onQueue(Queues::NFTS); }); diff --git a/config/dashbrd.php b/config/dashbrd.php index 0bfd2e587..3b2d13940 100644 --- a/config/dashbrd.php +++ b/config/dashbrd.php @@ -11,6 +11,7 @@ 'portfolio' => env('PORTFOLIO_ENABLED', true), 'galleries' => env('GALLERIES_ENABLED', true), 'collections' => env('COLLECTIONS_ENABLED', true), + 'activities' => env('ACTIVITIES_ENABLED', false), ], 'testing_wallet' => env('LOCAL_TESTING_ADDRESS'), @@ -200,6 +201,11 @@ '0x495f947276749ce646f68ac8c248420045cb7b5e', // OpenSea Shared Storefront ], + 'activities_blacklist' => [ + '0xba6666b118f8303f990f3519df07e160227cce87', // Planet IX - Assets + '0x22d5f9b75c524fec1d6619787e582644cd4d7422', // Sunflower Land Collectibles + ], + 'test_tokens' => [ 'TIC', 'USDC', diff --git a/database/factories/NftActivityFactory.php b/database/factories/NftActivityFactory.php index 0bf466a1a..ff3401e17 100644 --- a/database/factories/NftActivityFactory.php +++ b/database/factories/NftActivityFactory.php @@ -5,7 +5,6 @@ namespace Database\Factories; use App\Enums\NftTransferType; -use App\Models\Nft; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -21,7 +20,6 @@ class NftActivityFactory extends Factory public function definition(): array { return [ - 'nft_id' => fn () => Nft::factory(), 'type' => fn () => $this->faker->randomElement([ NftTransferType::Mint, NftTransferType::Transfer, diff --git a/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php b/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php new file mode 100644 index 000000000..659c08470 --- /dev/null +++ b/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php @@ -0,0 +1,29 @@ +foreignIdFor(Collection::class)->nullable()->constrained()->cascadeOnDelete()->after('id'); + $table->addColumn('numeric', 'token_number', ['numeric_type' => 'numeric'])->after('collection_id')->nullable(); + $table->addColumn('numeric', 'log_index', ['numeric_type' => 'numeric'])->after('tx_hash')->nullable(); + + $table->dropColumn('nft_id'); + + $table->unique(['tx_hash', 'log_index', 'collection_id', 'token_number', 'type']); + + $table->index(['collection_id', 'timestamp']); + }); + } +}; diff --git a/database/migrations/2023_09_11_071352_add_activity_updated_at_column_to_collections_table.php b/database/migrations/2023_09_11_071352_add_activity_updated_at_column_to_collections_table.php new file mode 100644 index 000000000..b2e619830 --- /dev/null +++ b/database/migrations/2023_09_11_071352_add_activity_updated_at_column_to_collections_table.php @@ -0,0 +1,23 @@ +boolean('is_fetching_activity')->default(false)->after('minted_at'); + $table->timestamp('activity_updated_at')->nullable()->after('is_fetching_activity'); + + $table->index('is_fetching_activity'); + }); + } +}; diff --git a/resources/js/Pages/Collections/View.tsx b/resources/js/Pages/Collections/View.tsx index de7f81706..793cf4f09 100644 --- a/resources/js/Pages/Collections/View.tsx +++ b/resources/js/Pages/Collections/View.tsx @@ -39,7 +39,7 @@ interface Properties { query: string; nftPageLimit: number; }; - activities: App.Data.Nfts.NftActivitiesData; + activities: App.Data.Nfts.NftActivitiesData | null; sortByMintDate?: boolean; nativeToken: App.Data.Token.TokenData; showReportModal: boolean; @@ -308,7 +308,7 @@ const CollectionsView = ({
- {activities.paginated.data.length === 0 ? ( + {!isTruthy(activities) || activities.paginated.data.length === 0 ? ( {t("pages.collections.activities.no_activity")} ) : ( ; + }; export type Web3ContractMetadata = { contractAddress: string; collectionName: string | null; @@ -493,18 +506,6 @@ declare namespace App.Data.Web3 { mintedBlock: number; mintedAt: string | null; }; - export type Web3NftTransfer = { - contractAddress: string; - tokenId: string; - sender: string; - recipient: string; - txHash: string; - type: App.Enums.NftTransferType; - timestamp: string; - totalNative: number | null; - totalUsd: number | null; - extraAttributes: Array; - }; export type Web3NftsChunk = { nfts: any; nextToken: string | null; diff --git a/tests/App/Console/Commands/FetchCollectionActivityTest.php b/tests/App/Console/Commands/FetchCollectionActivityTest.php new file mode 100644 index 000000000..715378043 --- /dev/null +++ b/tests/App/Console/Commands/FetchCollectionActivityTest.php @@ -0,0 +1,61 @@ + true, + ]); +}); + +it('dispatches a job only for collections that are not currently already retrieving their activity', function () { + Bus::fake(); + + Collection::factory()->create([ + 'is_fetching_activity' => true, + ]); + + Collection::factory()->create([ + 'is_fetching_activity' => false, + ]); + + Bus::assertDispatchedTimes(FetchCollectionActivity::class, 0); + + $this->artisan('collections:fetch-activity'); + + Bus::assertDispatchedTimes(FetchCollectionActivity::class, 1); +}); + +it('dispatches a job for a specific collection', function () { + Bus::fake(); + + $collection = Collection::factory()->create(); + + Bus::assertDispatchedTimes(FetchCollectionActivity::class, 0); + + $this->artisan('collections:fetch-activity', [ + '--collection-id' => $collection->id, + ]); + + Bus::assertDispatchedTimes(FetchCollectionActivity::class, 1); +}); + +it('does not run if activities are disabled', function () { + Bus::fake(); + + config([ + 'dashbrd.features.activities' => false, + ]); + + Collection::factory()->create(); + + Bus::assertDispatchedTimes(FetchCollectionActivity::class, 0); + + $this->artisan('collections:fetch-activity'); + + Bus::assertDispatchedTimes(FetchCollectionActivity::class, 0); +}); diff --git a/tests/App/Console/Commands/FetchNftActivityTest.php b/tests/App/Console/Commands/FetchNftActivityTest.php deleted file mode 100644 index 946c69234..000000000 --- a/tests/App/Console/Commands/FetchNftActivityTest.php +++ /dev/null @@ -1,39 +0,0 @@ -count(3)->create(); - - Nft::factory()->count(1)->create([ - 'wallet_id' => null, - ]); - - Bus::assertDispatchedTimes(FetchNftActivity::class, 0); - - $this->artisan('nfts:fetch-activity'); - - Bus::assertDispatchedTimes(FetchNftActivity::class, 3); -}); - -it('dispatches a job for a specific nft', function () { - Bus::fake(); - - Nft::factory()->count(3)->create(); - - $nft = Nft::factory()->create(); - - Bus::assertDispatchedTimes(FetchNftActivity::class, 0); - - $this->artisan('nfts:fetch-activity', [ - '--nft-id' => $nft->id, - ]); - - Bus::assertDispatchedTimes(FetchNftActivity::class, 1); -}); diff --git a/tests/App/Http/Client/Mnemonic/MnemonicPendingRequestTest.php b/tests/App/Http/Client/Mnemonic/MnemonicPendingRequestTest.php index c7a4b73fe..c54ad0b89 100644 --- a/tests/App/Http/Client/Mnemonic/MnemonicPendingRequestTest.php +++ b/tests/App/Http/Client/Mnemonic/MnemonicPendingRequestTest.php @@ -7,7 +7,6 @@ use App\Exceptions\RateLimitException; use App\Models\Collection; use App\Models\Network; -use App\Models\Nft; use App\Models\Token; use App\Models\TokenPriceHistory; use App\Support\Facades\Mnemonic; @@ -85,12 +84,7 @@ 'address' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', ]); - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - - expect(fn () => Mnemonic::getNftActivity(Chains::Polygon, $collection->address, $nft->token_number, 100, $from))->toThrow('400 Bad Request'); + expect(fn () => Mnemonic::getCollectionActivity(Chains::Polygon, $collection->address, 100, $from))->toThrow('400 Bad Request'); }); it('should get owners', function () { @@ -250,7 +244,7 @@ expect($data)->toHaveCount(1); }); -it('should fetch nft activity', function () { +it('should fetch the collection activity', function () { Mnemonic::fake([ 'https://*-rest.api.mnemonichq.com/foundational/v1beta2/transfers/nft?*' => Http::response(fixtureData('mnemonic.nft_transfers'), 200), ]); @@ -262,13 +256,8 @@ 'address' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', ]); - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - // Note: limit is ignored because the fixture is fixed size - $data = Mnemonic::getNftActivity(Chains::Polygon, $collection->address, $nft->token_number, 100); + $data = Mnemonic::getCollectionActivity(Chains::Polygon, $collection->address, 100); expect($data)->toHaveCount(18); @@ -278,6 +267,7 @@ 'sender' => '0x0000000000000000000000000000000000000000', 'recipient' => '0xe66e1e9e37e4e148b21eb22001431818e980d060', 'txHash' => '0x8f1c4d575332c9a89ceec4d3d05960e23a17ec385912b00f4e970faf446ae4de', + 'logIndex' => '164', 'type' => 'LABEL_MINT', 'timestamp' => '2022-04-16T16:39:27+00:00', 'total_native' => null, @@ -298,11 +288,6 @@ 'address' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', ]); - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - $ethToken = Token::whereHas('network', fn ($query) => $query ->where('chain_id', Chains::ETH->value) ->where('is_mainnet', true) @@ -316,7 +301,7 @@ ]); // Note: limit is ignored because the fixture is fixed size - $data = Mnemonic::getNftActivity(Chains::Polygon, $collection->address, $nft->token_number, 100); + $data = Mnemonic::getCollectionActivity(Chains::Polygon, $collection->address, 100); expect($data)->toHaveCount(18) ->and($data->first()->toArray())->toEqualCanonicalizing([ @@ -325,6 +310,7 @@ 'sender' => '0x0000000000000000000000000000000000000000', 'recipient' => '0xe66e1e9e37e4e148b21eb22001431818e980d060', 'txHash' => '0x8f1c4d575332c9a89ceec4d3d05960e23a17ec385912b00f4e970faf446ae4de', + 'logIndex' => '164', 'type' => 'LABEL_MINT', 'timestamp' => '2022-04-16T16:39:27+00:00', 'total_native' => 5.031996674344903, @@ -346,11 +332,6 @@ 'address' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', ]); - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - $ethToken = Token::whereHas('network', fn ($query) => $query ->where('chain_id', Chains::ETH->value) ->where('is_mainnet', true) @@ -372,7 +353,7 @@ ]); // Note: limit is ignored because the fixture is fixed size - $data = Mnemonic::getNftActivity(Chains::Polygon, $collection->address, $nft->token_number, 100); + $data = Mnemonic::getCollectionActivity(Chains::Polygon, $collection->address, 100); expect($data)->toHaveCount(18) ->and($data->first()->toArray())->toEqualCanonicalizing([ @@ -381,6 +362,7 @@ 'sender' => '0x0000000000000000000000000000000000000000', 'recipient' => '0xe66e1e9e37e4e148b21eb22001431818e980d060', 'txHash' => '0x8f1c4d575332c9a89ceec4d3d05960e23a17ec385912b00f4e970faf446ae4de', + 'logIndex' => '164', 'type' => 'LABEL_MINT', 'timestamp' => '2022-04-16T16:39:27+00:00', 'total_native' => 5.031996674344903, @@ -389,7 +371,7 @@ ]); }); -it('should ignore nft activity with unexpected label', function () { +it('should ignore activity with unexpected label', function () { $response = fixtureData('mnemonic.nft_transfers'); $response['nftTransfers'][1]['labels'] = ['LABEL_BURN']; @@ -398,38 +380,23 @@ 'https://*-rest.api.mnemonichq.com/foundational/v1beta2/transfers/nft?*' => Http::response($response, 200), ]); - $network = Network::polygon(); + $network = Network::polygon()->firstOrFail(); $collection = Collection::factory()->create([ 'network_id' => $network->id, 'address' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', ]); - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - // Note: limit is ignored because the fixture is fixed size - $data = Mnemonic::getNftActivity(Chains::Polygon, $collection->address, $nft->token_number, 100); + $data = Mnemonic::getCollectionActivity(Chains::Polygon, $collection->address, 100); - expect($data)->toHaveCount(17); + expect($data)->toHaveCount(18); - expect($data->first()->toArray())->toEqualCanonicalizing([ - 'contractAddress' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', - 'tokenId' => '8304', - 'sender' => '0x0000000000000000000000000000000000000000', - 'recipient' => '0xe66e1e9e37e4e148b21eb22001431818e980d060', - 'txHash' => '0x8f1c4d575332c9a89ceec4d3d05960e23a17ec385912b00f4e970faf446ae4de', - 'type' => 'LABEL_MINT', - 'timestamp' => '2022-04-16T16:39:27+00:00', - 'total_native' => null, - 'total_usd' => '7547.995011517354', - 'extra_attributes' => $data->first()->extraAttributes, - ]); + expect($data->contains(fn ($activity) => $activity->type?->value === 'LABEL_BURN'))->toBeFalse(); + expect($data->contains(fn ($activity) => $activity->type === null))->toBeTrue(); }); -it('should fetch nft from date', function () { +it('should fetch activity from date', function () { $from = Carbon::now(); Mnemonic::fake([ @@ -443,12 +410,7 @@ 'address' => '0x23581767a106ae21c074b2276d25e5c3e136a68b', ]); - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - - $data = Mnemonic::getNftActivity(Chains::Polygon, $collection->address, $nft->token_number, 100, $from); + $data = Mnemonic::getCollectionActivity(Chains::Polygon, $collection->address, 100, $from); expect($data)->toHaveCount(18); @@ -458,6 +420,7 @@ 'sender' => '0x0000000000000000000000000000000000000000', 'recipient' => '0xe66e1e9e37e4e148b21eb22001431818e980d060', 'txHash' => '0x8f1c4d575332c9a89ceec4d3d05960e23a17ec385912b00f4e970faf446ae4de', + 'logIndex' => '164', 'type' => 'LABEL_MINT', 'timestamp' => '2022-04-16T16:39:27+00:00', 'total_native' => null, diff --git a/tests/App/Http/Controllers/NftControllerTest.php b/tests/App/Http/Controllers/NftControllerTest.php index 236c7f577..9f08e7b33 100644 --- a/tests/App/Http/Controllers/NftControllerTest.php +++ b/tests/App/Http/Controllers/NftControllerTest.php @@ -61,7 +61,8 @@ ]); NftActivity::factory()->count(12)->create([ - 'nft_id' => $nft->id, + 'collection_id' => $collection->id, + 'token_number' => $nft->token_number, ]); $this->actingAs($user) diff --git a/tests/App/Jobs/FetchCollectionActivityTest.php b/tests/App/Jobs/FetchCollectionActivityTest.php new file mode 100644 index 000000000..7815f6295 --- /dev/null +++ b/tests/App/Jobs/FetchCollectionActivityTest.php @@ -0,0 +1,436 @@ + true, + ]); +}); + +it('does not run if collection is marked as spam', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => true, + ]); + + SpamContract::create([ + 'network_id' => $collection->network_id, + 'address' => $collection->address, + ]); + + expect($collection->isSpam())->toBeTrue(); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->never() + ); + + (new FetchCollectionActivity($collection))->handle($mock); +}); + +it('does not run if collection is already fetching activity', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => true, + ]); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->never() + ); + + (new FetchCollectionActivity($collection))->handle($mock); +}); + +it('does not run if collection is blacklisted from indexing activity', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + ]); + + config([ + 'dashbrd.activity_blacklist' => [ + $collection->address, + ], + ]); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->never() + ); + + (new FetchCollectionActivity($collection))->handle($mock); +}); + +it('does run in forced mode if collection is already fetching activity', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => true, + 'network_id' => Network::polygon()->first()->id, + ]); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(collect([])) + ); + + (new FetchCollectionActivity($collection, forced: true))->handle($mock); +}); + +it('does not dispatch another job in the chain if there are no activities at all', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(collect([])) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); +}); + +it('does not dispatch another job in the chain if there are no activities with the proper label', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(collect([ + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '1', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: null, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + ])) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); +}); + +it('does not dispatch another job in the chain if there are less than 500 activities returned from the provider', function () { + Bus::fake([FetchCollectionActivity::class]); + + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + expect($collection->activities()->count())->toBe(0); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(collect([ + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '1', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: NftTransferType::Transfer, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + ])) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + expect($collection->activities()->count())->toBe(1); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); + + Bus::assertNothingDispatched(); +}); + +it('does dispatch another job in the chain if there are more than 500 activities returned from the provider', function () { + Bus::fake([FetchCollectionActivity::class]); + + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + expect($collection->activities()->count())->toBe(0); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(BaseCollection::times(500, fn ($index) => new CollectionActivity( + contractAddress: 'test-address', + tokenId: (string) $index, + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: (string) $index, + type: NftTransferType::Transfer, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ))) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + expect($collection->activities()->count())->toBe(500); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeTrue(); + expect($collection->activity_updated_at)->toBeNull(); + + Bus::assertDispatched(FetchCollectionActivity::class, fn ($job) => $job->collection->is($collection) && $job->forced); +}); + +it('starts from the timestamp of the newest activity', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + $date = now(); + + // Newer... + $newer = NftActivity::factory()->create([ + 'collection_id' => $collection->id, + 'token_number' => 1, + 'timestamp' => $date, + ]); + + // Older... + NftActivity::factory()->create([ + 'collection_id' => $collection->id, + 'token_number' => 1, + 'timestamp' => $date->copy()->subMinutes(10), + ]); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->withArgs(function ($chain, $address, $limit, $from) use ($date, $collection) { + return $chain === Chains::Polygon + && $address === $collection->address + && $limit === 500 + && ($from->toDateTimeString() === $date->toDateTimeString()); + })->andReturn(collect([])) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); +}); + +it('ignores activities without any type (label)', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + expect($collection->activities()->count())->toBe(0); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(collect([ + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '1', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: NftTransferType::Transfer, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '2', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: null, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '3', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: NftTransferType::Transfer, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + ])) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + expect($collection->activities()->count())->toBe(2); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); + + expect($collection->activities()->where('token_number', '2')->exists())->toBeFalse(); +}); + +it('upserts existing activities', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + NftActivity::factory()->create([ + 'collection_id' => $collection->id, + 'token_number' => 1, + 'type' => NftTransferType::Mint, + 'tx_hash' => 'test-hash', + 'log_index' => '1', + 'sender' => 'old-sender', + ]); + + expect($collection->activities()->count())->toBe(1); + + $mock = $this->mock( + MnemonicWeb3DataProvider::class, + fn ($mock) => $mock->shouldReceive('getCollectionActivity')->once()->andReturn(collect([ + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '1', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-hash', + logIndex: '1', + type: NftTransferType::Mint, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '2', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: null, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + new CollectionActivity( + contractAddress: 'test-address', + tokenId: '3', + sender: 'test-sender', + recipient: 'test-recipient', + txHash: 'test-tx-hash', + logIndex: '1', + type: NftTransferType::Transfer, + timestamp: now(), + totalNative: 0, + totalUsd: 0, + extraAttributes: [], + ), + ])) + ); + + (new FetchCollectionActivity($collection))->handle($mock); + + expect($collection->activities()->count())->toBe(2); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); + + $activity = $collection->activities()->where([ + 'token_number' => 1, + 'type' => NftTransferType::Mint, + 'tx_hash' => 'test-hash', + ])->first(); + + expect($activity->sender)->toBe('test-sender'); + expect($activity->recipient)->toBe('test-recipient'); +}); + +it('has a retry until', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => false, + 'network_id' => Network::polygon()->first()->id, + 'activity_updated_at' => null, + ]); + + expect((new FetchCollectionActivity($collection))->retryUntil())->toBeInstanceOf(DateTime::class); +}); + +it('resets the collection state if the job fails', function () { + $collection = Collection::factory()->create([ + 'is_fetching_activity' => true, + 'activity_updated_at' => null, + ]); + + (new FetchCollectionActivity($collection))->onFailure(new RuntimeException); + + $collection->refresh(); + + expect($collection->is_fetching_activity)->toBeFalse(); + expect($collection->activity_updated_at)->not->toBeNull(); +}); diff --git a/tests/App/Jobs/FetchNftActivityTest.php b/tests/App/Jobs/FetchNftActivityTest.php deleted file mode 100644 index aa9e4fa19..000000000 --- a/tests/App/Jobs/FetchNftActivityTest.php +++ /dev/null @@ -1,125 +0,0 @@ -create([ - 'network_id' => Network::where('chain_id', 1)->firstOrFail()->id, - 'symbol' => 'ETH', - 'is_native_token' => 1, - 'is_default_token' => 1, - ]); -}); - -it('should fetch and store nft activity', function () { - Mnemonic::fake([ - 'https://*-rest.api.mnemonichq.com/foundational/v1beta2/transfers/nft?*' => Http::response(fixtureData('mnemonic.nft_transfers'), 200), - ]); - - Token::factory()->create([ - 'network_id' => Network::where('chain_id', 1)->firstOrFail()->id, - 'symbol' => 'ETH', - 'is_native_token' => 1, - 'is_default_token' => 1, - ]); - - $network = Network::polygon(); - - $collection = Collection::factory()->create([ - 'network_id' => $network->id, - ]); - - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - - (new FetchNftActivity($nft))->handle(); - - expect($nft->activities()->count())->toBe(18); -}); - -it('should skip fetching NFT activity for a spam collection', function () { - $network = Network::polygon(); - - $collectionAddress = '0x000000000a42c2791eec307fff43fa5c640e3ef7'; - - $collection = Collection::factory()->create([ - 'network_id' => $network->id, - 'address' => $collectionAddress, - 'owners' => null, - ]); - - SpamContract::query()->insert([ - 'address' => $collectionAddress, - 'network_id' => $network->id, - ]); - - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - - expect($nft->activities)->toBeEmpty(); - - (new FetchNftActivity($nft))->handle(); - - expect($nft->activities)->toBeEmpty(); - - Mnemonic::assertNothingSent(); -}); - -it('should call the job again if response equals the limit', function () { - $fixture = fixtureData('mnemonic.nft_transfers'); - - Token::factory()->create([ - 'network_id' => Network::where('chain_id', 1)->firstOrFail()->id, - 'symbol' => 'ETH', - 'is_native_token' => 1, - 'is_default_token' => 1, - ]); - - $itemTemplate = $fixture['nftTransfers'][0]; - - $response = [ - 'nftTransfers' => collect(range(1, 500))->map(function () use ($itemTemplate) { - return [ - ...$itemTemplate, - 'blockchainEvent' => [ - ...$itemTemplate['blockchainEvent'], - 'txHash' => '0x'.fake()->sha1(), - ], - ]; - })->toArray(), - ]; - - Mnemonic::fake([ - 'https://*-rest.api.mnemonichq.com/foundational/v1beta2/transfers/nft?*' => Http::sequence() - ->push($response, 200) - ->push($fixture, 200), - ]); - - $network = Network::polygon(); - - $collection = Collection::factory()->create([ - 'network_id' => $network->id, - ]); - - $nft = Nft::factory()->create([ - 'token_number' => '8304', - 'collection_id' => $collection->id, - ]); - - (new FetchNftActivity($nft))->handle(); - - expect($nft->activities()->count())->toBe(500 + 18); -}); diff --git a/tests/App/Models/CollectionTest.php b/tests/App/Models/CollectionTest.php index 1f59900ba..3cad483b3 100644 --- a/tests/App/Models/CollectionTest.php +++ b/tests/App/Models/CollectionTest.php @@ -642,7 +642,9 @@ NftTransferType::Transfer->value => 3, // timestamp = 3 NftTransferType::Sale->value => 5, // timestamp = 5 ] as $type => $timestamp) { - NftActivity::factory()->for($nft1)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft1->collection_id, + 'token_number' => $nft1->token_number, 'type' => $type, 'timestamp' => $timestamp, 'recipient' => $wallet->address, @@ -650,7 +652,9 @@ } // Create activity for some other wallet... - NftActivity::factory()->for($nft1)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft1->collection_id, + 'token_number' => $nft1->token_number, 'type' => NftTransferType::Sale, 'timestamp' => 10, 'recipient' => $otherWallet->address, @@ -663,7 +667,9 @@ NftTransferType::Mint->value => 4, // timestamp = 4 NftTransferType::Sale->value => 6, // timestamp = 6 ] as $type => $timestamp) { - NftActivity::factory()->for($nft2)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft2->collection_id, + 'token_number' => $nft2->token_number, 'type' => $type, 'timestamp' => $timestamp, 'recipient' => $wallet->address, @@ -671,7 +677,9 @@ } // Create activity for some other wallet... - NftActivity::factory()->for($nft2)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft2->collection_id, + 'token_number' => $nft2->token_number, 'type' => NftTransferType::Sale, 'timestamp' => 15, 'recipient' => $otherWallet->address, @@ -684,7 +692,9 @@ NftTransferType::Mint->value => 3, // timestamp = 3 NftTransferType::Sale->value => 1, // timestamp = 1 ] as $type => $timestamp) { - NftActivity::factory()->for($nft3)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft3->collection_id, + 'token_number' => $nft3->token_number, 'type' => $type, 'timestamp' => $timestamp, 'recipient' => $wallet->address, @@ -698,7 +708,9 @@ NftTransferType::Sale->value => 4, // timestamp = 4 NftTransferType::Mint->value => 1, // timestamp = 1 ] as $type => $timestamp) { - NftActivity::factory()->for($nft4)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft4->collection_id, + 'token_number' => $nft4->token_number, 'type' => $type, 'timestamp' => $timestamp, 'recipient' => $wallet->address, diff --git a/tests/App/Models/NftActivityTest.php b/tests/App/Models/NftActivityTest.php index 02bccb43a..cd32939b4 100644 --- a/tests/App/Models/NftActivityTest.php +++ b/tests/App/Models/NftActivityTest.php @@ -2,11 +2,22 @@ declare(strict_types=1); +use App\Models\Collection; use App\Models\Nft; use App\Models\NftActivity; it('belongs to a nft', function () { - $activity = NftActivity::factory()->create(); + $collection = Collection::factory()->create(); - expect($activity->nft)->toBeinstanceOf(Nft::class); + $nft = Nft::factory()->create([ + 'collection_id' => $collection->id, + 'token_number' => '1', + ]); + + $activity = NftActivity::factory()->create([ + 'token_number' => '1', + 'collection_id' => $collection->id, + ]); + + expect($activity->nft->is($nft))->toBeTrue(); }); diff --git a/tests/App/Models/NftTest.php b/tests/App/Models/NftTest.php index cd8030bd5..2eb24e47d 100644 --- a/tests/App/Models/NftTest.php +++ b/tests/App/Models/NftTest.php @@ -270,7 +270,9 @@ NftTransferType::Mint->value => 3, // timestamp = 3 NftTransferType::Sale->value => 5, // timestamp = 5 ] as $type => $timestamp) { - NftActivity::factory()->for($nft1)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft1->collection_id, + 'token_number' => $nft1->token_number, 'type' => $type, 'timestamp' => $timestamp, ]); @@ -282,7 +284,9 @@ NftTransferType::Mint->value => 4, // timestamp = 4 NftTransferType::Sale->value => 6, // timestamp = 6 ] as $type => $timestamp) { - NftActivity::factory()->for($nft2)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft2->collection_id, + 'token_number' => $nft2->token_number, 'type' => $type, 'timestamp' => $timestamp, ]); @@ -294,7 +298,9 @@ NftTransferType::Mint->value => 2, // timestamp = 3 NftTransferType::Sale->value => 1, // timestamp = 1 ] as $type => $timestamp) { - NftActivity::factory()->for($nft3)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft3->collection_id, + 'token_number' => $nft3->token_number, 'type' => $type, 'timestamp' => $timestamp, ]); @@ -321,7 +327,9 @@ NftTransferType::Mint->value => 3, // timestamp = 3 NftTransferType::Sale->value => 5, // timestamp = 5 ] as $type => $timestamp) { - NftActivity::factory()->for($nft1)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft1->collection_id, + 'token_number' => $nft1->token_number, 'type' => $type, 'timestamp' => $timestamp, ]); @@ -333,7 +341,9 @@ NftTransferType::Mint->value => 4, // timestamp = 4 NftTransferType::Sale->value => 6, // timestamp = 6 ] as $type => $timestamp) { - NftActivity::factory()->for($nft2)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft2->collection_id, + 'token_number' => $nft2->token_number, 'type' => $type, 'timestamp' => $timestamp, ]); @@ -345,7 +355,9 @@ NftTransferType::Mint->value => 2, // timestamp = 3 NftTransferType::Sale->value => 1, // timestamp = 1 ] as $type => $timestamp) { - NftActivity::factory()->for($nft3)->create([ + NftActivity::factory()->create([ + 'collection_id' => $nft3->collection_id, + 'token_number' => $nft3->token_number, 'type' => $type, 'timestamp' => $timestamp, ]); @@ -376,3 +388,53 @@ ->and(Nft::search('Test')->get()->pluck('id')->toArray()[0])->toBe($nft3->id) ->and(Nft::search('NFT')->get()->pluck('id')->toArray())->toEqualCanonicalizing([$nft1->id, $nft2->id]); }); + +it('has activity', function () { + $first = Nft::factory()->create([ + 'token_number' => 1, + ]); + + $second = Nft::factory()->create([ + 'collection_id' => $first->collection_id, + 'token_number' => 2, + ]); + + $third = Nft::factory()->create([ + 'token_number' => 1, + ]); + + $activity1 = NftActivity::factory()->create([ + 'collection_id' => $first->collection_id, + 'token_number' => 1, + ]); + + $activity2 = NftActivity::factory()->create([ + 'collection_id' => $first->collection_id, + 'token_number' => 1, + ]); + + $activity3 = NftActivity::factory()->create([ + 'collection_id' => $first->collection_id, + 'token_number' => 2, + ]); + + $activity4 = NftActivity::factory()->create([ + 'collection_id' => $third->collection_id, + 'token_number' => 1, + ]); + + $activity5 = NftActivity::factory()->create([ + 'collection_id' => $third->collection_id, + 'token_number' => 2, + ]); + + expect($first->activities()->count())->toBe(2); + expect($first->activities->modelKeys())->toContain($activity1->id); + expect($first->activities->modelKeys())->toContain($activity2->id); + + expect($second->activities()->count())->toBe(1); + expect($second->activities->modelKeys())->toContain($activity3->id); + + expect($third->activities()->count())->toBe(1); + expect($third->activities->modelKeys())->toContain($activity4->id); +}); diff --git a/tests/App/Services/Web3/Mnemonic/MnemonicWeb3DataProviderTest.php b/tests/App/Services/Web3/Mnemonic/MnemonicWeb3DataProviderTest.php index f0a73da34..8cd4cd378 100644 --- a/tests/App/Services/Web3/Mnemonic/MnemonicWeb3DataProviderTest.php +++ b/tests/App/Services/Web3/Mnemonic/MnemonicWeb3DataProviderTest.php @@ -111,3 +111,22 @@ expect(fn () => $provider->getBlockTimestamp($network, blockNumber: 10000))->toThrow(NotImplementedException::class); }); + +it('can get collection activity', function () { + Mnemonic::fake([ + '*' => Http::response(fixtureData('mnemonic.nft_transfers'), 200), + ]); + + Token::factory()->withGuid()->create([ + 'network_id' => Network::where('chain_id', 1)->firstOrFail()->id, + 'symbol' => 'ETH', + 'is_native_token' => 1, + 'is_default_token' => 1, + ]); + + $collection = Collection::factory()->create(); + + $activity = (new MnemonicWeb3DataProvider)->getCollectionActivity(Chains::Polygon, $collection->address, limit: 10); + + expect($activity)->toHaveCount(18); +}); From 33cb509727299f6cc925a13917fc7f9ccf8f4aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Crnkovi=C4=87?= Date: Fri, 13 Oct 2023 11:01:14 +0200 Subject: [PATCH 02/14] fix: add `typeNumeric` macro to migrations (#225) --- ...135157_add_token_number_column_to_nft_activity_table.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php b/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php index 659c08470..5182319df 100644 --- a/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php +++ b/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php @@ -3,9 +3,11 @@ declare(strict_types=1); use App\Models\Collection; +use Illuminate\Database\Grammar; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Fluent; return new class extends Migration { @@ -14,6 +16,10 @@ */ public function up(): void { + Grammar::macro('typeNumeric', function (Fluent $column) { + return $column->get('numeric_type'); + }); + Schema::table('nft_activity', function (Blueprint $table) { $table->foreignIdFor(Collection::class)->nullable()->constrained()->cascadeOnDelete()->after('id'); $table->addColumn('numeric', 'token_number', ['numeric_type' => 'numeric'])->after('collection_id')->nullable(); From 4e2036dc53a84bda0a0d4aa96603d73ffab44684 Mon Sep 17 00:00:00 2001 From: shahin-hq <132887516+shahin-hq@users.noreply.github.com> Date: Mon, 16 Oct 2023 18:07:48 +0400 Subject: [PATCH 03/14] perf: improve collections sorting speed (#235) --- app/Models/Collection.php | 16 ++++------------ ...token_number_column_to_nft_activity_table.php | 3 +++ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/Models/Collection.php b/app/Models/Collection.php index bbe47b4ee..ce14d72ee 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -253,21 +253,13 @@ public function scopeOrderByMintDate(Builder $query, string $direction): Builder */ public function scopeOrderByReceivedDate(Builder $query, Wallet $wallet, string $direction): Builder { - // Get the latest timestamp for each NFT - $subselect = sprintf("SELECT timestamp + $select = sprintf("SELECT timestamp FROM nft_activity - WHERE nft_activity.collection_id = nfts.collection_id AND nft_activity.token_number = nfts.token_number AND lower(recipient) = '%s' - -- Latest timestamp - ORDER BY timestamp desc - LIMIT 1", strtolower($wallet->address)); - - $select = sprintf('SELECT (%s) as timestamp - FROM nfts - WHERE nfts.collection_id = collections.id - -- Return the latest timestamp for each NFT + WHERE nft_activity.collection_id = collections.id + AND recipient = '%s' ORDER BY timestamp desc LIMIT 1 - ', $subselect); + ", $wallet->address); if ($direction === 'asc') { return $query->orderByRaw(sprintf('(%s) ASC NULLS FIRST', $select)); diff --git a/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php b/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php index 5182319df..09fe7e338 100644 --- a/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php +++ b/database/migrations/2023_09_07_135157_add_token_number_column_to_nft_activity_table.php @@ -25,11 +25,14 @@ public function up(): void $table->addColumn('numeric', 'token_number', ['numeric_type' => 'numeric'])->after('collection_id')->nullable(); $table->addColumn('numeric', 'log_index', ['numeric_type' => 'numeric'])->after('tx_hash')->nullable(); + $table->caseInsensitiveText('recipient')->change(); + $table->dropColumn('nft_id'); $table->unique(['tx_hash', 'log_index', 'collection_id', 'token_number', 'type']); $table->index(['collection_id', 'timestamp']); + $table->index(['collection_id', 'recipient', 'timestamp']); }); } }; From e67abd02de9706b28f8f60ce1fc1c16842f2500f Mon Sep 17 00:00:00 2001 From: Patricio Marroquin <55117912+patricio0312rev@users.noreply.github.com> Date: Tue, 17 Oct 2023 04:06:45 -0500 Subject: [PATCH 04/14] refactor: sort by recently received by default (#237) --- app/Http/Controllers/CollectionController.php | 4 ++-- resources/js/Pages/Collections/Index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/CollectionController.php b/app/Http/Controllers/CollectionController.php index 1f3271340..d439d9faa 100644 --- a/app/Http/Controllers/CollectionController.php +++ b/app/Http/Controllers/CollectionController.php @@ -89,10 +89,10 @@ public function index(Request $request): Response|JsonResponse|RedirectResponse ->when(count($selectedChainIds) > 0, fn ($q) => $q->whereIn('collections.network_id', Network::whereIn('chain_id', $selectedChainIds)->pluck('id'))) ->when($sortBy === 'name', fn ($q) => $q->orderByName($sortDirection)) ->when($sortBy === 'floor-price', fn ($q) => $q->orderByFloorPrice($sortDirection, $user->currency())) - ->when($sortBy === 'value' || $sortBy === null, fn ($q) => $q->orderByValue($user->wallet, $sortDirection, $user->currency())) + ->when($sortBy === 'value', fn ($q) => $q->orderByValue($user->wallet, $sortDirection, $user->currency())) ->when($sortBy === 'chain', fn ($q) => $q->orderByChainId($sortDirection)) ->when($sortBy === 'oldest', fn ($q) => $q->orderByMintDate('asc')) - ->when($sortBy === 'received', fn ($q) => $q->orderByReceivedDate($user->wallet, 'desc')) + ->when($sortBy === 'received' || $sortBy === null, fn ($q) => $q->orderByReceivedDate($user->wallet, 'desc')) ->search($user, $searchQuery) ->with('reports') ->paginate(25); diff --git a/resources/js/Pages/Collections/Index.tsx b/resources/js/Pages/Collections/Index.tsx index 26d0aa42e..a8d20ab45 100644 --- a/resources/js/Pages/Collections/Index.tsx +++ b/resources/js/Pages/Collections/Index.tsx @@ -31,7 +31,7 @@ const CollectionsIndex = ({ auth, initialStats, title, - sortBy, + sortBy = "received", sortDirection, }: { title: string; @@ -130,7 +130,7 @@ const CollectionsIndex = ({ hiddenCount={hiddenCollectionAddresses.length} searchQuery={query} setSearchQuery={search} - activeSort={sortBy} + activeSort={sortBy ?? "received"} onSort={sort} onChangeVisibilityStatus={(isHidden) => { reload({ showHidden: isHidden, selectedChainIds, page: 1 }); From e7ac089861e7b655653bbc2a7c753c49439d965d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Crnkovi=C4=87?= Date: Tue, 17 Oct 2023 11:36:55 +0200 Subject: [PATCH 05/14] fix: reset activity state if collection is invalid (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Josip Crnković <6536260+crnkovic@users.noreply.github.com> --- app/Console/Commands/FetchCollectionActivity.php | 2 +- app/Jobs/FetchCollectionActivity.php | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/FetchCollectionActivity.php b/app/Console/Commands/FetchCollectionActivity.php index f1de54d4f..7a96f6f1a 100644 --- a/app/Console/Commands/FetchCollectionActivity.php +++ b/app/Console/Commands/FetchCollectionActivity.php @@ -45,7 +45,7 @@ public function handle(): int }; $this->forEachCollection(function ($collection) { - Job::dispatch($collection); + Job::dispatch($collection, forced: true); }, $queryCallback); return Command::SUCCESS; diff --git a/app/Jobs/FetchCollectionActivity.php b/app/Jobs/FetchCollectionActivity.php index c7fb03cb8..2a47fb8d0 100644 --- a/app/Jobs/FetchCollectionActivity.php +++ b/app/Jobs/FetchCollectionActivity.php @@ -43,10 +43,20 @@ public function __construct( public function handle(MnemonicWeb3DataProvider $provider): void { if (! config('dashbrd.features.activities') || $this->shouldIgnoreCollection()) { + $this->collection->update([ + 'is_fetching_activity' => false, + 'activity_updated_at' => now(), + ]); + return; } if ($this->collection->isInvalid()) { + $this->collection->update([ + 'is_fetching_activity' => false, + 'activity_updated_at' => now(), + ]); + return; } @@ -147,6 +157,6 @@ public function onFailure(Throwable $exception): void public function retryUntil(): DateTime { - return now()->addMinutes(10); // This is retry PER JOB (i.e. per request)... + return now()->addHours(2); // This is retry PER JOB (i.e. per request)... } } From 0718c4478d18643395bcc5c26b3350c24bf5bcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Crnkovi=C4=87?= Date: Tue, 17 Oct 2023 12:05:36 +0200 Subject: [PATCH 06/14] refactor: enable collection activity tab (#239) --- app/Http/Controllers/CollectionController.php | 32 ++++++++----------- .../CollectionActivityTable.test.tsx | 20 ++++++++++++ .../CollectionActivityTable.tsx | 19 +++++++---- .../CollectionNavigation.tsx | 5 +-- resources/js/Pages/Collections/View.tsx | 18 +++++++++-- 5 files changed, 63 insertions(+), 31 deletions(-) diff --git a/app/Http/Controllers/CollectionController.php b/app/Http/Controllers/CollectionController.php index d439d9faa..b6acf5b70 100644 --- a/app/Http/Controllers/CollectionController.php +++ b/app/Http/Controllers/CollectionController.php @@ -202,21 +202,19 @@ public function show(Request $request, Collection $collection): Response $tab = $request->get('tab') === 'activity' ? 'activity' : 'collection'; - // TODO: enable when we enable the "Activity" tab (https://app.clickup.com/t/862kftp7w)... - // $activities = $collection->activities() - // ->latest('timestamp') - // ->with(['nft' => fn ($q) => $q->where('collection_id', $collection->id)]) - // ->whereHas('nft', fn ($q) => $q->where('collection_id', $collection->id)) - // ->where('type', '!=', NftTransferType::Transfer) - // ->paginate($activityPageLimit) - // ->appends([ - // 'tab' => 'activity', - // 'activityPageLimit' => $activityPageLimit, - // ]); - - // TODO: enable when we enable the "Activity" tab (https://app.clickup.com/t/862kftp7w)... - // /** @var PaginatedDataCollection */ - // $paginated = NftActivityData::collection($activities); + $activities = $tab === 'activity' ? $collection->activities() + ->latest('timestamp') + ->with(['nft' => fn ($q) => $q->where('collection_id', $collection->id)]) + ->whereHas('nft', fn ($q) => $q->where('collection_id', $collection->id)) + ->where('type', '!=', NftTransferType::Transfer) + ->paginate($activityPageLimit) + ->appends([ + 'tab' => 'activity', + 'activityPageLimit' => $activityPageLimit, + ]) : null; + + /** @var PaginatedDataCollection|null */ + $paginated = $activities !== null ? NftActivityData::collection($activities) : null; $ownedNftIds = $user ? $collection->nfts()->ownedBy($user)->pluck('id') @@ -234,9 +232,7 @@ public function show(Request $request, Collection $collection): Response $currency = $user ? $user->currency() : CurrencyCode::USD; return Inertia::render('Collections/View', [ - // TODO: enable when we enable the "Activity" tab (https://app.clickup.com/t/862kftp7w)... - // 'activities' => new NftActivitiesData($paginated), - 'activities' => null, + 'initialActivities' => $paginated !== null ? new NftActivitiesData($paginated) : null, 'collection' => CollectionDetailData::fromModel($collection, $currency, $user), 'isHidden' => $user && $user->hiddenCollections()->where('id', $collection->id)->exists(), 'previousUrl' => url()->previous() === url()->current() diff --git a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.test.tsx b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.test.tsx index 532d37e7a..e5b92aeb5 100644 --- a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.test.tsx +++ b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.test.tsx @@ -68,6 +68,26 @@ describe("CollectionActivityTable", () => { } }); + it.each(allBreakpoints)("should render loading %s screen if activities are not set", (breakpoint) => { + render( + , + { breakpoint }, + ); + + if (breakpoint === Breakpoint.xs) { + expect(screen.getByTestId("CollectionActivityTable__Mobile")).toBeInTheDocument(); + } else { + expect(screen.getByTestId("CollectionActivityTable")).toBeInTheDocument(); + } + }); + it.each(allBreakpoints)("should render loading and with name column in %s screen", (breakpoint) => { render( ); }, [collection, isLoading, isXsAndAbove, isSmAndAbove, showNameColumn], ); - const tableData = isLoading - ? (Array.from({ length: 5 }).fill({}) as App.Data.Nfts.NftActivityData[]) - : activities.paginated.data; + const tableData = useMemo( + () => + isLoading + ? (Array.from({ length: 5 }).fill({}) as App.Data.Nfts.NftActivityData[]) + : activities !== null + ? activities.paginated.data + : [], + [isLoading, activities], + ); return ( <> @@ -150,6 +156,7 @@ export const CollectionActivityTable = ({ data={tableData} row={renderTableRow} footer={ + activities !== null && activities.paginated.meta.total > 10 && (
{children} - {activities.paginated.meta.total > 10 && ( + {activities !== null && activities.paginated.meta.total > 10 && (
- + {t("pages.collections.menu.activity")} diff --git a/resources/js/Pages/Collections/View.tsx b/resources/js/Pages/Collections/View.tsx index 793cf4f09..7e1c4fd08 100644 --- a/resources/js/Pages/Collections/View.tsx +++ b/resources/js/Pages/Collections/View.tsx @@ -39,7 +39,7 @@ interface Properties { query: string; nftPageLimit: number; }; - activities: App.Data.Nfts.NftActivitiesData | null; + initialActivities: App.Data.Nfts.NftActivitiesData | null; sortByMintDate?: boolean; nativeToken: App.Data.Token.TokenData; showReportModal: boolean; @@ -52,7 +52,7 @@ const CollectionsView = ({ isHidden, previousUrl, nfts, - activities, + initialActivities, alreadyReported = false, reportAvailableIn, collectionTraits, @@ -71,6 +71,9 @@ const CollectionsView = ({ const [showOnlyOwned, setShowOnlyOwned] = useState(appliedFilters.owned); const [filterIsDirty, setFilterIsDirty] = useState(false); const [query, setQuery] = useState(""); + const [activities, setActivities] = useState(initialActivities); + + const [loading, setLoading] = useState(false); const [showCollectionFilterSlider, setShowCollectionFilterSlider] = useState(false); @@ -116,6 +119,14 @@ const CollectionsView = ({ preserveState: true, queryStringArrayFormat: "indices", replace: true, + onBefore: () => { + setLoading(true); + }, + onSuccess: (event) => { + setActivities(event.props.initialActivities as App.Data.Nfts.NftActivitiesData | null); + + setLoading(false); + }, }, ); }, [filterIsDirty, filters]); @@ -308,12 +319,13 @@ const CollectionsView = ({
- {!isTruthy(activities) || activities.paginated.data.length === 0 ? ( + {!loading && (!isTruthy(activities) || activities.paginated.data.length === 0) ? ( {t("pages.collections.activities.no_activity")} ) : ( Date: Tue, 17 Oct 2023 13:59:30 +0200 Subject: [PATCH 07/14] refactor: show alternative placeholder if collection doesn't store activity (#241) --- app/Http/Controllers/CollectionController.php | 4 +- app/Models/Collection.php | 14 +++++++ config/dashbrd.php | 2 +- lang/en/pages.php | 1 + resources/js/I18n/Locales/en.json | 2 +- resources/js/Pages/Collections/View.tsx | 40 ++++++++++++------- tests/App/Models/CollectionTest.php | 26 ++++++++++++ 7 files changed, 71 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/CollectionController.php b/app/Http/Controllers/CollectionController.php index b6acf5b70..32bdf24d5 100644 --- a/app/Http/Controllers/CollectionController.php +++ b/app/Http/Controllers/CollectionController.php @@ -201,8 +201,9 @@ public function show(Request $request, Collection $collection): Response $activityPageLimit = min($request->has('activityPageLimit') ? (int) $request->get('activityPageLimit') : 10, 100); $tab = $request->get('tab') === 'activity' ? 'activity' : 'collection'; + $hasActivity = $collection->indexesActivities(); - $activities = $tab === 'activity' ? $collection->activities() + $activities = ($tab === 'activity' && $hasActivity) ? $collection->activities() ->latest('timestamp') ->with(['nft' => fn ($q) => $q->where('collection_id', $collection->id)]) ->whereHas('nft', fn ($q) => $q->where('collection_id', $collection->id)) @@ -233,6 +234,7 @@ public function show(Request $request, Collection $collection): Response return Inertia::render('Collections/View', [ 'initialActivities' => $paginated !== null ? new NftActivitiesData($paginated) : null, + 'hasActivities' => $hasActivity, 'collection' => CollectionDetailData::fromModel($collection, $currency, $user), 'isHidden' => $user && $user->hiddenCollections()->where('id', $collection->id)->exists(), 'previousUrl' => url()->previous() === url()->current() diff --git a/app/Models/Collection.php b/app/Models/Collection.php index ce14d72ee..0b91e96fc 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -543,6 +543,20 @@ public function isInvalid(bool $withSpamCheck = true): bool return false; } + public function indexesActivities(): bool + { + /** + * @var string[] + */ + $blacklisted = config('dashbrd.activity_blacklist', []); + + if (collect($blacklisted)->map(fn ($collection) => Str::lower($collection))->contains(Str::lower($this->address))) { + return false; + } + + return ! $this->isInvalid(); + } + public function isBlacklisted(): bool { return BlacklistedCollections::includes($this->address); diff --git a/config/dashbrd.php b/config/dashbrd.php index 3b2d13940..cb11c1d87 100644 --- a/config/dashbrd.php +++ b/config/dashbrd.php @@ -201,7 +201,7 @@ '0x495f947276749ce646f68ac8c248420045cb7b5e', // OpenSea Shared Storefront ], - 'activities_blacklist' => [ + 'activity_blacklist' => [ '0xba6666b118f8303f990f3519df07e160227cce87', // Planet IX - Assets '0x22d5f9b75c524fec1d6619787e582644cd4d7422', // Sunflower Land Collectibles ], diff --git a/lang/en/pages.php b/lang/en/pages.php index 700c25ede..ae1e087ce 100644 --- a/lang/en/pages.php +++ b/lang/en/pages.php @@ -53,6 +53,7 @@ 'owned' => 'Owned', 'activities' => [ 'loading_activities' => "We're fetching Activity for this NFT, please hang tight, this can take a while.", + 'ignores_activities' => "We don't support activity history for this collection yet.", 'no_activity' => 'This collection does not have any activity yet.', 'types' => [ 'LABEL_MINT' => 'Mint', diff --git a/resources/js/I18n/Locales/en.json b/resources/js/I18n/Locales/en.json index 444898090..c5f125a73 100644 --- a/resources/js/I18n/Locales/en.json +++ b/resources/js/I18n/Locales/en.json @@ -1 +1 @@ -{"auth.welcome":"Welcome to Dashbrd","auth.logged_in":"You're logged in!","auth.log_out":"Log out","auth.failed":"These credentials do not match our records.","auth.session_timeout":"Your session has timed out. Please refresh the page and try connecting your account again.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in {{seconds}} seconds.","auth.wallet.connecting":"Connecting …","auth.wallet.waiting_for_signature":"Waiting for Signature …","auth.wallet.switching_wallet":"Switching Wallet …","auth.wallet.connect":"Connect Wallet","auth.wallet.connect_long":"Connect Your Wallet to Get Started","auth.wallet.sign_subtitle":"Connect Your Wallet to Continue","auth.wallet.disconnect":"Disconnect Wallet","auth.wallet.install":"Install MetaMask","auth.wallet.install_long":"Install MetaMask to Get Started","auth.wallet.sign":"Sign Message","auth.wallet.sign_message":"Welcome to Dashbrd. In order to login, sign this message with your wallet. It doesn't cost you anything!\n\nSigning ID (you can ignore this): {{nonce}}","auth.wallet.connect_subtitle":"Click on the MetaMask icon in your browser to confirm the action and connect your wallet.","auth.wallet.requires_signature":"In order to prevent impersonation, we require a signature to perform this action. This signature is only a signed message and does not give any access to your wallet.","auth.errors.metamask.no_account":"No account found. Please connect your wallet and try again.","auth.errors.metamask.generic":"Connection attempt error. Please retry and follow the steps to connect your wallet.","auth.errors.metamask.invalid_network":"Please switch to Polygon or Ethereum Mainnet in your MetaMask plugin to connect to Dashbrd.","auth.errors.metamask.provider_missing":"You don't have MetaMask installed in your browser. Please install and try again.","auth.errors.metamask.user_rejected":"It looks like you cancelled signing of the authentication message. Please try again.","auth.errors.metamask.provider_not_set":"Ethereum provider is not set","auth.validation.wallet_login_failed":"There was a problem trying to verify your signature. Please try again.","auth.validation.invalid_address":"Your wallet address is invalid. Please try again.","auth.validation.invalid_signature":"Signature is invalid. Please try again.","auth.validation.invalid_network":"Please switch to Polygon or Ethereum Mainnet in your MetaMask plugin to connect to Dashbrd.","common.add":"Add","common.amount":"Amount","common.balance":"Balance","common.cancel":"Cancel","common.delete":"Delete","common.edit":"Edit","common.confirm":"Confirm","common.connect":"Connect","common.continue":"Continue","common.done":"Done","common.filter":"Filter","common.items":"Items","common.receive":"Receive","common.received":"Received","common.retry":"Retry","common.records":"Records","common.save":"Save","common.send":"Send","common.sent":"Sent","common.show":"Show","common.searching":"Searching...","common.other":"Other","common.owned":"Owned","common.token":"Token","common.tokens":"Tokens","common.wallet":"Wallet","common.pending":"Pending","common.publish":"Publish","common.empty":"Empty","common.your_address":"Your Address","common.warning":"Warning","common.my_address":"My Address","common.my_wallet":"My Wallet","common.my_balance":"My Balance","common.na":"N/A","common.simple_plural_without_data":"One comment|Many comments","common.simple_plural_with_data":"{{count}} comment|{{count}} comments","common.advanced_plural_without_data":"{0} no comments yet|{1} 1 comment|[2,*] Many comments","common.advanced_plural_with_data":"{0} no comments yet|{1} 1 comment|[2,*] {{count}} comments","common.copy_clipboard":"Copy to Clipboard","common.copy":"Copy","common.download":"Download","common.zoom":"Zoom","common.my_collection":"My Collection","common.max":"Max","common.chain":"Chain","common.copied":"Copied!","common.coming_soon":"Coming Soon","common.more_details":"More Details","common.close":"Close","common.close_toast":"Close toast","common.loading":"Loading","common.price":"Price","common.market_cap":"Market Cap","common.volume":"Volume {{frequency}}","common.value":"Value","common.last_n_days":"Last {{count}} Days","common.details":"Details","common.view_all":"View All","common.view_more_on_polygonscan":"View More on Polygonscan","common.view_more_on_etherscan":"View More on Etherscan","common.view_nft_on_etherscan":"View NFT on Etherscan","common.view_nft_on_polygonscan":"View NFT on Polygonscan","common.view_nft_on_goerli_tesnet":"View NFT on Goerli Testnet Explorer","common.view_nft_on_mumbai_tesnet":"View NFT on Mumbai Testnet Explorer","common.polygonscan":"Polygonscan","common.etherscan":"Etherscan","common.featured":"Featured","common.floor_price":"Floor Price","common.floor":"Floor","common.supply":"Supply","common.owners":"Owners","common.created":"Created","common.nft":"NFT","common.collection":"Collection","common.collections":"Collections","common.gallery":"Gallery","common.basic_gallery":"Basic Gallery","common.gallery_name":"Gallery Name","common.create_gallery":"Create Gallery","common.external_link":"External Link","common.follow_link":"Follow Link","common.cover":"Cover","common.template":"Template","common.page":"Page","common.polygon":"Polygon","common.ethereum":"Ethereum","common.mumbai":"Mumbai","common.goerli":"Goerli","common.of":"of","common.pagination_input_placeholder":"Enter the page number","common.pagination_input_placeholder_mobile":"Page number","common.nft_count":"{0} {{count}} NFTs|{1} {{count}} NFT|[2,*] {{count}} NFTs","common.nft_gallery":"NFT Gallery","common.unable_to_retrieve_image":"Unable to retrieve your NFT image at this time","common.optional":"Optional","common.selected":"Selected","common.select":"Select","common.preview":"Preview","common.image_size_error":"Image size must not exceed 2MB","common.image_dimensions_error":"Image dimensions must exceed 287px x 190px","common.write_to_confirm":"Write {{word}} to confirm","common.n_hours":"{0} {{count}} hour|{1} {{count}} hour|[2,*] {{count}} hours","common.n_minutes":"{0} {{count}} minute|{1} {{count}} minute|[2,*] {{count}} minutes","common.report":"Report","common.n_nfts":"{0}NFTs|{1}NFT|[2,*]NFTs","common.n_collections":"{0}collections|{1}collection|[2,*]collections","common.back":"Back","common.back_to":"Back to","common.name":"Name","common.type":"Type","common.from":"From","common.to":"To","common.sale_price":"Sale Price","common.recent_activity":"Recent Activity","common.time":"Time","common.datetime.few_seconds_ago":"A few seconds ago","common.datetime.minutes_ago":"{0} Less than a minute ago|{1} A minute ago|[2,*] {{count}} minutes ago","common.datetime.hours_ago":"{0} Less than an hour ago|{1} An hour ago|[2,*] {{count}} hours ago","common.datetime.days_ago":"{0} Less than a day ago|{1} A day ago|[2,*] {{count}} days ago","common.datetime.weeks_ago":"{0} Less than a week ago|{1} A week ago|[2,*] {{count}} weeks ago","common.datetime.months_ago":"{0} Less than a month ago|{1} A month ago|[2,*] {{count}} months ago","common.datetime.years_ago":"{0} Less than a year ago|{1} A year ago|[2,*] {{count}} years ago","common.no_traits_found":"No traits found.","common.empty_transactions":"You don't have any transactions yet. Once transactions have been made, they will show up here.","common.error":"Error","common.refresh_metadata":"Refresh Metadata","common.refreshing_metadata":"Refreshing Metadata... Please check back later.","common.pending_confirmation":"Pending Confirmation","common.confirmed_transaction":"Confirmed Transaction","common.transaction_error":"Transaction Error","common.transaction_error_description_first_part":"Your transaction encountered an error. View this transaction on","common.transaction_error_description_second_part":"for more details.","common.home":"Home","common.contact":"Contact","common.menu":"Menu","common.website":"Website","common.twitter":"Twitter","common.discord":"Discord","common.sort":"Sort","footer.copyright":"{{year}} © Dashbrd. All rights reserved.","footer.all_rights_reserved":"All rights reserved","footer.privacy_policy":"Privacy Policy","footer.terms_of_service":"Terms of Service","footer.powered_by":"Powered by","format.fiat":"{{ value, currency }}","format.number":"{{ value, number }}","metatags.home.title":"Dashbrd | Web3 Portfolio Management Made Simple","metatags.home.description":"Simplify your Web3 journey with Dashbrd. Manage your portfolio of tokens, NFTs, and other digital collectibles across the Ethereum and Polygon blockchains.","metatags.home.image":"/images/meta/home.png","metatags.error.title":"Error {{code}} | Dashbrd","metatags.wallet.title":"My Wallet | Dashbrd","metatags.galleries.title":"Top NFT Galleries | Dashbrd","metatags.galleries.image":"/images/meta/nft-galleries.png","metatags.galleries.description":"Explore user published NFT galleries to find custom curated PFPs, Digital Collectibles, & More.","metatags.galleries.most_popular.title":"Most Popular Galleries | Dashbrd","metatags.galleries.most_popular.image":"/images/meta/most-popular-nft-galleries.png","metatags.galleries.most_popular.description":"Explore and discover Most Popular NFT Galleries created by our users featuring custom curated PFPs, Digital Collectibles, & More.","metatags.galleries.newest.title":"Newest Galleries | Dashbrd","metatags.galleries.newest.image":"/images/meta/newest-nft-galleries.png","metatags.galleries.newest.description":"Explore and discover most recent NFT galleries created by our users featuring custom curated PFPs, Digital Collectibles, & More","metatags.galleries.most_valuable.title":"Most Valuable Galleries | Dashbrd","metatags.galleries.most_valuable.image":"/images/meta/most-valuable-nft-galleries.png","metatags.galleries.most_valuable.description":"Explore and discover Most Valuable NFT Galleries created by our users featuring custom curated PFPs, Digital Collectibles, & More.","metatags.galleries.view.title":"{{name}} | Dashbrd","metatags.galleries.view.description":"{{name}} | A Curated NFT Gallery at Dashbrd","metatags.galleries.view.image":"/images/meta/nft-gallery.png","metatags.my_galleries.title":"My Galleries | Dashbrd","metatags.my_galleries.create.title":"Create Gallery | Dashbrd","metatags.my_galleries.edit.title":"Edit {{name}} | Dashbrd","metatags.collections.title":"Collections | Dashbrd","metatags.collections.view.title":"{{name}} Collection | Dashbrd","metatags.collections.view.description":"Immerse yourself in the intricate details of {{name}} collection, featuring remarkable digital assets. Start your NFT journey today!","metatags.collections.view.image":"/images/meta/nft-collection.png","metatags.nfts.view.title":"{{nft}} NFT | Dashbrd","metatags.nfts.view.description":"Uncover the complete story of {{nft}} NFT from the {{collection}} collection, delving into its unique attributes and distinctive features.","metatags.nfts.view.image":"/images/meta/nft-details.png","metatags.settings.title":"Settings | Dashbrd","metatags.login.title":"Login | Dashbrd","metatags.privacy_policy.title":"Privacy Policy | Dashbrd","metatags.privacy_policy.description":"Dashbrd’s privacy policy outlines the information we collect and explains your choices surrounding how we use information about you.","metatags.terms_of_service.title":"Terms of Service | Dashbrd","metatags.terms_of_service.description":"These Terms of Service cover your use and access to services, products or websites of Dashbrd.","metatags.cookie_policy.title":"Cookie Policy | Dashbrd","metatags.cookie_policy.description":"Dashbrd uses cookies to make the website more user-friendly. Find out about the main types of cookies we use, and what we use them for.","metatags.welcome.title":"Welcome to Dashbrd | Web3 Simplified","pages.onboarding.title":"Get Started","pages.onboarding.heading":"Your monkeys were bored and ran off, we are trying to round them up.","pages.onboarding.message":"We are setting up your account. This process usually takes just a few minutes, but can take up to 15 minutes.","pages.error.heading":"Oops, something went wrong ...","pages.error.message":"Please try again or get in touch if the issue persists.","pages.maintenance.title":"Dashbrd is currently down for scheduled maintenance.","pages.maintenance.description":"We expect to be back soon. Thanks for your patience.","pages.dashboard.title":"My Wallet","pages.dashboard.breakdown.title":"Portfolio Breakdown","pages.dashboard.line_chart.data_error":"Could not load chart data","pages.collections.title":"Collections","pages.collections.collections":"Collections","pages.collections.collection_value":"Collection Value","pages.collections.nfts_owned":"NFTs Owned","pages.collections.header_title":"You own <0>{{nftsCount}} {{nfts}} across <0>{{collectionsCount}} {{collections}}, worth about <0><1>{{worth}}","pages.collections.search_placeholder":"Search by Collection","pages.collections.properties":"Properties","pages.collections.collections_network":"Collections Network","pages.collections.property_search_placeholder":"Feature Search","pages.collections.floor_price":"Floor Price","pages.collections.value":"Value","pages.collections.rarity":"Rarity","pages.collections.report":"Report","pages.collections.hide_collection":"Hide Collection","pages.collections.unhide_collection":"Unhide Collection","pages.collections.no_collections":"You do not own any NFTs yet. Once you do they will be shown here.","pages.collections.all_collections_hidden":"You have hidden all your collections. Unhide and they will appear here.","pages.collections.about_collection":"About Collection","pages.collections.show_hidden":"Show Hidden","pages.collections.show_my_collection":"Show My Collection","pages.collections.owned":"Owned","pages.collections.activities.loading_activities":"We're fetching Activity for this NFT, please hang tight, this can take a while.","pages.collections.activities.no_activity":"This collection does not have any activity yet.","pages.collections.activities.types.LABEL_MINT":"Mint","pages.collections.activities.types.LABEL_TRANSFER":"Transfer","pages.collections.activities.types.LABEL_SALE":"Sale","pages.collections.search.loading_results":"Loading results...","pages.collections.search.no_results":"We could not find anything matching your search criteria, please try again!","pages.collections.search.no_results_with_filters":"We could not find anything matching your filters, please try again!","pages.collections.search.no_results_ownership":"You do not own any NFTs in this collection","pages.collections.search.error":"Could not load search results. Please try again later.","pages.collections.sorting.token_number":"Token Number","pages.collections.sorting.recently_received":"Recently Received","pages.collections.sorting.recently_created":"Recently Created","pages.collections.sorting.oldest_collection":"Oldest Collection","pages.collections.traits.description":"List of NFT traits by % of occurrence in the collection","pages.collections.traits.no_traits":"No Properties can be found for this NFT","pages.collections.menu.collection":"Collection","pages.collections.menu.activity":"Activity","pages.collections.hidden_modal.collection_hidden":"Collection Hidden","pages.collections.hidden_modal.description":"This collection is currently set to Hidden. Are you sure you want to unhide this collection? You can\n reset the collection to hidden from the collection menu.","pages.collections.hidden_modal.unhide":"Unhide","pages.collections.hidden_modal.error":"Something went wrong. Please try again.","pages.collections.external_modal.you_wish_continue":"You are about to leave Dashbrd to an external website. Dashbrd has no control over the content of\n this site. Are you sure you wish to continue?","pages.collections.external_modal.not_show":"Do not show this message again.","pages.nfts.nft":"nft","pages.nfts.about_nft":"About NFT","pages.nfts.owned_by":"Owned by","pages.nfts.collection_image":"collection image","pages.nfts.menu.properties":"Properties","pages.nfts.menu.activity":"Activity","pages.reports.title":"Submit a Report","pages.reports.description":"Thanks for looking out by reporting things that break the rules. Let us know what's happening and we'll receive the report.","pages.reports.success":"Thank you for your report. We'll review it and see if it breaks our ToS.","pages.reports.failed":"Something went wrong. Please try again.","pages.reports.throttle":"You have made too many requests. Please wait {{time}} before reporting again.","pages.reports.reported":"You have already reported this {{model}}.","pages.reports.reasons.spam":"Spam","pages.reports.reasons.violence":"Promoting Violence","pages.reports.reasons.hate":"Hate","pages.reports.reasons.inappropriate_content":"Inappropriate Content","pages.reports.reasons.impersonation":"Impersonation","pages.reports.reasons.trademark":"Trademark or Copyright","pages.reports.reasons.selfharm":"Self-Harm","pages.reports.reasons.harassment":"Harassment","pages.galleries.title":"Galleries","pages.galleries.empty_title":"No galleries have been published yet. Once they do they will appear here.","pages.galleries.search.loading_results":"Loading results...","pages.galleries.search.no_results":"We could not find anything matching your search criteria, please try again!","pages.galleries.search.placeholder":"Search by Galleries","pages.galleries.search.placeholder_nfts":"Search by NFTs","pages.galleries.search.error":"Could not load search results. Please try again later.","pages.galleries.my_galleries.title":"My Galleries","pages.galleries.my_galleries.new_gallery":"New Gallery","pages.galleries.my_galleries.no_galleries":"You have not created any galleries yet. To create a gallery, click on the \"Create Gallery\" button.","pages.galleries.my_galleries.succesfully_deleted":"Gallery successfully deleted","pages.galleries.my_galleries.successfully_created":"Gallery has been successfully created","pages.galleries.my_galleries.successfully_updated":"Gallery has been successfully updated","pages.galleries.my_galleries.new_gallery_no_nfts":"Creating a Gallery requires you to own an NFT.","pages.galleries.copy_gallery_link":"Copy Gallery Link","pages.galleries.my_nfts":"My NFTs","pages.galleries.value":"Value","pages.galleries.floor_price":"Floor Price","pages.galleries.nfts":"NFTs","pages.galleries.collections":"Collections","pages.galleries.galleries_count_simple":"{0} galleries|{1} gallery|[2,*] galleries","pages.galleries.galleries_count":"{0} {{count}} Galleries|{1} {{count}} Gallery|[2,*] {{count}} Galleries","pages.galleries.collections_count_simple":"{0} collections|{1} collection|[2,*] collections","pages.galleries.collections_count":"{0} {{count}} Collections|{1} {{count}} Collection|[2,*] {{count}} Collections","pages.galleries.nfts_count_simple":"{0} NFTs|{1} NFT|[2,*] NFTs","pages.galleries.nfts_count":"{0} {{count}} NFTs|{1} {{count}} NFT|[2,*] {{count}} NFTs","pages.galleries.users_count_simple":"{0} users|{1} user|[2,*] users","pages.galleries.users_count":"{0} {{count}} Users|{1} {{count}} User|[2,*] {{count}} Users","pages.galleries.featuring":"Featuring","pages.galleries.curated_by":"Curated by","pages.galleries.worth_about":"Worth About","pages.galleries.valued_at":"valued at","pages.galleries.from":"From","pages.galleries.most_popular_galleries":"Most Popular Galleries","pages.galleries.newest_galleries":"Newest Galleries","pages.galleries.most_valuable_galleries":"Most Valuable Galleries","pages.galleries.most_popular":"Most Popular","pages.galleries.newest":"Newest","pages.galleries.most_valuable":"Most Valuable","pages.galleries.create.search_by_nfts":"Search by NFTs","pages.galleries.create.input_placeholder":"Enter gallery name","pages.galleries.create.title_too_long":"Gallery name must not exceed {{max}} characters.","pages.galleries.create.already_selected_nft":"NFT already exists in this gallery","pages.galleries.create.gallery_cover":"Gallery Cover","pages.galleries.create.gallery_cover_description":"The cover is used for the card on the gallery list page. While the cover is not a requirement it will allow you to add personality and stand out from the crowd.","pages.galleries.create.gallery_cover_information":"Image dimensions must be at least 287px x 190px, with a max size of 2 MB (JPG, PNG or GIF)","pages.galleries.create.no_results":"We could not find anything matching your search criteria, please try again!","pages.galleries.create.templates.cover":"Cover","pages.galleries.create.templates.template":"Template","pages.galleries.create.templates.select":"Select Gallery Template","pages.galleries.create.templates.basic":"Basic Gallery","pages.galleries.create.templates.coming_soon":"More Coming Soon","pages.galleries.create.load_more_collections_one":"Load {{count}} More Collection","pages.galleries.create.load_more_collections_other":"Load {{count}} More Collections","pages.galleries.create.load_more_nfts":"Load More NFTs","pages.galleries.create.can_purchase":"You can purchase NFTs with these top NFT Marketplaces:","pages.galleries.create.must_own_one_nft":"You must own at least one (1) NFT in order to create a gallery.","pages.galleries.create.back_to_galleries":"Back to Galleries","pages.galleries.delete_modal.title":"Delete Gallery","pages.galleries.delete_modal.confirmation_text":"Are you sure you want to delete the gallery? Everything you've done will be deleted and you won't be able to get it back.","pages.galleries.consists_of_collections":"{0} This gallery consists of {{count}} collections|{1} This gallery consists of {{count}} collection|[2,*] This gallery consists of {{count}} collections","pages.galleries.guest_banner.title":"Craft the ultimate","pages.galleries.guest_banner.subtitle":"Pick your favorites, curate your gallery, & share it with the world.","pages.profile.title":"Profile","pages.token_panel.balance_tooltip":"Total percentage of the portfolio held in this token","pages.token_panel.insufficient_funds":"Insufficient Balance","pages.token_panel.error":"Dashbrd has failed to load token information. Please try again later.","pages.token_panel.failed_to_retrieve_transactions":"We were unable to fetch your transactions.","pages.token_panel.tabs.transaction_history":"Transaction History","pages.token_panel.tabs.history":"History","pages.token_panel.tabs.market_data":"Market Data","pages.token_panel.details.current_price":"Current Price","pages.token_panel.details.title":"Token Details","pages.token_panel.details.market_cap":"Market Cap","pages.token_panel.details.volume":"Daily Volume","pages.token_panel.details.supply":"Minted Supply","pages.token_panel.details.ath":"All-Time High","pages.token_panel.details.atl":"All-Time Low","pages.token_panel.chart.failed":"Dashbrd has failed to load chart information. Please try again later.","pages.transaction_details_panel.title":"Transaction Details","pages.transaction_details_panel.details.blockchain":"Blockchain","pages.transaction_details_panel.details.timestamp":"Timestamp","pages.transaction_details_panel.details.transaction_hash":"Transaction Hash","pages.transaction_details_panel.details.transaction_fee":"Transaction Fee","pages.transaction_details_panel.details.gas_price":"Gas Price","pages.transaction_details_panel.details.gas_used":"Gas Used","pages.transaction_details_panel.details.nonce":"Nonce","pages.send_receive_panel.send.labels.token_and_amount":"Token and Amount","pages.send_receive_panel.send.labels.destination_address":"Destination Address","pages.send_receive_panel.send.labels.projected_fee":"Projected Fee","pages.send_receive_panel.send.placeholders.enter_amount":"Enter Amount","pages.send_receive_panel.send.placeholders.insert_recipient_address":"Insert Recipient Address","pages.send_receive_panel.send.placeholders.projected_fee":"Projected Fee","pages.send_receive_panel.send.errors.amount":"Insufficient Funds: You do not have enough to cover the amount + fee.","pages.send_receive_panel.send.errors.destination":"Destination address is not correct. Check and input again.","pages.send_receive_panel.send.hints.token_price":"Token Price","pages.send_receive_panel.send.fees.Fast":"Fast","pages.send_receive_panel.send.fees.Avg":"Avg","pages.send_receive_panel.send.fees.Slow":"Slow","pages.send_receive_panel.send.search_dropdown.placeholder":"Search token","pages.send_receive_panel.send.search_dropdown.no_results":"No results","pages.send_receive_panel.send.search_dropdown.error":"Error occurred while searching tokens.","pages.send_receive_panel.send.transaction_time":"Transaction Time: ~{{ time }} minutes","pages.send_receive_panel.send.from":"From","pages.send_receive_panel.send.to":"To","pages.send_receive_panel.send.amount":"Amount","pages.send_receive_panel.send.fee":"Fee","pages.send_receive_panel.send.total_amount":"Total Amount","pages.send_receive_panel.send.waiting_message":"Review and verify the information on your MetaMask. Sign to send the transaction.","pages.send_receive_panel.send.waiting_spinner_text":"Waiting for confirmation...","pages.send_receive_panel.send.failed_message":"It looks like something went wrong while sending your transaction. Press 'Retry' to make another attempt.","pages.send_receive_panel.receive.alert":"Send only Polygon or Ethereum Network compatible tokens to this address or you could permanently lose your funds!","pages.settings.title":"Settings","pages.settings.sidebar.general":"General","pages.settings.sidebar.notifications":"Notifications","pages.settings.sidebar.session_history":"Sessions History","pages.settings.general.title":"Settings","pages.settings.general.subtitle":"Customize your App Experience","pages.settings.general.currency":"Currency","pages.settings.general.currency_subtitle":"Select your default currency which will be used throughout the app.","pages.settings.general.time_date":"Time & Date","pages.settings.general.time_date_subtitle":"Select how you want time and date be shown inside app.","pages.settings.general.date_format":"Date Format","pages.settings.general.time_format":"Time Format","pages.settings.general.timezone":"Timezone","pages.settings.general.set_defaults":"Set Defaults","pages.settings.general.set_defaults_content":"Reverting to the default settings will remove any customizations previously made. Are you sure?","pages.settings.general.save":"Save Settings","pages.settings.general.saved":"Your settings have been successfully saved","pages.wallet.title":"Wallet","pages.privacy_policy.title":"Privacy Policy","pages.terms_of_service.title":"Terms of Service","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset!","passwords.sent":"We have emailed your password reset link!","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","urls.landing":"https://dashbrd.com","urls.cookie_policy":"https://dashbrd.com/cookie-policy","urls.privacy_policy":"https://dashbrd.com/privacy-policy","urls.terms_of_service":"https://dashbrd.com/terms-of-service","urls.twitter":"https://x.com/DashbrdApp","urls.discord":"https://discord.gg/MJyWKkCJ5k","urls.github":"https://github.com/ArdentHQ/dashbrd","urls.coingecko":"https://www.coingecko.com","urls.etherscan":"https://etherscan.io","urls.polygonscan":"https://polygonscan.com","urls.alchemy":"https://www.alchemy.com","urls.moralis":"https://moralis.io","urls.mnemonic":"https://www.mnemonichq.com","urls.opensea":"https://opensea.io/","urls.explorers.etherscan.token_transactions":"https://etherscan.io/token/{{token}}?a={{address}}","urls.explorers.etherscan.addresses":"https://etherscan.io/address/{{address}}","urls.explorers.etherscan.transactions":"https://etherscan.io/tx/{{id}}","urls.explorers.etherscan.nft":"https://etherscan.io/nft/{{address}}/{{nftId}}","urls.explorers.polygonscan.token_transactions":"https://polygonscan.com/token/{{token}}?a={{address}}","urls.explorers.polygonscan.addresses":"https://polygonscan.com/address/{{address}}","urls.explorers.polygonscan.transactions":"https://polygonscan.com/tx/{{id}}","urls.explorers.polygonscan.nft":"https://polygonscan.com/nft/{{address}}/{{nftId}}","urls.explorers.mumbai.token_transactions":"https://mumbai.polygonscan.com/token/{{token}}?a={{address}}","urls.explorers.mumbai.addresses":"https://mumbai.polygonscan.com/address/{{address}}","urls.explorers.mumbai.transactions":"https://mumbai.polygonscan.com/tx/{{id}}","urls.explorers.mumbai.nft":"https://mumbai.polygonscan.com/nft/{{address}}/{{nftId}}","urls.explorers.goerli.token_transactions":"https://goerli.etherscan.io/token/{{token}}?a={{address}}","urls.explorers.goerli.addresses":"https://goerli.etherscan.io/address/{{address}}","urls.explorers.goerli.transactions":"https://goerli.etherscan.io/tx/{{id}}","urls.explorers.goerli.nft":"https://goerli.etherscan.io/nft/{{address}}/{{nftId}}","urls.marketplaces.opensea.collection":"https://opensea.io/assets/{{network}}/{{address}}","urls.marketplaces.opensea.nft":"https://opensea.io/assets/{{network}}/{{address}}/{{nftId}}","urls.marketplaces.rarible.collection":"https://rarible.com/collection/{{address}}/items","urls.marketplaces.rarible.nft":"https://rarible.com/token/{{address}}:{{nftId}}","urls.marketplaces.blur.collection":"https://blur.io/collection/{{address}}","urls.marketplaces.blur.nft":"https://blur.io/asset/{{address}}/{{nftId}}","urls.marketplaces.looksrare.collection":"https://looksrare.org/collections/{{address}}","urls.marketplaces.looksrare.nft":"https://looksrare.org/collections/{{address}}/{{nftId}}","validation.accepted":"The {{attribute}} must be accepted.","validation.accepted_if":"The {{attribute}} must be accepted when {{other}} is {{value}}.","validation.active_url":"The {{attribute}} is not a valid URL.","validation.after":"The {{attribute}} must be a date after {{date}}.","validation.after_or_equal":"The {{attribute}} must be a date after or equal to {{date}}.","validation.alpha":"The {{attribute}} must only contain letters.","validation.alpha_dash":"The {{attribute}} must only contain letters, numbers, dashes and underscores.","validation.alpha_num":"The {{attribute}} must only contain letters and numbers.","validation.array":"The {{attribute}} must be an array.","validation.ascii":"The {{attribute}} must only contain single-byte alphanumeric characters and symbols.","validation.before":"The {{attribute}} must be a date before {{date}}.","validation.before_or_equal":"The {{attribute}} must be a date before or equal to {{date}}.","validation.between.array":"The {{attribute}} must have between {{min}} and {{max}} items.","validation.between.file":"The {{attribute}} must be between {{min}} and {{max}} kilobytes.","validation.between.numeric":"The {{attribute}} must be between {{min}} and {{max}}.","validation.between.string":"The {{attribute}} must be between {{min}} and {{max}} characters.","validation.boolean":"The {{attribute}} field must be true or false.","validation.confirmed":"The {{attribute}} confirmation does not match.","validation.current_password":"The password is incorrect.","validation.date":"The {{attribute}} is not a valid date.","validation.date_equals":"The {{attribute}} must be a date equal to {{date}}.","validation.date_format":"The {{attribute}} does not match the format {{format}}.","validation.decimal":"The {{attribute}} must have {{decimal}} decimal places.","validation.declined":"The {{attribute}} must be declined.","validation.declined_if":"The {{attribute}} must be declined when {{other}} is {{value}}.","validation.different":"The {{attribute}} and {{other}} must be different.","validation.digits":"The {{attribute}} must be {{digits}} digits.","validation.digits_between":"The {{attribute}} must be between {{min}} and {{max}} digits.","validation.dimensions":"The {{attribute}} has invalid image dimensions.","validation.distinct":"The {{attribute}} field has a duplicate value.","validation.doesnt_end_with":"The {{attribute}} may not end with one of the following: {{values}}.","validation.doesnt_start_with":"The {{attribute}} may not start with one of the following: {{values}}.","validation.email":"The {{attribute}} must be a valid email address.","validation.ends_with":"The {{attribute}} must end with one of the following: {{values}}.","validation.enum":"The selected {{attribute}} is invalid.","validation.exists":"The selected {{attribute}} is invalid.","validation.file":"The {{attribute}} must be a file.","validation.filled":"The {{attribute}} field must have a value.","validation.gt.array":"The {{attribute}} must have more than {{value}} items.","validation.gt.file":"The {{attribute}} must be greater than {{value}} kilobytes.","validation.gt.numeric":"The {{attribute}} must be greater than {{value}}.","validation.gt.string":"The {{attribute}} must be greater than {{value}} characters.","validation.gte.array":"The {{attribute}} must have {{value}} items or more.","validation.gte.file":"The {{attribute}} must be greater than or equal to {{value}} kilobytes.","validation.gte.numeric":"The {{attribute}} must be greater than or equal to {{value}}.","validation.gte.string":"The {{attribute}} must be greater than or equal to {{value}} characters.","validation.image":"The {{attribute}} must be an image.","validation.in":"The selected {{attribute}} is invalid.","validation.in_array":"The {{attribute}} field does not exist in {{other}}.","validation.integer":"The {{attribute}} must be an integer.","validation.ip":"The {{attribute}} must be a valid IP address.","validation.ipv4":"The {{attribute}} must be a valid IPv4 address.","validation.ipv6":"The {{attribute}} must be a valid IPv6 address.","validation.json":"The {{attribute}} must be a valid JSON string.","validation.lowercase":"The {{attribute}} must be lowercase.","validation.lt.array":"The {{attribute}} must have less than {{value}} items.","validation.lt.file":"The {{attribute}} must be less than {{value}} kilobytes.","validation.lt.numeric":"The {{attribute}} must be less than {{value}}.","validation.lt.string":"The {{attribute}} must be less than {{value}} characters.","validation.lte.array":"The {{attribute}} must not have more than {{value}} items.","validation.lte.file":"The {{attribute}} must be less than or equal to {{value}} kilobytes.","validation.lte.numeric":"The {{attribute}} must be less than or equal to {{value}}.","validation.lte.string":"The {{attribute}} must be less than or equal to {{value}} characters.","validation.mac_address":"The {{attribute}} must be a valid MAC address.","validation.max.array":"The {{attribute}} must not have more than {{max}} items.","validation.max.file":"The {{attribute}} must not be greater than {{max}} kilobytes.","validation.max.numeric":"The {{attribute}} must not be greater than {{max}}.","validation.max.string":"The {{attribute}} must not be greater than {{max}} characters.","validation.max_digits":"The {{attribute}} must not have more than {{max}} digits.","validation.mimes":"The {{attribute}} must be a file of type: {{values}}.","validation.mimetypes":"The {{attribute}} must be a file of type: {{values}}.","validation.min.array":"The {{attribute}} must have at least {{min}} items.","validation.min.file":"The {{attribute}} must be at least {{min}} kilobytes.","validation.min.numeric":"The {{attribute}} must be at least {{min}}.","validation.min.string":"The {{attribute}} must be at least {{min}} characters.","validation.min_digits":"The {{attribute}} must have at least {{min}} digits.","validation.missing":"The {{attribute}} field must be missing.","validation.missing_if":"The {{attribute}} field must be missing when {{other}} is {{value}}.","validation.missing_unless":"The {{attribute}} field must be missing unless {{other}} is {{value}}.","validation.missing_with":"The {{attribute}} field must be missing when {{values}} is present.","validation.missing_with_all":"The {{attribute}} field must be missing when {{values}} are present.","validation.multiple_of":"The {{attribute}} must be a multiple of {{value}}.","validation.not_in":"The selected {{attribute}} is invalid.","validation.not_regex":"The {{attribute}} format is invalid.","validation.numeric":"The {{attribute}} must be a number.","validation.password.letters":"The {{attribute}} must contain at least one letter.","validation.password.mixed":"The {{attribute}} must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The {{attribute}} must contain at least one number.","validation.password.symbols":"The {{attribute}} must contain at least one symbol.","validation.password.uncompromised":"The given {{attribute}} has appeared in a data leak. Please choose a different {{attribute}}.","validation.present":"The {{attribute}} field must be present.","validation.prohibited":"The {{attribute}} field is prohibited.","validation.prohibited_if":"The {{attribute}} field is prohibited when {{other}} is {{value}}.","validation.prohibited_unless":"The {{attribute}} field is prohibited unless {{other}} is in {{values}}.","validation.prohibits":"The {{attribute}} field prohibits {{other}} from being present.","validation.regex":"The {{attribute}} format is invalid.","validation.required":"The {{attribute}} field is required.","validation.required_array_keys":"The {{attribute}} field must contain entries for: {{values}}.","validation.required_if":"The {{attribute}} field is required when {{other}} is {{value}}.","validation.required_if_accepted":"The {{attribute}} field is required when {{other}} is accepted.","validation.required_unless":"The {{attribute}} field is required unless {{other}} is in {{values}}.","validation.required_with":"The {{attribute}} field is required when {{values}} is present.","validation.required_with_all":"The {{attribute}} field is required when {{values}} are present.","validation.required_without":"The {{attribute}} field is required when {{values}} is not present.","validation.required_without_all":"The {{attribute}} field is required when none of {{values}} are present.","validation.same":"The {{attribute}} and {{other}} must match.","validation.size.array":"The {{attribute}} must contain {{size}} items.","validation.size.file":"The {{attribute}} must be {{size}} kilobytes.","validation.size.numeric":"The {{attribute}} must be {{size}}.","validation.size.string":"The {{attribute}} must be {{size}} characters.","validation.starts_with":"The {{attribute}} must start with one of the following: {{values}}.","validation.string":"The {{attribute}} must be a string.","validation.timezone":"The {{attribute}} must be a valid timezone.","validation.unique":"The {{attribute}} has already been taken.","validation.uploaded":"The {{attribute}} failed to upload.","validation.uppercase":"The {{attribute}} must be uppercase.","validation.url":"The {{attribute}} must be a valid URL.","validation.ulid":"The {{attribute}} must be a valid ULID.","validation.uuid":"The {{attribute}} must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","validation.unsupported_currency_code":"The currency code you provided is invalid or not supported.","validation.unsupported_period":"The period you provided is invalid or not supported.","validation.unsupported_token_symbol":"The token symbol you provided is invalid or not supported.","validation.gallery_title_required":"Gallery name is required.","validation.gallery_title_max_characters":"The gallery name should not exceed 50 characters.","validation.gallery_title_invalid":"The gallery name is invalid.","validation.nfts_required":"Please add at least one NFT.","validation.nfts_max_size":"Galleries can contain no more than {{limit}} NFTs","validation.invalid_nfts":"The NFT in position {{position}} is invalid, please select another one.","validation.invalid_cover":"You have selected an invalid cover image, please try another one."} \ No newline at end of file +{"auth.welcome":"Welcome to Dashbrd","auth.logged_in":"You're logged in!","auth.log_out":"Log out","auth.failed":"These credentials do not match our records.","auth.session_timeout":"Your session has timed out. Please refresh the page and try connecting your account again.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in {{seconds}} seconds.","auth.wallet.connecting":"Connecting …","auth.wallet.waiting_for_signature":"Waiting for Signature …","auth.wallet.switching_wallet":"Switching Wallet …","auth.wallet.connect":"Connect Wallet","auth.wallet.connect_long":"Connect Your Wallet to Get Started","auth.wallet.sign_subtitle":"Connect Your Wallet to Continue","auth.wallet.disconnect":"Disconnect Wallet","auth.wallet.install":"Install MetaMask","auth.wallet.install_long":"Install MetaMask to Get Started","auth.wallet.sign":"Sign Message","auth.wallet.sign_message":"Welcome to Dashbrd. In order to login, sign this message with your wallet. It doesn't cost you anything!\n\nSigning ID (you can ignore this): {{nonce}}","auth.wallet.connect_subtitle":"Click on the MetaMask icon in your browser to confirm the action and connect your wallet.","auth.wallet.requires_signature":"In order to prevent impersonation, we require a signature to perform this action. This signature is only a signed message and does not give any access to your wallet.","auth.errors.metamask.no_account":"No account found. Please connect your wallet and try again.","auth.errors.metamask.generic":"Connection attempt error. Please retry and follow the steps to connect your wallet.","auth.errors.metamask.invalid_network":"Please switch to Polygon or Ethereum Mainnet in your MetaMask plugin to connect to Dashbrd.","auth.errors.metamask.provider_missing":"You don't have MetaMask installed in your browser. Please install and try again.","auth.errors.metamask.user_rejected":"It looks like you cancelled signing of the authentication message. Please try again.","auth.errors.metamask.provider_not_set":"Ethereum provider is not set","auth.validation.wallet_login_failed":"There was a problem trying to verify your signature. Please try again.","auth.validation.invalid_address":"Your wallet address is invalid. Please try again.","auth.validation.invalid_signature":"Signature is invalid. Please try again.","auth.validation.invalid_network":"Please switch to Polygon or Ethereum Mainnet in your MetaMask plugin to connect to Dashbrd.","common.add":"Add","common.amount":"Amount","common.balance":"Balance","common.cancel":"Cancel","common.delete":"Delete","common.edit":"Edit","common.confirm":"Confirm","common.connect":"Connect","common.continue":"Continue","common.done":"Done","common.filter":"Filter","common.items":"Items","common.receive":"Receive","common.received":"Received","common.retry":"Retry","common.records":"Records","common.save":"Save","common.send":"Send","common.sent":"Sent","common.show":"Show","common.searching":"Searching...","common.other":"Other","common.owned":"Owned","common.token":"Token","common.tokens":"Tokens","common.wallet":"Wallet","common.pending":"Pending","common.publish":"Publish","common.empty":"Empty","common.your_address":"Your Address","common.warning":"Warning","common.my_address":"My Address","common.my_wallet":"My Wallet","common.my_balance":"My Balance","common.na":"N/A","common.simple_plural_without_data":"One comment|Many comments","common.simple_plural_with_data":"{{count}} comment|{{count}} comments","common.advanced_plural_without_data":"{0} no comments yet|{1} 1 comment|[2,*] Many comments","common.advanced_plural_with_data":"{0} no comments yet|{1} 1 comment|[2,*] {{count}} comments","common.copy_clipboard":"Copy to Clipboard","common.copy":"Copy","common.download":"Download","common.zoom":"Zoom","common.my_collection":"My Collection","common.max":"Max","common.chain":"Chain","common.copied":"Copied!","common.coming_soon":"Coming Soon","common.more_details":"More Details","common.close":"Close","common.close_toast":"Close toast","common.loading":"Loading","common.price":"Price","common.market_cap":"Market Cap","common.volume":"Volume {{frequency}}","common.value":"Value","common.last_n_days":"Last {{count}} Days","common.details":"Details","common.view_all":"View All","common.view_more_on_polygonscan":"View More on Polygonscan","common.view_more_on_etherscan":"View More on Etherscan","common.view_nft_on_etherscan":"View NFT on Etherscan","common.view_nft_on_polygonscan":"View NFT on Polygonscan","common.view_nft_on_goerli_tesnet":"View NFT on Goerli Testnet Explorer","common.view_nft_on_mumbai_tesnet":"View NFT on Mumbai Testnet Explorer","common.polygonscan":"Polygonscan","common.etherscan":"Etherscan","common.featured":"Featured","common.floor_price":"Floor Price","common.floor":"Floor","common.supply":"Supply","common.owners":"Owners","common.created":"Created","common.nft":"NFT","common.collection":"Collection","common.collections":"Collections","common.gallery":"Gallery","common.basic_gallery":"Basic Gallery","common.gallery_name":"Gallery Name","common.create_gallery":"Create Gallery","common.external_link":"External Link","common.follow_link":"Follow Link","common.cover":"Cover","common.template":"Template","common.page":"Page","common.polygon":"Polygon","common.ethereum":"Ethereum","common.mumbai":"Mumbai","common.goerli":"Goerli","common.of":"of","common.pagination_input_placeholder":"Enter the page number","common.pagination_input_placeholder_mobile":"Page number","common.nft_count":"{0} {{count}} NFTs|{1} {{count}} NFT|[2,*] {{count}} NFTs","common.nft_gallery":"NFT Gallery","common.unable_to_retrieve_image":"Unable to retrieve your NFT image at this time","common.optional":"Optional","common.selected":"Selected","common.select":"Select","common.preview":"Preview","common.image_size_error":"Image size must not exceed 2MB","common.image_dimensions_error":"Image dimensions must exceed 287px x 190px","common.write_to_confirm":"Write {{word}} to confirm","common.n_hours":"{0} {{count}} hour|{1} {{count}} hour|[2,*] {{count}} hours","common.n_minutes":"{0} {{count}} minute|{1} {{count}} minute|[2,*] {{count}} minutes","common.report":"Report","common.n_nfts":"{0}NFTs|{1}NFT|[2,*]NFTs","common.n_collections":"{0}collections|{1}collection|[2,*]collections","common.back":"Back","common.back_to":"Back to","common.name":"Name","common.type":"Type","common.from":"From","common.to":"To","common.sale_price":"Sale Price","common.recent_activity":"Recent Activity","common.time":"Time","common.datetime.few_seconds_ago":"A few seconds ago","common.datetime.minutes_ago":"{0} Less than a minute ago|{1} A minute ago|[2,*] {{count}} minutes ago","common.datetime.hours_ago":"{0} Less than an hour ago|{1} An hour ago|[2,*] {{count}} hours ago","common.datetime.days_ago":"{0} Less than a day ago|{1} A day ago|[2,*] {{count}} days ago","common.datetime.weeks_ago":"{0} Less than a week ago|{1} A week ago|[2,*] {{count}} weeks ago","common.datetime.months_ago":"{0} Less than a month ago|{1} A month ago|[2,*] {{count}} months ago","common.datetime.years_ago":"{0} Less than a year ago|{1} A year ago|[2,*] {{count}} years ago","common.no_traits_found":"No traits found.","common.empty_transactions":"You don't have any transactions yet. Once transactions have been made, they will show up here.","common.error":"Error","common.refresh_metadata":"Refresh Metadata","common.refreshing_metadata":"Refreshing Metadata... Please check back later.","common.pending_confirmation":"Pending Confirmation","common.confirmed_transaction":"Confirmed Transaction","common.transaction_error":"Transaction Error","common.transaction_error_description_first_part":"Your transaction encountered an error. View this transaction on","common.transaction_error_description_second_part":"for more details.","common.home":"Home","common.contact":"Contact","common.menu":"Menu","common.website":"Website","common.twitter":"Twitter","common.discord":"Discord","common.sort":"Sort","footer.copyright":"{{year}} © Dashbrd. All rights reserved.","footer.all_rights_reserved":"All rights reserved","footer.privacy_policy":"Privacy Policy","footer.terms_of_service":"Terms of Service","footer.powered_by":"Powered by","format.fiat":"{{ value, currency }}","format.number":"{{ value, number }}","metatags.home.title":"Dashbrd | Web3 Portfolio Management Made Simple","metatags.home.description":"Simplify your Web3 journey with Dashbrd. Manage your portfolio of tokens, NFTs, and other digital collectibles across the Ethereum and Polygon blockchains.","metatags.home.image":"/images/meta/home.png","metatags.error.title":"Error {{code}} | Dashbrd","metatags.wallet.title":"My Wallet | Dashbrd","metatags.galleries.title":"Top NFT Galleries | Dashbrd","metatags.galleries.image":"/images/meta/nft-galleries.png","metatags.galleries.description":"Explore user published NFT galleries to find custom curated PFPs, Digital Collectibles, & More.","metatags.galleries.most_popular.title":"Most Popular Galleries | Dashbrd","metatags.galleries.most_popular.image":"/images/meta/most-popular-nft-galleries.png","metatags.galleries.most_popular.description":"Explore and discover Most Popular NFT Galleries created by our users featuring custom curated PFPs, Digital Collectibles, & More.","metatags.galleries.newest.title":"Newest Galleries | Dashbrd","metatags.galleries.newest.image":"/images/meta/newest-nft-galleries.png","metatags.galleries.newest.description":"Explore and discover most recent NFT galleries created by our users featuring custom curated PFPs, Digital Collectibles, & More","metatags.galleries.most_valuable.title":"Most Valuable Galleries | Dashbrd","metatags.galleries.most_valuable.image":"/images/meta/most-valuable-nft-galleries.png","metatags.galleries.most_valuable.description":"Explore and discover Most Valuable NFT Galleries created by our users featuring custom curated PFPs, Digital Collectibles, & More.","metatags.galleries.view.title":"{{name}} | Dashbrd","metatags.galleries.view.description":"{{name}} | A Curated NFT Gallery at Dashbrd","metatags.galleries.view.image":"/images/meta/nft-gallery.png","metatags.my_galleries.title":"My Galleries | Dashbrd","metatags.my_galleries.create.title":"Create Gallery | Dashbrd","metatags.my_galleries.edit.title":"Edit {{name}} | Dashbrd","metatags.collections.title":"Collections | Dashbrd","metatags.collections.view.title":"{{name}} Collection | Dashbrd","metatags.collections.view.description":"Immerse yourself in the intricate details of {{name}} collection, featuring remarkable digital assets. Start your NFT journey today!","metatags.collections.view.image":"/images/meta/nft-collection.png","metatags.nfts.view.title":"{{nft}} NFT | Dashbrd","metatags.nfts.view.description":"Uncover the complete story of {{nft}} NFT from the {{collection}} collection, delving into its unique attributes and distinctive features.","metatags.nfts.view.image":"/images/meta/nft-details.png","metatags.settings.title":"Settings | Dashbrd","metatags.login.title":"Login | Dashbrd","metatags.privacy_policy.title":"Privacy Policy | Dashbrd","metatags.privacy_policy.description":"Dashbrd’s privacy policy outlines the information we collect and explains your choices surrounding how we use information about you.","metatags.terms_of_service.title":"Terms of Service | Dashbrd","metatags.terms_of_service.description":"These Terms of Service cover your use and access to services, products or websites of Dashbrd.","metatags.cookie_policy.title":"Cookie Policy | Dashbrd","metatags.cookie_policy.description":"Dashbrd uses cookies to make the website more user-friendly. Find out about the main types of cookies we use, and what we use them for.","metatags.welcome.title":"Welcome to Dashbrd | Web3 Simplified","pages.onboarding.title":"Get Started","pages.onboarding.heading":"Your monkeys were bored and ran off, we are trying to round them up.","pages.onboarding.message":"We are setting up your account. This process usually takes just a few minutes, but can take up to 15 minutes.","pages.error.heading":"Oops, something went wrong ...","pages.error.message":"Please try again or get in touch if the issue persists.","pages.maintenance.title":"Dashbrd is currently down for scheduled maintenance.","pages.maintenance.description":"We expect to be back soon. Thanks for your patience.","pages.dashboard.title":"My Wallet","pages.dashboard.breakdown.title":"Portfolio Breakdown","pages.dashboard.line_chart.data_error":"Could not load chart data","pages.collections.title":"Collections","pages.collections.collections":"Collections","pages.collections.collection_value":"Collection Value","pages.collections.nfts_owned":"NFTs Owned","pages.collections.header_title":"You own <0>{{nftsCount}} {{nfts}} across <0>{{collectionsCount}} {{collections}}, worth about <0><1>{{worth}}","pages.collections.search_placeholder":"Search by Collection","pages.collections.properties":"Properties","pages.collections.collections_network":"Collections Network","pages.collections.property_search_placeholder":"Feature Search","pages.collections.floor_price":"Floor Price","pages.collections.value":"Value","pages.collections.rarity":"Rarity","pages.collections.report":"Report","pages.collections.hide_collection":"Hide Collection","pages.collections.unhide_collection":"Unhide Collection","pages.collections.no_collections":"You do not own any NFTs yet. Once you do they will be shown here.","pages.collections.all_collections_hidden":"You have hidden all your collections. Unhide and they will appear here.","pages.collections.about_collection":"About Collection","pages.collections.show_hidden":"Show Hidden","pages.collections.show_my_collection":"Show My Collection","pages.collections.owned":"Owned","pages.collections.activities.loading_activities":"We're fetching Activity for this NFT, please hang tight, this can take a while.","pages.collections.activities.ignores_activities":"We don't support activity history for this collection yet.","pages.collections.activities.no_activity":"This collection does not have any activity yet.","pages.collections.activities.types.LABEL_MINT":"Mint","pages.collections.activities.types.LABEL_TRANSFER":"Transfer","pages.collections.activities.types.LABEL_SALE":"Sale","pages.collections.search.loading_results":"Loading results...","pages.collections.search.no_results":"We could not find anything matching your search criteria, please try again!","pages.collections.search.no_results_with_filters":"We could not find anything matching your filters, please try again!","pages.collections.search.no_results_ownership":"You do not own any NFTs in this collection","pages.collections.search.error":"Could not load search results. Please try again later.","pages.collections.sorting.token_number":"Token Number","pages.collections.sorting.recently_received":"Recently Received","pages.collections.sorting.recently_created":"Recently Created","pages.collections.sorting.oldest_collection":"Oldest Collection","pages.collections.traits.description":"List of NFT traits by % of occurrence in the collection","pages.collections.traits.no_traits":"No Properties can be found for this NFT","pages.collections.menu.collection":"Collection","pages.collections.menu.activity":"Activity","pages.collections.hidden_modal.collection_hidden":"Collection Hidden","pages.collections.hidden_modal.description":"This collection is currently set to Hidden. Are you sure you want to unhide this collection? You can\n reset the collection to hidden from the collection menu.","pages.collections.hidden_modal.unhide":"Unhide","pages.collections.hidden_modal.error":"Something went wrong. Please try again.","pages.collections.external_modal.you_wish_continue":"You are about to leave Dashbrd to an external website. Dashbrd has no control over the content of\n this site. Are you sure you wish to continue?","pages.collections.external_modal.not_show":"Do not show this message again.","pages.nfts.nft":"nft","pages.nfts.about_nft":"About NFT","pages.nfts.owned_by":"Owned by","pages.nfts.collection_image":"collection image","pages.nfts.menu.properties":"Properties","pages.nfts.menu.activity":"Activity","pages.reports.title":"Submit a Report","pages.reports.description":"Thanks for looking out by reporting things that break the rules. Let us know what's happening and we'll receive the report.","pages.reports.success":"Thank you for your report. We'll review it and see if it breaks our ToS.","pages.reports.failed":"Something went wrong. Please try again.","pages.reports.throttle":"You have made too many requests. Please wait {{time}} before reporting again.","pages.reports.reported":"You have already reported this {{model}}.","pages.reports.reasons.spam":"Spam","pages.reports.reasons.violence":"Promoting Violence","pages.reports.reasons.hate":"Hate","pages.reports.reasons.inappropriate_content":"Inappropriate Content","pages.reports.reasons.impersonation":"Impersonation","pages.reports.reasons.trademark":"Trademark or Copyright","pages.reports.reasons.selfharm":"Self-Harm","pages.reports.reasons.harassment":"Harassment","pages.galleries.title":"Galleries","pages.galleries.empty_title":"No galleries have been published yet. Once they do they will appear here.","pages.galleries.search.loading_results":"Loading results...","pages.galleries.search.no_results":"We could not find anything matching your search criteria, please try again!","pages.galleries.search.placeholder":"Search by Galleries","pages.galleries.search.placeholder_nfts":"Search by NFTs","pages.galleries.search.error":"Could not load search results. Please try again later.","pages.galleries.my_galleries.title":"My Galleries","pages.galleries.my_galleries.new_gallery":"New Gallery","pages.galleries.my_galleries.no_galleries":"You have not created any galleries yet. To create a gallery, click on the \"Create Gallery\" button.","pages.galleries.my_galleries.succesfully_deleted":"Gallery successfully deleted","pages.galleries.my_galleries.successfully_created":"Gallery has been successfully created","pages.galleries.my_galleries.successfully_updated":"Gallery has been successfully updated","pages.galleries.my_galleries.new_gallery_no_nfts":"Creating a Gallery requires you to own an NFT.","pages.galleries.copy_gallery_link":"Copy Gallery Link","pages.galleries.my_nfts":"My NFTs","pages.galleries.value":"Value","pages.galleries.floor_price":"Floor Price","pages.galleries.nfts":"NFTs","pages.galleries.collections":"Collections","pages.galleries.galleries_count_simple":"{0} galleries|{1} gallery|[2,*] galleries","pages.galleries.galleries_count":"{0} {{count}} Galleries|{1} {{count}} Gallery|[2,*] {{count}} Galleries","pages.galleries.collections_count_simple":"{0} collections|{1} collection|[2,*] collections","pages.galleries.collections_count":"{0} {{count}} Collections|{1} {{count}} Collection|[2,*] {{count}} Collections","pages.galleries.nfts_count_simple":"{0} NFTs|{1} NFT|[2,*] NFTs","pages.galleries.nfts_count":"{0} {{count}} NFTs|{1} {{count}} NFT|[2,*] {{count}} NFTs","pages.galleries.users_count_simple":"{0} users|{1} user|[2,*] users","pages.galleries.users_count":"{0} {{count}} Users|{1} {{count}} User|[2,*] {{count}} Users","pages.galleries.featuring":"Featuring","pages.galleries.curated_by":"Curated by","pages.galleries.worth_about":"Worth About","pages.galleries.valued_at":"valued at","pages.galleries.from":"From","pages.galleries.most_popular_galleries":"Most Popular Galleries","pages.galleries.newest_galleries":"Newest Galleries","pages.galleries.most_valuable_galleries":"Most Valuable Galleries","pages.galleries.most_popular":"Most Popular","pages.galleries.newest":"Newest","pages.galleries.most_valuable":"Most Valuable","pages.galleries.create.search_by_nfts":"Search by NFTs","pages.galleries.create.input_placeholder":"Enter gallery name","pages.galleries.create.title_too_long":"Gallery name must not exceed {{max}} characters.","pages.galleries.create.already_selected_nft":"NFT already exists in this gallery","pages.galleries.create.gallery_cover":"Gallery Cover","pages.galleries.create.gallery_cover_description":"The cover is used for the card on the gallery list page. While the cover is not a requirement it will allow you to add personality and stand out from the crowd.","pages.galleries.create.gallery_cover_information":"Image dimensions must be at least 287px x 190px, with a max size of 2 MB (JPG, PNG or GIF)","pages.galleries.create.no_results":"We could not find anything matching your search criteria, please try again!","pages.galleries.create.templates.cover":"Cover","pages.galleries.create.templates.template":"Template","pages.galleries.create.templates.select":"Select Gallery Template","pages.galleries.create.templates.basic":"Basic Gallery","pages.galleries.create.templates.coming_soon":"More Coming Soon","pages.galleries.create.load_more_collections_one":"Load {{count}} More Collection","pages.galleries.create.load_more_collections_other":"Load {{count}} More Collections","pages.galleries.create.load_more_nfts":"Load More NFTs","pages.galleries.create.can_purchase":"You can purchase NFTs with these top NFT Marketplaces:","pages.galleries.create.must_own_one_nft":"You must own at least one (1) NFT in order to create a gallery.","pages.galleries.create.back_to_galleries":"Back to Galleries","pages.galleries.delete_modal.title":"Delete Gallery","pages.galleries.delete_modal.confirmation_text":"Are you sure you want to delete the gallery? Everything you've done will be deleted and you won't be able to get it back.","pages.galleries.consists_of_collections":"{0} This gallery consists of {{count}} collections|{1} This gallery consists of {{count}} collection|[2,*] This gallery consists of {{count}} collections","pages.galleries.guest_banner.title":"Craft the ultimate","pages.galleries.guest_banner.subtitle":"Pick your favorites, curate your gallery, & share it with the world.","pages.profile.title":"Profile","pages.token_panel.balance_tooltip":"Total percentage of the portfolio held in this token","pages.token_panel.insufficient_funds":"Insufficient Balance","pages.token_panel.error":"Dashbrd has failed to load token information. Please try again later.","pages.token_panel.failed_to_retrieve_transactions":"We were unable to fetch your transactions.","pages.token_panel.tabs.transaction_history":"Transaction History","pages.token_panel.tabs.history":"History","pages.token_panel.tabs.market_data":"Market Data","pages.token_panel.details.current_price":"Current Price","pages.token_panel.details.title":"Token Details","pages.token_panel.details.market_cap":"Market Cap","pages.token_panel.details.volume":"Daily Volume","pages.token_panel.details.supply":"Minted Supply","pages.token_panel.details.ath":"All-Time High","pages.token_panel.details.atl":"All-Time Low","pages.token_panel.chart.failed":"Dashbrd has failed to load chart information. Please try again later.","pages.transaction_details_panel.title":"Transaction Details","pages.transaction_details_panel.details.blockchain":"Blockchain","pages.transaction_details_panel.details.timestamp":"Timestamp","pages.transaction_details_panel.details.transaction_hash":"Transaction Hash","pages.transaction_details_panel.details.transaction_fee":"Transaction Fee","pages.transaction_details_panel.details.gas_price":"Gas Price","pages.transaction_details_panel.details.gas_used":"Gas Used","pages.transaction_details_panel.details.nonce":"Nonce","pages.send_receive_panel.send.labels.token_and_amount":"Token and Amount","pages.send_receive_panel.send.labels.destination_address":"Destination Address","pages.send_receive_panel.send.labels.projected_fee":"Projected Fee","pages.send_receive_panel.send.placeholders.enter_amount":"Enter Amount","pages.send_receive_panel.send.placeholders.insert_recipient_address":"Insert Recipient Address","pages.send_receive_panel.send.placeholders.projected_fee":"Projected Fee","pages.send_receive_panel.send.errors.amount":"Insufficient Funds: You do not have enough to cover the amount + fee.","pages.send_receive_panel.send.errors.destination":"Destination address is not correct. Check and input again.","pages.send_receive_panel.send.hints.token_price":"Token Price","pages.send_receive_panel.send.fees.Fast":"Fast","pages.send_receive_panel.send.fees.Avg":"Avg","pages.send_receive_panel.send.fees.Slow":"Slow","pages.send_receive_panel.send.search_dropdown.placeholder":"Search token","pages.send_receive_panel.send.search_dropdown.no_results":"No results","pages.send_receive_panel.send.search_dropdown.error":"Error occurred while searching tokens.","pages.send_receive_panel.send.transaction_time":"Transaction Time: ~{{ time }} minutes","pages.send_receive_panel.send.from":"From","pages.send_receive_panel.send.to":"To","pages.send_receive_panel.send.amount":"Amount","pages.send_receive_panel.send.fee":"Fee","pages.send_receive_panel.send.total_amount":"Total Amount","pages.send_receive_panel.send.waiting_message":"Review and verify the information on your MetaMask. Sign to send the transaction.","pages.send_receive_panel.send.waiting_spinner_text":"Waiting for confirmation...","pages.send_receive_panel.send.failed_message":"It looks like something went wrong while sending your transaction. Press 'Retry' to make another attempt.","pages.send_receive_panel.receive.alert":"Send only Polygon or Ethereum Network compatible tokens to this address or you could permanently lose your funds!","pages.settings.title":"Settings","pages.settings.sidebar.general":"General","pages.settings.sidebar.notifications":"Notifications","pages.settings.sidebar.session_history":"Sessions History","pages.settings.general.title":"Settings","pages.settings.general.subtitle":"Customize your App Experience","pages.settings.general.currency":"Currency","pages.settings.general.currency_subtitle":"Select your default currency which will be used throughout the app.","pages.settings.general.time_date":"Time & Date","pages.settings.general.time_date_subtitle":"Select how you want time and date be shown inside app.","pages.settings.general.date_format":"Date Format","pages.settings.general.time_format":"Time Format","pages.settings.general.timezone":"Timezone","pages.settings.general.set_defaults":"Set Defaults","pages.settings.general.set_defaults_content":"Reverting to the default settings will remove any customizations previously made. Are you sure?","pages.settings.general.save":"Save Settings","pages.settings.general.saved":"Your settings have been successfully saved","pages.wallet.title":"Wallet","pages.privacy_policy.title":"Privacy Policy","pages.terms_of_service.title":"Terms of Service","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset!","passwords.sent":"We have emailed your password reset link!","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","urls.landing":"https://dashbrd.com","urls.cookie_policy":"https://dashbrd.com/cookie-policy","urls.privacy_policy":"https://dashbrd.com/privacy-policy","urls.terms_of_service":"https://dashbrd.com/terms-of-service","urls.twitter":"https://x.com/DashbrdApp","urls.discord":"https://discord.gg/MJyWKkCJ5k","urls.github":"https://github.com/ArdentHQ/dashbrd","urls.coingecko":"https://www.coingecko.com","urls.etherscan":"https://etherscan.io","urls.polygonscan":"https://polygonscan.com","urls.alchemy":"https://www.alchemy.com","urls.moralis":"https://moralis.io","urls.mnemonic":"https://www.mnemonichq.com","urls.opensea":"https://opensea.io/","urls.explorers.etherscan.token_transactions":"https://etherscan.io/token/{{token}}?a={{address}}","urls.explorers.etherscan.addresses":"https://etherscan.io/address/{{address}}","urls.explorers.etherscan.transactions":"https://etherscan.io/tx/{{id}}","urls.explorers.etherscan.nft":"https://etherscan.io/nft/{{address}}/{{nftId}}","urls.explorers.polygonscan.token_transactions":"https://polygonscan.com/token/{{token}}?a={{address}}","urls.explorers.polygonscan.addresses":"https://polygonscan.com/address/{{address}}","urls.explorers.polygonscan.transactions":"https://polygonscan.com/tx/{{id}}","urls.explorers.polygonscan.nft":"https://polygonscan.com/nft/{{address}}/{{nftId}}","urls.explorers.mumbai.token_transactions":"https://mumbai.polygonscan.com/token/{{token}}?a={{address}}","urls.explorers.mumbai.addresses":"https://mumbai.polygonscan.com/address/{{address}}","urls.explorers.mumbai.transactions":"https://mumbai.polygonscan.com/tx/{{id}}","urls.explorers.mumbai.nft":"https://mumbai.polygonscan.com/nft/{{address}}/{{nftId}}","urls.explorers.goerli.token_transactions":"https://goerli.etherscan.io/token/{{token}}?a={{address}}","urls.explorers.goerli.addresses":"https://goerli.etherscan.io/address/{{address}}","urls.explorers.goerli.transactions":"https://goerli.etherscan.io/tx/{{id}}","urls.explorers.goerli.nft":"https://goerli.etherscan.io/nft/{{address}}/{{nftId}}","urls.marketplaces.opensea.collection":"https://opensea.io/assets/{{network}}/{{address}}","urls.marketplaces.opensea.nft":"https://opensea.io/assets/{{network}}/{{address}}/{{nftId}}","urls.marketplaces.rarible.collection":"https://rarible.com/collection/{{address}}/items","urls.marketplaces.rarible.nft":"https://rarible.com/token/{{address}}:{{nftId}}","urls.marketplaces.blur.collection":"https://blur.io/collection/{{address}}","urls.marketplaces.blur.nft":"https://blur.io/asset/{{address}}/{{nftId}}","urls.marketplaces.looksrare.collection":"https://looksrare.org/collections/{{address}}","urls.marketplaces.looksrare.nft":"https://looksrare.org/collections/{{address}}/{{nftId}}","validation.accepted":"The {{attribute}} must be accepted.","validation.accepted_if":"The {{attribute}} must be accepted when {{other}} is {{value}}.","validation.active_url":"The {{attribute}} is not a valid URL.","validation.after":"The {{attribute}} must be a date after {{date}}.","validation.after_or_equal":"The {{attribute}} must be a date after or equal to {{date}}.","validation.alpha":"The {{attribute}} must only contain letters.","validation.alpha_dash":"The {{attribute}} must only contain letters, numbers, dashes and underscores.","validation.alpha_num":"The {{attribute}} must only contain letters and numbers.","validation.array":"The {{attribute}} must be an array.","validation.ascii":"The {{attribute}} must only contain single-byte alphanumeric characters and symbols.","validation.before":"The {{attribute}} must be a date before {{date}}.","validation.before_or_equal":"The {{attribute}} must be a date before or equal to {{date}}.","validation.between.array":"The {{attribute}} must have between {{min}} and {{max}} items.","validation.between.file":"The {{attribute}} must be between {{min}} and {{max}} kilobytes.","validation.between.numeric":"The {{attribute}} must be between {{min}} and {{max}}.","validation.between.string":"The {{attribute}} must be between {{min}} and {{max}} characters.","validation.boolean":"The {{attribute}} field must be true or false.","validation.confirmed":"The {{attribute}} confirmation does not match.","validation.current_password":"The password is incorrect.","validation.date":"The {{attribute}} is not a valid date.","validation.date_equals":"The {{attribute}} must be a date equal to {{date}}.","validation.date_format":"The {{attribute}} does not match the format {{format}}.","validation.decimal":"The {{attribute}} must have {{decimal}} decimal places.","validation.declined":"The {{attribute}} must be declined.","validation.declined_if":"The {{attribute}} must be declined when {{other}} is {{value}}.","validation.different":"The {{attribute}} and {{other}} must be different.","validation.digits":"The {{attribute}} must be {{digits}} digits.","validation.digits_between":"The {{attribute}} must be between {{min}} and {{max}} digits.","validation.dimensions":"The {{attribute}} has invalid image dimensions.","validation.distinct":"The {{attribute}} field has a duplicate value.","validation.doesnt_end_with":"The {{attribute}} may not end with one of the following: {{values}}.","validation.doesnt_start_with":"The {{attribute}} may not start with one of the following: {{values}}.","validation.email":"The {{attribute}} must be a valid email address.","validation.ends_with":"The {{attribute}} must end with one of the following: {{values}}.","validation.enum":"The selected {{attribute}} is invalid.","validation.exists":"The selected {{attribute}} is invalid.","validation.file":"The {{attribute}} must be a file.","validation.filled":"The {{attribute}} field must have a value.","validation.gt.array":"The {{attribute}} must have more than {{value}} items.","validation.gt.file":"The {{attribute}} must be greater than {{value}} kilobytes.","validation.gt.numeric":"The {{attribute}} must be greater than {{value}}.","validation.gt.string":"The {{attribute}} must be greater than {{value}} characters.","validation.gte.array":"The {{attribute}} must have {{value}} items or more.","validation.gte.file":"The {{attribute}} must be greater than or equal to {{value}} kilobytes.","validation.gte.numeric":"The {{attribute}} must be greater than or equal to {{value}}.","validation.gte.string":"The {{attribute}} must be greater than or equal to {{value}} characters.","validation.image":"The {{attribute}} must be an image.","validation.in":"The selected {{attribute}} is invalid.","validation.in_array":"The {{attribute}} field does not exist in {{other}}.","validation.integer":"The {{attribute}} must be an integer.","validation.ip":"The {{attribute}} must be a valid IP address.","validation.ipv4":"The {{attribute}} must be a valid IPv4 address.","validation.ipv6":"The {{attribute}} must be a valid IPv6 address.","validation.json":"The {{attribute}} must be a valid JSON string.","validation.lowercase":"The {{attribute}} must be lowercase.","validation.lt.array":"The {{attribute}} must have less than {{value}} items.","validation.lt.file":"The {{attribute}} must be less than {{value}} kilobytes.","validation.lt.numeric":"The {{attribute}} must be less than {{value}}.","validation.lt.string":"The {{attribute}} must be less than {{value}} characters.","validation.lte.array":"The {{attribute}} must not have more than {{value}} items.","validation.lte.file":"The {{attribute}} must be less than or equal to {{value}} kilobytes.","validation.lte.numeric":"The {{attribute}} must be less than or equal to {{value}}.","validation.lte.string":"The {{attribute}} must be less than or equal to {{value}} characters.","validation.mac_address":"The {{attribute}} must be a valid MAC address.","validation.max.array":"The {{attribute}} must not have more than {{max}} items.","validation.max.file":"The {{attribute}} must not be greater than {{max}} kilobytes.","validation.max.numeric":"The {{attribute}} must not be greater than {{max}}.","validation.max.string":"The {{attribute}} must not be greater than {{max}} characters.","validation.max_digits":"The {{attribute}} must not have more than {{max}} digits.","validation.mimes":"The {{attribute}} must be a file of type: {{values}}.","validation.mimetypes":"The {{attribute}} must be a file of type: {{values}}.","validation.min.array":"The {{attribute}} must have at least {{min}} items.","validation.min.file":"The {{attribute}} must be at least {{min}} kilobytes.","validation.min.numeric":"The {{attribute}} must be at least {{min}}.","validation.min.string":"The {{attribute}} must be at least {{min}} characters.","validation.min_digits":"The {{attribute}} must have at least {{min}} digits.","validation.missing":"The {{attribute}} field must be missing.","validation.missing_if":"The {{attribute}} field must be missing when {{other}} is {{value}}.","validation.missing_unless":"The {{attribute}} field must be missing unless {{other}} is {{value}}.","validation.missing_with":"The {{attribute}} field must be missing when {{values}} is present.","validation.missing_with_all":"The {{attribute}} field must be missing when {{values}} are present.","validation.multiple_of":"The {{attribute}} must be a multiple of {{value}}.","validation.not_in":"The selected {{attribute}} is invalid.","validation.not_regex":"The {{attribute}} format is invalid.","validation.numeric":"The {{attribute}} must be a number.","validation.password.letters":"The {{attribute}} must contain at least one letter.","validation.password.mixed":"The {{attribute}} must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The {{attribute}} must contain at least one number.","validation.password.symbols":"The {{attribute}} must contain at least one symbol.","validation.password.uncompromised":"The given {{attribute}} has appeared in a data leak. Please choose a different {{attribute}}.","validation.present":"The {{attribute}} field must be present.","validation.prohibited":"The {{attribute}} field is prohibited.","validation.prohibited_if":"The {{attribute}} field is prohibited when {{other}} is {{value}}.","validation.prohibited_unless":"The {{attribute}} field is prohibited unless {{other}} is in {{values}}.","validation.prohibits":"The {{attribute}} field prohibits {{other}} from being present.","validation.regex":"The {{attribute}} format is invalid.","validation.required":"The {{attribute}} field is required.","validation.required_array_keys":"The {{attribute}} field must contain entries for: {{values}}.","validation.required_if":"The {{attribute}} field is required when {{other}} is {{value}}.","validation.required_if_accepted":"The {{attribute}} field is required when {{other}} is accepted.","validation.required_unless":"The {{attribute}} field is required unless {{other}} is in {{values}}.","validation.required_with":"The {{attribute}} field is required when {{values}} is present.","validation.required_with_all":"The {{attribute}} field is required when {{values}} are present.","validation.required_without":"The {{attribute}} field is required when {{values}} is not present.","validation.required_without_all":"The {{attribute}} field is required when none of {{values}} are present.","validation.same":"The {{attribute}} and {{other}} must match.","validation.size.array":"The {{attribute}} must contain {{size}} items.","validation.size.file":"The {{attribute}} must be {{size}} kilobytes.","validation.size.numeric":"The {{attribute}} must be {{size}}.","validation.size.string":"The {{attribute}} must be {{size}} characters.","validation.starts_with":"The {{attribute}} must start with one of the following: {{values}}.","validation.string":"The {{attribute}} must be a string.","validation.timezone":"The {{attribute}} must be a valid timezone.","validation.unique":"The {{attribute}} has already been taken.","validation.uploaded":"The {{attribute}} failed to upload.","validation.uppercase":"The {{attribute}} must be uppercase.","validation.url":"The {{attribute}} must be a valid URL.","validation.ulid":"The {{attribute}} must be a valid ULID.","validation.uuid":"The {{attribute}} must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","validation.unsupported_currency_code":"The currency code you provided is invalid or not supported.","validation.unsupported_period":"The period you provided is invalid or not supported.","validation.unsupported_token_symbol":"The token symbol you provided is invalid or not supported.","validation.gallery_title_required":"Gallery name is required.","validation.gallery_title_max_characters":"The gallery name should not exceed 50 characters.","validation.gallery_title_invalid":"The gallery name is invalid.","validation.nfts_required":"Please add at least one NFT.","validation.nfts_max_size":"Galleries can contain no more than {{limit}} NFTs","validation.invalid_nfts":"The NFT in position {{position}} is invalid, please select another one.","validation.invalid_cover":"You have selected an invalid cover image, please try another one."} \ No newline at end of file diff --git a/resources/js/Pages/Collections/View.tsx b/resources/js/Pages/Collections/View.tsx index 7e1c4fd08..26c6fad68 100644 --- a/resources/js/Pages/Collections/View.tsx +++ b/resources/js/Pages/Collections/View.tsx @@ -40,6 +40,7 @@ interface Properties { nftPageLimit: number; }; initialActivities: App.Data.Nfts.NftActivitiesData | null; + hasActivities: boolean; sortByMintDate?: boolean; nativeToken: App.Data.Token.TokenData; showReportModal: boolean; @@ -57,6 +58,7 @@ const CollectionsView = ({ reportAvailableIn, collectionTraits, appliedFilters, + hasActivities, sortByMintDate = false, nativeToken, showReportModal, @@ -228,6 +230,28 @@ const CollectionsView = ({ return {t("pages.collections.search.no_results_ownership")}; }; + const renderActivities = (): JSX.Element => { + if (!hasActivities) { + return {t("pages.collections.activities.ignores_activities")}; + } + + if (!loading && (activities === null || activities.paginated.data.length === 0)) { + return {t("pages.collections.activities.no_activity")}; + } + + return ( + + ); + }; + return ( @@ -318,21 +342,7 @@ const CollectionsView = ({ -
- {!loading && (!isTruthy(activities) || activities.paginated.data.length === 0) ? ( - {t("pages.collections.activities.no_activity")} - ) : ( - - )} -
+
{renderActivities()}
diff --git a/tests/App/Models/CollectionTest.php b/tests/App/Models/CollectionTest.php index 3cad483b3..181ebf4a8 100644 --- a/tests/App/Models/CollectionTest.php +++ b/tests/App/Models/CollectionTest.php @@ -1219,3 +1219,29 @@ $collection4->id, ]); }); + +it('can determine whether collection has its activities indexed', function () { + config([ + 'dashbrd.activity_blacklist' => [ + '0x123', + ], + ]); + + expect(Collection::factory()->create([ + 'address' => '0x123', + ])->indexesActivities())->toBeFalse(); + + expect(Collection::factory()->create([ + 'address' => '0x1234', + ])->indexesActivities())->toBeTrue(); + + expect(Collection::factory()->create([ + 'address' => '0x12345', + 'supply' => null, + ])->indexesActivities())->toBeFalse(); + + expect(Collection::factory()->create([ + 'address' => '0x123456', + 'supply' => 100000, + ])->indexesActivities())->toBeFalse(); +}); From f2e3d10d192c3165dac267850a1fedbd949d6b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Crnkovi=C4=87?= Date: Tue, 17 Oct 2023 14:30:31 +0200 Subject: [PATCH 08/14] fix: add spacing for activity on mobile (#242) --- .../CollectionActivityTable.blocks.tsx | 4 ++-- .../CollectionActivityTable/CollectionActivityTable.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx index 1d7ebafdb..eaee778a1 100644 --- a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx +++ b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx @@ -384,7 +384,7 @@ export const CollectionActivityTableItemSkeleton = ({ if (isCompact) { return ( -
+
{showNameColumn && (
+
{showNameColumn && (
diff --git a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.tsx b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.tsx index 02736a57c..d3fe62413 100644 --- a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.tsx +++ b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.tsx @@ -178,7 +178,7 @@ export const CollectionActivityTable = ({ : (children: React.ReactNode) => (
{children} From 93c0a02a2cdca0f093b1fc505d0b3ed7ff38f9eb Mon Sep 17 00:00:00 2001 From: Patricio Marroquin <55117912+patricio0312rev@users.noreply.github.com> Date: Thu, 12 Oct 2023 03:13:08 -0500 Subject: [PATCH 09/14] fix: make collections grid view respect list view sorting (#203) --- .../CollectionsGrid/CollectionsGrid.test.tsx | 80 +++++++++++++++---- .../CollectionsGrid/CollectionsGrid.tsx | 7 +- .../CollectionsTable/CollectionsTable.tsx | 1 + 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.test.tsx b/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.test.tsx index 378508cfe..fd2ee8684 100644 --- a/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.test.tsx +++ b/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.test.tsx @@ -40,7 +40,7 @@ describe("CollectionsGrid", () => { expect(getByTestId("CollectionsGridSkeleton")).toBeInTheDocument(); }); - it("sorts using value descending by default", () => { + it("should display collections in the order they were provided", () => { mockViewportVisibilitySensor({ inViewport: true, }); @@ -71,7 +71,7 @@ describe("CollectionsGrid", () => { ), ]; - const { getByTestId, getAllByTestId } = render( + const defaultGrid = render( { />, ); - expect(getByTestId("CollectionsGrid")).toBeInTheDocument(); + expect(defaultGrid.getByTestId("CollectionsGrid")).toBeInTheDocument(); - expect(getAllByTestId("CollectionCard")).toHaveLength(collections.length); - expect(getAllByTestId("CollectionFloorPrice")).toHaveLength(collections.length - 4); // offset by 4 because of nulls... + expect(defaultGrid.getAllByTestId("CollectionCard")).toHaveLength(collections.length); + expect(defaultGrid.getAllByTestId("CollectionFloorPrice")).toHaveLength(collections.length - 4); // offset by 4 because of nulls... - const values = getAllByTestId("CollectionFloorPrice"); + const values = defaultGrid.getAllByTestId("CollectionFloorPrice"); const sorted: string[] = [ - "0.7 ETH", + "0.1 ETH", + "0.2 ETH", "0.4 ETH", - "700 USDC", + "0.7 ETH", "0.3 ETH", + "0.1 ETH", + "0.05 ETH", + "100 USDC", + "200 USDC", "400 USDC", - "0.2 ETH", + "700 USDC", "300 USDC", - "200 USDC", - "1 ETH", - "1 ETH", - "100 USDC", "100 USDC", - "0.05 ETH", "50 USDC", ]; for (let index = 0; index < sorted.length; index++) { expect(values[index]).toHaveTextContent(sorted[index]); } + + // Unmount previous grid + defaultGrid.unmount(); + + // Sort by value ascending, if null then first + const collectionsSortedByFloorPrice = collections.sort((a, b) => { + if (a.floorPriceFiat === null) { + return -1; + } + + if (b.floorPriceFiat === null) { + return 1; + } + + return a.floorPriceFiat - b.floorPriceFiat; + }); + + const sortedGrid = render( + , + ); + + expect(sortedGrid.getAllByTestId("CollectionCard")).toHaveLength(collectionsSortedByFloorPrice.length); + + const sortedValues = sortedGrid.getAllByTestId("CollectionFloorPrice"); + + const expectedSortedValues = [ + "50 USDC", + "0.05 ETH", + "100 USDC", + "100 USDC", + "0.1 ETH", + "0.1 ETH", + "200 USDC", + "300 USDC", + "0.2 ETH", + "400 USDC", + "0.3 ETH", + "700 USDC", + "0.4 ETH", + "0.7 ETH", + ]; + + for (let index = 0; index < sorted.length; index++) { + expect(sortedValues[index]).toHaveTextContent(expectedSortedValues[index]); + } }); }); diff --git a/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.tsx b/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.tsx index 5c72d0c3c..13ba0cd12 100644 --- a/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.tsx +++ b/resources/js/Components/Collections/CollectionsGrid/CollectionsGrid.tsx @@ -1,4 +1,3 @@ -import { BigNumber, sortByDesc } from "@ardenthq/sdk-helpers"; import { CollectionCard } from "@/Components/Collections/CollectionCard"; import { CollectionCardSkeleton } from "@/Components/Collections/CollectionCard/CollectionCardSkeleton"; @@ -47,11 +46,7 @@ export const CollectionsGrid = ({ data-testid="CollectionsGrid" className="grid grid-cols-1 gap-3 sm:grid-cols-2 md-lg:grid-cols-3 xl:grid-cols-4" > - {sortByDesc(collections, (collection) => - BigNumber.make(collection.floorPriceFiat ?? 0) - .times(collection.nftsCount) - .toNumber(), - ).map((collection, index) => ( + {collections.map((collection, index) => ( 0 ? initialState : {}} activeSort={activeSort} sortDirection={sortDirection} + manualSortBy={true} onSort={ onSort != null ? (column) => { From 50dadde3b6786a07cb7479f18e6426a5c5dace98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Crnkovi=C4=87?= Date: Tue, 17 Oct 2023 15:55:59 +0200 Subject: [PATCH 10/14] fix: do not index activity for prior indexed collections when running FetchWalletNfts (#243) --- app/Support/Web3NftHandler.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/Support/Web3NftHandler.php b/app/Support/Web3NftHandler.php index 4ce6d5d71..6d1391b01 100644 --- a/app/Support/Web3NftHandler.php +++ b/app/Support/Web3NftHandler.php @@ -168,9 +168,14 @@ public function store(Collection $nfts, bool $dispatchJobs = false): void }); if (Feature::active(Features::Collections->value)) { - CollectionModel::where('is_fetching_activity', false)->whereIn('id', $ids)->chunkById(100, function ($collections) { - $collections->each(fn ($collection) => FetchCollectionActivity::dispatch($collection)->onQueue(Queues::NFTS)); - }); + // Index activity only for newly created collections... + CollectionModel::query() + ->where('is_fetching_activity', false) + ->whereNull('activity_updated_at') + ->whereIn('id', $ids) + ->chunkById(100, function ($collections) { + $collections->each(fn ($collection) => FetchCollectionActivity::dispatch($collection)->onQueue(Queues::NFTS)); + }); $nftsGroupedByCollectionAddress->filter(fn (Web3NftData $nft) => $nft->mintedAt === null)->each(function (Web3NftData $nft) { DetermineCollectionMintingDate::dispatch($nft)->onQueue(Queues::NFTS); From 6dc4cd2bc757931939e328a0b48dd80d897134d2 Mon Sep 17 00:00:00 2001 From: ItsANameToo <35610748+ItsANameToo@users.noreply.github.com> Date: Tue, 17 Oct 2023 16:27:01 +0200 Subject: [PATCH 11/14] fix: activity table nft name size on mobile (#244) --- .../CollectionActivityTable/CollectionActivityTable.blocks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx index eaee778a1..21e9355bb 100644 --- a/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx +++ b/resources/js/Components/Collections/CollectionActivityTable/CollectionActivityTable.blocks.tsx @@ -92,7 +92,7 @@ export const Name = ({ />
- + {activity.nft.name}
From f0165a1c20e3228acadd9e29719e0a017bd51918 Mon Sep 17 00:00:00 2001 From: ItsANameToo <35610748+ItsANameToo@users.noreply.github.com> Date: Wed, 18 Oct 2023 14:25:15 +0200 Subject: [PATCH 12/14] fix: handle unexpected trait formats better (#248) --- .../Client/Alchemy/AlchemyPendingRequest.php | 2 +- .../Alchemy/AlchemyPendingRequestTest.php | 28 + .../nfts_array_collection_array_traits.json | 817 ++++++++++++++++++ .../alchemy/nfts_array_unexpected_traits.json | 77 ++ 4 files changed, 923 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/alchemy/nfts_array_collection_array_traits.json create mode 100644 tests/fixtures/alchemy/nfts_array_unexpected_traits.json diff --git a/app/Http/Client/Alchemy/AlchemyPendingRequest.php b/app/Http/Client/Alchemy/AlchemyPendingRequest.php index 495e02f60..d3aa9d74c 100644 --- a/app/Http/Client/Alchemy/AlchemyPendingRequest.php +++ b/app/Http/Client/Alchemy/AlchemyPendingRequest.php @@ -621,7 +621,7 @@ private function extractTraits(array $nft): array return collect($props) ->filter(function ($item) { - return ! empty(Arr::get($item, 'trait_type')) && ! empty(Arr::get($item, 'value')); + return ! empty(Arr::get($item, 'trait_type')) && ! empty(Arr::get($item, 'value')) && ! is_array(Arr::get($item, 'value')); }) ->map(function ($item) { $value = strval($item['value']); diff --git a/tests/App/Http/Client/Alchemy/AlchemyPendingRequestTest.php b/tests/App/Http/Client/Alchemy/AlchemyPendingRequestTest.php index 76a8d15fe..a25ce9847 100644 --- a/tests/App/Http/Client/Alchemy/AlchemyPendingRequestTest.php +++ b/tests/App/Http/Client/Alchemy/AlchemyPendingRequestTest.php @@ -69,3 +69,31 @@ expect($collection->nfts[0]->collectionBannerImageUrl)->toContain('w=1378'); }); + +it('should ignore arrays in trait values', function () { + Alchemy::fake([ + 'https://polygon-mainnet.g.alchemy.com/nft/v2/*' => Http::sequence() + ->push(fixtureData('alchemy.nfts_array_collection_array_traits'), 200), + ]); + + $wallet = Wallet::factory()->create(); + $network = Network::polygon(); + + $collection = Alchemy::getWalletNfts($wallet, $network); + + expect($collection->nfts[0]->traits[1])->toContain('Tails'); +}); + +it('should handle unexpected trait values', function () { + Alchemy::fake([ + 'https://polygon-mainnet.g.alchemy.com/nft/v2/*' => Http::sequence() + ->push(fixtureData('alchemy.nfts_array_unexpected_traits'), 200), + ]); + + $wallet = Wallet::factory()->create(); + $network = Network::polygon(); + + $collection = Alchemy::getWalletNfts($wallet, $network); + + expect(count($collection->nfts[0]->traits))->toBe(1); +}); diff --git a/tests/fixtures/alchemy/nfts_array_collection_array_traits.json b/tests/fixtures/alchemy/nfts_array_collection_array_traits.json new file mode 100644 index 000000000..1af765611 --- /dev/null +++ b/tests/fixtures/alchemy/nfts_array_collection_array_traits.json @@ -0,0 +1,817 @@ +{ + "ownedNfts": [ + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003bf", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/959.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/959.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/4c04cc8ca145ee9f48a137b437c899c4", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/4c04cc8ca145ee9f48a137b437c899c4", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/959.png", + "format": "png", + "bytes": 2567 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/959.png", + "attributes": [ + { + "value": { + "color": "Yellow" + }, + "trait_type": "Color" + }, + { + "value": "Rocket Tail", + "trait_type": "Tails" + }, + { + "value": "Sphere", + "trait_type": "Bodies" + }, + { + "value": "Squid Legs", + "trait_type": "Legs" + }, + { + "value": "Cyclops Eye", + "trait_type": "Eyes" + }, + { + "value": "Srsly Face", + "trait_type": "Mouths" + }, + { + "value": 48, + "trait_type": "Intelligence" + }, + { + "value": 19, + "trait_type": "Strength" + }, + { + "value": 4, + "trait_type": "Luck" + } + ], + "id": 959 + }, + "timeLastUpdated": "2023-09-19T18:22:40.335Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c0", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://alchemy.mypinata.cloud/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/960.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/960.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/72c5618d25a0d29930ea2d6c4bb02e99", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/72c5618d25a0d29930ea2d6c4bb02e99", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/960.png", + "format": "png", + "bytes": 2893 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/960.png", + "attributes": [ + { + "value": { + "color": "Stone" + }, + "trait_type": "Color" + }, + { + "value": "Dino Tail", + "trait_type": "Tails" + }, + { + "value": "Cube", + "trait_type": "Bodies" + }, + { + "value": "Raptor Legs", + "trait_type": "Legs" + }, + { + "value": "Bug Eyes", + "trait_type": "Eyes" + }, + { + "value": "Srsly Face", + "trait_type": "Mouths" + }, + { + "value": 24, + "trait_type": "Intelligence" + }, + { + "value": 58, + "trait_type": "Strength" + }, + { + "value": 52, + "trait_type": "Luck" + } + ], + "id": 960 + }, + "timeLastUpdated": "2023-10-12T18:22:15.026Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c1", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/961.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/961.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/583b8b9fef4894736120a8e431d29be3", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/583b8b9fef4894736120a8e431d29be3", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/961.png", + "format": "png", + "bytes": 3804 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/961.png", + "attributes": [ + { + "value": { + "color": "Eggshell" + }, + "trait_type": "Color" + }, + { + "value": "Turkey Tail", + "trait_type": "Tails" + }, + { + "value": "Cube", + "trait_type": "Bodies" + }, + { + "value": "Abduction Beam", + "trait_type": "Legs" + }, + { + "value": "Kawaii Eyes", + "trait_type": "Eyes" + }, + { + "value": "Oh Face", + "trait_type": "Mouths" + }, + { + "value": 87, + "trait_type": "Intelligence" + }, + { + "value": 62, + "trait_type": "Strength" + }, + { + "value": 90, + "trait_type": "Luck" + } + ], + "id": 961 + }, + "timeLastUpdated": "2023-09-19T12:02:40.643Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c2", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/962.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/962.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/967d2a09ad72ac2fdaeeeb5e0d7527b7", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/967d2a09ad72ac2fdaeeeb5e0d7527b7", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/962.png", + "format": "png", + "bytes": 2510 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/962.png", + "attributes": [ + { + "value": { + "color": "Cherry" + }, + "trait_type": "Color" + }, + { + "value": "Fae Tail", + "trait_type": "Tails" + }, + { + "value": "Sphere", + "trait_type": "Bodies" + }, + { + "value": "Fish Fins", + "trait_type": "Legs" + }, + { + "value": "X-", + "trait_type": "Eyes" + }, + { + "value": "Baby Face", + "trait_type": "Mouths" + }, + { + "value": 49, + "trait_type": "Intelligence" + }, + { + "value": 72, + "trait_type": "Strength" + }, + { + "value": 8, + "trait_type": "Luck" + } + ], + "id": 962 + }, + "timeLastUpdated": "2023-09-29T05:32:39.991Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c3", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/963.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/963.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/71e2a68e9e3187d1f00d56ec28c21f3d", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/71e2a68e9e3187d1f00d56ec28c21f3d", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/963.png", + "format": "png", + "bytes": 2332 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/963.png", + "attributes": [ + { + "value": { + "color": "Marshmallow" + }, + "trait_type": "Color" + }, + { + "value": "Fae Tail", + "trait_type": "Tails" + }, + { + "value": "Pyramid", + "trait_type": "Bodies" + }, + { + "value": "Space Man_Legs", + "trait_type": "Legs" + }, + { + "value": "Dino Head", + "trait_type": "Mouths" + }, + { + "value": 25, + "trait_type": "Intelligence" + }, + { + "value": 59, + "trait_type": "Strength" + }, + { + "value": 44, + "trait_type": "Luck" + } + ], + "id": 963 + }, + "timeLastUpdated": "2023-09-18T22:46:40.856Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c4", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/964.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/964.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/d366f91416977e83772afcb259a9568b", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/d366f91416977e83772afcb259a9568b", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/964.png", + "format": "png", + "bytes": 2542 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/964.png", + "attributes": [ + { + "value": { + "color": "Lemon" + }, + "trait_type": "Color" + }, + { + "value": "Fox Tail", + "trait_type": "Tails" + }, + { + "value": "Mega Cube", + "trait_type": "Bodies" + }, + { + "value": "Dino Feet", + "trait_type": "Legs" + }, + { + "value": "X-", + "trait_type": "Eyes" + }, + { + "value": "Antena", + "trait_type": "Mouths" + }, + { + "value": 31, + "trait_type": "Intelligence" + }, + { + "value": 21, + "trait_type": "Strength" + }, + { + "value": 20, + "trait_type": "Luck" + } + ], + "id": 964 + }, + "timeLastUpdated": "2023-09-21T23:36:39.617Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c5", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/965.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/965.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/30cd8e45247f425929a24da3bae1f5e5", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/30cd8e45247f425929a24da3bae1f5e5", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/965.png", + "format": "png", + "bytes": 3652 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/965.png", + "attributes": [ + { + "value": { + "color": "Blueberry" + }, + "trait_type": "Color" + }, + { + "value": "Dino Tail", + "trait_type": "Tails" + }, + { + "value": "Sphere", + "trait_type": "Bodies" + }, + { + "value": "Abduction Beam", + "trait_type": "Legs" + }, + { + "value": "Phantom Eyes", + "trait_type": "Eyes" + }, + { + "value": "Antena", + "trait_type": "Mouths" + }, + { + "value": 43, + "trait_type": "Intelligence" + }, + { + "value": 51, + "trait_type": "Strength" + }, + { + "value": 51, + "trait_type": "Luck" + } + ], + "id": 965 + }, + "timeLastUpdated": "2023-08-30T13:39:11.383Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c7", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/967.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/967.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/86c58e5f7c484aac5de038026261861e", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/86c58e5f7c484aac5de038026261861e", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/967.png", + "format": "png", + "bytes": 1838 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/967.png", + "attributes": [ + { + "value": { + "color": "Lemon" + }, + "trait_type": "Color" + }, + { + "value": "Cube", + "trait_type": "Bodies" + }, + { + "value": "Raptor Legs", + "trait_type": "Legs" + }, + { + "value": "Stoner Eyes", + "trait_type": "Eyes" + }, + { + "value": "Oh Face", + "trait_type": "Mouths" + }, + { + "value": 41, + "trait_type": "Intelligence" + }, + { + "value": 66, + "trait_type": "Strength" + }, + { + "value": 77, + "trait_type": "Luck" + } + ], + "id": 967 + }, + "timeLastUpdated": "2023-09-19T06:40:40.184Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + }, + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003c8", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/968.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/968.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/b65341bc3f717206b47e37ca7ea85bbb", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/b65341bc3f717206b47e37ca7ea85bbb", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/968.png", + "format": "png", + "bytes": 2100 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/968.png", + "attributes": [ + { + "value": { + "color": "Yellow" + }, + "trait_type": "Color" + }, + { + "value": "Fish Tail", + "trait_type": "Tails" + }, + { + "value": "Mega Cube", + "trait_type": "Bodies" + }, + { + "value": "Space Man_Legs", + "trait_type": "Legs" + }, + { + "value": "X-", + "trait_type": "Eyes" + }, + { + "value": "Srsly Face", + "trait_type": "Mouths" + }, + { + "value": 35, + "trait_type": "Intelligence" + }, + { + "value": 54, + "trait_type": "Strength" + }, + { + "value": 55, + "trait_type": "Luck" + } + ], + "id": 968 + }, + "timeLastUpdated": "2023-09-14T00:38:40.864Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + } + ], + "totalCount": 9, + "blockHash": "0x067d95bf438abb5261b795bf0f1ccef433a49bd90f5a3940cf1bc1bd5b4aa209" +} diff --git a/tests/fixtures/alchemy/nfts_array_unexpected_traits.json b/tests/fixtures/alchemy/nfts_array_unexpected_traits.json new file mode 100644 index 000000000..36c76415f --- /dev/null +++ b/tests/fixtures/alchemy/nfts_array_unexpected_traits.json @@ -0,0 +1,77 @@ +{ + "ownedNfts": [ + { + "contract": { + "address": "0xfa9937555dc20a020a161232de4d2b109c62aa9c" + }, + "id": { + "tokenId": "0x00000000000000000000000000000000000000000000000000000000000003bf", + "tokenMetadata": { + "tokenType": "ERC721" + } + }, + "balance": "1", + "title": "", + "description": "", + "tokenUri": { + "gateway": "https://ipfs.io/ipfs/bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/959.json", + "raw": "ipfs://bafybeiavhuuwieyqervfv3d6qo2mvyphnyvdmtxihk3z76ktni3ijbzila/959.json" + }, + "media": [ + { + "gateway": "https://nft-cdn.alchemy.com/eth-mainnet/4c04cc8ca145ee9f48a137b437c899c4", + "thumbnail": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/4c04cc8ca145ee9f48a137b437c899c4", + "raw": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/959.png", + "format": "png", + "bytes": 2567 + } + ], + "metadata": { + "image": "ipfs://bafybeicqei7siw3fa4zqd7mpv5dmhkpiafpcyxh7wupunhvgi47ba6td7q/959.png", + "attributes": [ + {}, + { + "value": { + "color": "Yellow" + }, + "trait_type": "Color" + }, + { + "value": "Rocket Tail", + "trait_type": "Tails" + }, + { + "value": null + }, + { + "value": [], + "trait_type": "Tails" + } + ], + "id": 959 + }, + "timeLastUpdated": "2023-09-19T18:22:40.335Z", + "contractMetadata": { + "name": "Sudolets", + "symbol": "LETS", + "totalSupply": "1000", + "tokenType": "ERC721", + "contractDeployer": "0xa5dd8ee48f61c8018a5aebdf07baa7e6f24d4e6e", + "deployedBlockNumber": 15325467, + "openSea": { + "floorPrice": 0, + "collectionName": "Sudolets", + "collectionSlug": "sudolets", + "safelistRequestStatus": "not_requested", + "imageUrl": "https://i.seadn.io/gcs/files/a1e9b73a0e24640bf2e9bf145ebba3cd.png?w=500&auto=format", + "description": "On-chain gaming experiment powered by the $XMON token and Chainlink. 0% royalties and all art is CC0.", + "twitterUsername": "sudolets", + "bannerImageUrl": "https://i.seadn.io/gcs/files/536aea8ce4bf39cc98d2815b0e0d2249.png?w=500&auto=format", + "lastIngestedAt": "2023-10-10T10:07:59.000Z" + } + } + } + ], + "totalCount": 1, + "blockHash": "0x067d95bf438abb5261b795bf0f1ccef433a49bd90f5a3940cf1bc1bd5b4aa209" +} From af623b88ba2a968a2b3c0be9f5f14bb5fbd60a8d Mon Sep 17 00:00:00 2001 From: Patricio Marroquin <55117912+patricio0312rev@users.noreply.github.com> Date: Fri, 20 Oct 2023 05:18:13 -0500 Subject: [PATCH 13/14] feat: SyncActivityPrices command to update missing nft activity sales prices (#249) --- app/Console/Commands/SyncActivityPrices.php | 118 ++++++++++++++++++ app/Enums/TokenGuid.php | 14 +++ .../factories/TokenPriceHistoryFactory.php | 29 +++++ resources/types/generated.d.ts | 1 + .../Commands/SyncActivityPricesTest.php | 101 +++++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 app/Console/Commands/SyncActivityPrices.php create mode 100644 app/Enums/TokenGuid.php create mode 100644 database/factories/TokenPriceHistoryFactory.php create mode 100644 tests/App/Console/Commands/SyncActivityPricesTest.php diff --git a/app/Console/Commands/SyncActivityPrices.php b/app/Console/Commands/SyncActivityPrices.php new file mode 100644 index 000000000..0f6443d08 --- /dev/null +++ b/app/Console/Commands/SyncActivityPrices.php @@ -0,0 +1,118 @@ +info('Updating NFT activity table...'); + + $network = Network::firstWhere('chain_id', Chains::Polygon); + $ethereumGuid = TokenGuid::Ethereum->value; + $polygonGuid = TokenGuid::Polygon->value; + + $updateSql = " + UPDATE nft_activity + SET + total_usd = (extra_attributes->'recipientPaid'->>'totalNative')::numeric * + ( + SELECT price + FROM token_price_history + WHERE + token_guid = '{$polygonGuid}' + AND currency = 'usd' + AND timestamp <= nft_activity.timestamp + ORDER BY timestamp DESC + LIMIT 1 + ), + total_native = ( + SELECT ( + (extra_attributes->'recipientPaid'->>'totalNative')::numeric * + ( + SELECT price + FROM token_price_history + WHERE + token_guid = '{$polygonGuid}' + AND currency = 'usd' + AND timestamp <= nft_activity.timestamp + ORDER BY timestamp DESC + LIMIT 1 + ) + ) / ( + SELECT price + FROM token_price_history + WHERE + token_guid = '{$ethereumGuid}' + AND timestamp <= nft_activity.timestamp + AND currency = 'usd' + ORDER BY timestamp DESC + LIMIT 1 + ) + ) + WHERE + nft_activity.collection_id IN ( + SELECT collections.id + FROM collections + WHERE collections.network_id = '$network->id' + ) + AND nft_activity.type = 'LABEL_SALE'; + "; + + DB::statement($updateSql); + + DB::commit(); + + $this->info('NFT activity table updated successfully.'); + + return Command::SUCCESS; + } catch (\Exception $e) { + DB::rollBack(); + $this->error('An error occurred while updating the NFT activity table: '.$e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/app/Enums/TokenGuid.php b/app/Enums/TokenGuid.php new file mode 100644 index 000000000..b4d2296ce --- /dev/null +++ b/app/Enums/TokenGuid.php @@ -0,0 +1,14 @@ + + */ +class TokenPriceHistoryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + // select between ['ethereum', 'matic-network'] + 'token_guid' => fake()->randomElement(['ethereum', 'matic-network']), + 'currency' => 'usd', + 'price' => fake()->randomFloat(2, 0.005, 3.5), + 'timestamp' => fake()->dateTimeBetween('-1 year', 'now'), + ]; + } +} diff --git a/resources/types/generated.d.ts b/resources/types/generated.d.ts index aa146b51f..72afe2350 100644 --- a/resources/types/generated.d.ts +++ b/resources/types/generated.d.ts @@ -515,4 +515,5 @@ declare namespace App.Enums { export type Chains = 1 | 5 | 137 | 80001; export type NftTransferType = "LABEL_MINT" | "LABEL_SALE" | "LABEL_TRANSFER"; export type Platforms = "ethereum" | "polygon-pos"; + export type TokenGuid = "ethereum" | "matic-network"; } diff --git a/tests/App/Console/Commands/SyncActivityPricesTest.php b/tests/App/Console/Commands/SyncActivityPricesTest.php new file mode 100644 index 000000000..ecebdd793 --- /dev/null +++ b/tests/App/Console/Commands/SyncActivityPricesTest.php @@ -0,0 +1,101 @@ +create([ + 'chain_id' => 137, + ]); + + $collection = Collection::factory()->create([ + 'network_id' => $network->id, + ]); + + $extraAttributes = [ + 'sender' => [ + 'type' => 'TYPE_OWNER', + 'address' => '0x0000000000000000000000000000000000000000', + ], + 'recipient' => [ + 'type' => 'TYPE_OWNER', + 'address' => '0x7aa7ce4a1ddd38e1f87498959e164b6e1607bc5d', + ], + 'recipientPaid' => [ + 'totalUsd' => '1.4860810185415954', + 'totalNative' => '1', + 'attributedBy' => 'ATTRIBUTED_BY_SINGLE_TRANSFER', + 'fungibleTotals' => [], + 'nativeTransfersTotal' => '2', + 'fungibleTransfersTotal' => '0', + ], + 'senderReceived' => [ + 'totalUsd' => '0', + 'totalNative' => '0', + 'attributedBy' => 'ATTRIBUTED_BY_NO_PAYMENTS', + 'fungibleTotals' => [], + 'nativeTransfersTotal' => '0', + 'fungibleTransfersTotal' => '0', + ], + ]; + + $priceData = [ + ['price' => 0, 'daysAgo' => 4, 'expected_native' => 0], + ['price' => 300, 'daysAgo' => 0, 'expected_native' => 150], + ]; + + foreach ($priceData as $data) { + $timestamp = Carbon::now()->subDays($data['daysAgo']); + + TokenPriceHistory::factory()->create([ + 'token_guid' => 'matic-network', + 'currency' => 'usd', + 'price' => $data['price'], + 'timestamp' => $timestamp->subHour(), + ]); + + TokenPriceHistory::factory()->create([ + 'token_guid' => 'ethereum', + 'currency' => 'usd', + 'price' => '2', + 'timestamp' => $timestamp, + ]); + + NftActivity::factory()->create([ + 'type' => 'LABEL_SALE', + 'timestamp' => $timestamp, + 'extra_attributes' => $extraAttributes, + 'collection_id' => $collection->id, + 'total_native' => null, + 'total_usd' => null, + ]); + } + + $this->artisan('activities:sync-prices'); + + $activities = NftActivity::where('collection_id', $collection->id)->orderBy('id')->get(); + + expect($activities->count())->toBe(2); + + $activities->each(function ($activity, $index) use ($priceData) { + $price = (int) $priceData[$index]['price']; + $expectedNative = (int) $priceData[$index]['expected_native']; + + expect((int) $activity->total_native)->toBe($expectedNative); + expect((int) $activity->total_usd)->toBe($price); + }); +}); + +it('handles an exception and rolls back the transaction', function () { + DB::shouldReceive('beginTransaction')->once(); + DB::shouldReceive('statement')->once()->andThrow(new \Exception('An error occurred while updating the NFT activity table')); + DB::shouldReceive('rollBack')->once(); + + $this->artisan('activities:sync-prices')->expectsOutput('An error occurred while updating the NFT activity table: An error occurred while updating the NFT activity table')->assertExitCode(1); +}); From ec2d6951d4282f4d8b56d1acaf559d72657b2e61 Mon Sep 17 00:00:00 2001 From: shahin-hq <132887516+shahin-hq@users.noreply.github.com> Date: Fri, 20 Oct 2023 17:03:53 +0400 Subject: [PATCH 14/14] perf: improve collections sorting query (#255) --- app/Models/Collection.php | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/app/Models/Collection.php b/app/Models/Collection.php index 0b91e96fc..d84643aef 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Query\Expression; +use Illuminate\Database\Query\JoinClause; use Illuminate\Notifications\Notification; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -253,19 +254,23 @@ public function scopeOrderByMintDate(Builder $query, string $direction): Builder */ public function scopeOrderByReceivedDate(Builder $query, Wallet $wallet, string $direction): Builder { - $select = sprintf("SELECT timestamp - FROM nft_activity - WHERE nft_activity.collection_id = collections.id - AND recipient = '%s' - ORDER BY timestamp desc - LIMIT 1 - ", $wallet->address); + // this is to ensure that `addSelect` doesn't override the `select collections.*` + if (empty($query->getQuery()->columns)) { + $query->select($this->qualifyColumn('*')); + } + + $query->leftJoin('nft_activity', function (JoinClause $join) use ($wallet) { + $join->on('nft_activity.collection_id', '=', 'collections.id') + ->where('nft_activity.recipient', '=', $wallet->address); + }) + ->addSelect(DB::raw('MAX(nft_activity.timestamp) as received_at')) + ->groupBy('collections.id'); if ($direction === 'asc') { - return $query->orderByRaw(sprintf('(%s) ASC NULLS FIRST', $select)); + return $query->orderByRaw('received_at ASC NULLS FIRST'); } - return $query->orderByRaw(sprintf('(%s) DESC NULLS LAST', $select)); + return $query->orderByRaw('received_at DESC NULLS LAST'); } /** @@ -411,7 +416,7 @@ public function scopeForCollectionData(Builder $query, User $user = null): Build DB::raw(sprintf($extraAttributeSelect, 'opensea_slug', 'opensea_slug').' as opensea_slug'), // gets the website url with the same logic used on the `website` method DB::raw(sprintf('COALESCE(%s, CONCAT(networks.explorer_url, \'%s\', collections.address)) as website', sprintf($extraAttributeSelect, 'website', 'website'), '/token/')), - DB::raw('COUNT(nfts.id) as nfts_count'), + DB::raw('COUNT(DISTINCT nfts.id) as nfts_count'), ])->join( 'networks', 'networks.id',