diff --git a/lang/en/validation.php b/lang/en/validation.php index 7ec3e58..79f010a 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -18,7 +18,7 @@ 'verify_signed_message' => 'The :attribute is invalid.', 'beam_scan_not_found' => 'Beam scan record is not found.', 'max_token_count' => 'The token count exceeded the maximum limit of :limit for this collection.', - 'max_token_supply' => 'The :attribute exceeded the maximum supply limit of :limit for each token for this collection.', + 'max_token_supply' => 'The :attribute exceeded the maximum supply limit of :limit for unique tokens for this collection.', 'has_beam_flag' => 'The :attribute is invalid.', 'not_expired' => 'The beam has expired.', 'tokens_doesnt_exist_in_beam' => 'The :attribute already exist in beam.', diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 7041319..3cb1d1f 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -7,10 +7,12 @@ use Enjin\Platform\Beam\Models\BeamClaim; use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Enjin\Platform\Models\Collection; +use Enjin\Platform\Models\Token; use Enjin\Platform\Rules\Traits\HasDataAwareRule; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Arr; +use Illuminate\Support\LazyCollection; class MaxTokenCount implements DataAwareRule, ValidationRule { @@ -33,37 +35,102 @@ public function __construct(protected ?string $collectionId) {} */ public function validate(string $attribute, mixed $value, Closure $fail): void { - if ($this->collectionId && ($collection = Collection::withCount('tokens')->firstWhere(['collection_chain_id' => $this->collectionId]))) { - if (! is_null($this->limit = $collection->max_token_count)) { - $passes = $collection->max_token_count >= $collection->tokens_count - + collect($this->data['tokens']) - ->filter(fn ($token) => BeamType::getEnumCase($token['type']) == BeamType::MINT_ON_DEMAND) - ->reduce(function ($carry, $token) { - return collect(Arr::get($token, 'tokenIds'))->reduce(function ($val, $tokenId) use ($token) { - $range = $this->integerRange($tokenId); - - $claimQuantity = Arr::get($token, 'claimQuantity', 1); - - return $val + ( - $range === false - ? $claimQuantity - : (($range[1] - $range[0]) + 1) * $claimQuantity - ); - }, $carry); - }, 0) - + BeamClaim::whereHas( - 'beam', - fn ($query) => $query->where('collection_chain_id', $this->collectionId)->where('end', '>', now()) - )->where('type', BeamType::MINT_ON_DEMAND->name) + /** + * The sum of all unique tokens (including existing tokens, tokens in beams, and tokens to be created) + * must not exceed the collection's maximum token count. + */ + if ($this->collectionId + && ($collection = Collection::withCount('tokens')->firstWhere(['collection_chain_id' => $this->collectionId])) + && ! is_null($this->limit = $collection->max_token_count) + ) { + if ($this->limit == 0) { + $fail('enjin-platform-beam::validation.max_token_count')->translate(['limit' => $this->limit]); + + return; + } + + $claimCount = BeamClaim::where('type', BeamType::MINT_ON_DEMAND->name) + ->whereHas( + 'beam', + fn ($query) => $query->where('collection_chain_id', $this->collectionId)->where('end', '>', now()) + )->whereNotExists(function ($query) { + $query->selectRaw('1') + ->from('tokens') + ->whereColumn('tokens.token_chain_id', 'beam_claims.token_chain_id'); + }) + ->groupBy('token_chain_id') + ->count(); + + $tokens = collect($this->data['tokens']) + ->filter(fn ($data) => !empty(Arr::get($data, 'tokenIds'))) + ->pluck('tokenIds') + ->flatten(); + + collect($this->data['tokens']) + ->filter(fn ($data) => !empty(Arr::get($data, 'tokenIdDataUpload'))) + ->map(function ($data) use ($tokens) { + $handle = fopen($data['tokenIdDataUpload']->getPathname(), 'r'); + while (($line = fgets($handle)) !== false) { + if (! $this->tokenIdExists($tokens->all(), $tokenId = trim($line))) { + $tokens->push($tokenId); + } + } + fclose($handle); + }); + + [$integers, $ranges] = collect($tokens)->unique()->partition(fn ($val) => $this->integerRange($val) === false); + + $createTokenTotal = 0; + if ($integers->count()) { + $existingTokens = Token::where('collection_id', $collection->id) + ->whereIn('token_chain_id', $integers) + ->pluck('token_chain_id'); + + $integers = $integers->diff($existingTokens); + if ($integers->count()) { + $existingClaimsCount = BeamClaim::where('collection_id', $collection->id) + ->whereIn('token_chain_id', $integers) + ->claimable() + ->pluck('token_chain_id'); + + $createTokenTotal = $integers->diff($existingClaimsCount)->count(); + } + } + + if ($ranges->count()) { + foreach ($ranges as $range) { + [$from, $to] = $this->integerRange($range); + $existingTokensCount = Token::where('collection_id', $collection->id) + ->whereBetween('token_chain_id', [(int) $from, (int) $to]) ->count(); - if (! $passes) { - $fail('enjin-platform-beam::validation.max_token_count') - ->translate([ - 'limit' => $this->limit, - ]); + if (($to - $from) + 1 == $existingTokensCount) { + continue; + } + + LazyCollection::range((int) $from, (int) $to) + ->chunk(5000) + ->each(function ($chunk) use (&$createTokenTotal, $collection) { + $existingTokens = Token::where('collection_id', $collection->id) + ->whereIn('token_chain_id', $chunk) + ->pluck('token_chain_id'); + + $integers = $chunk->diff($existingTokens); + if ($integers->count()) { + $existingClaimsCount = BeamClaim::where('collection_id', $collection->id) + ->whereIn('token_chain_id', $integers) + ->claimable() + ->pluck('token_chain_id'); + $createTokenTotal += $integers->diff($existingClaimsCount)->count(); + } + }); } } + + $createTokenTotal = $createTokenTotal > 0 ? $createTokenTotal : 0; + if ($collection->max_token_count < $collection->tokens_count + $claimCount + $createTokenTotal) { + $fail('enjin-platform-beam::validation.max_token_count')->translate(['limit' => $this->limit]); + } } } } diff --git a/src/Rules/MaxTokenSupply.php b/src/Rules/MaxTokenSupply.php index be18804..963102a 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -8,9 +8,7 @@ use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Enjin\Platform\Models\Collection; use Enjin\Platform\Models\TokenAccount; -use Enjin\Platform\Models\Wallet; use Enjin\Platform\Rules\Traits\HasDataAwareRule; -use Enjin\Platform\Support\Account; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Arr; @@ -46,97 +44,78 @@ public function __construct(protected ?string $collectionId) {} */ public function validate(string $attribute, mixed $value, Closure $fail): void { + /** + * The total circulating supply of tokens must not exceed the collection's maximum token supply. + * For example, if the maximum token count is 10 and the maximum token supply is 10, + * the total circulating supply must not exceed 100. + */ if ($this->collectionId && ($collection = Collection::firstWhere(['collection_chain_id' => $this->collectionId])) && ! is_null($this->limit = $collection->max_token_supply) ) { - if (Arr::get($this->data, str_replace('tokenQuantityPerClaim', 'type', $attribute)) == BeamType::MINT_ON_DEMAND->name) { - if (! $collection->max_token_supply >= $value) { - $fail($this->maxTokenSupplyMessage) - ->translate([ - 'limit' => $this->limit, - ]); - - return; - } + if ((Arr::get($this->data, str_replace('tokenQuantityPerClaim', 'type', $attribute)) == BeamType::MINT_ON_DEMAND->name + && !$collection->max_token_supply >= $value) + || $this->limit == 0 + ) { + $fail($this->maxTokenSupplyMessage)->translate(['limit' => $this->limit]); + + return; } - $tokenIds = Arr::get($this->data, str_replace('tokenQuantityPerClaim', 'tokenIds', $attribute)); - $integers = collect($tokenIds)->filter(fn ($val) => $this->integerRange($val) === false)->all(); - if ($integers) { - $wallet = Wallet::firstWhere(['public_key' => Account::daemonPublicKey()]); - $collection = Collection::firstWhere(['collection_chain_id' => $this->collectionId]); - if (! $wallet || ! $collection) { - $fail($this->maxTokenSupplyMessage) - ->translate([ - 'limit' => $this->limit, - ]); - - return; - } - $accounts = TokenAccount::join('tokens', 'tokens.id', '=', 'token_accounts.token_id') - ->where('token_accounts.wallet_id', $wallet->id) - ->where('token_accounts.collection_id', $collection->id) - ->whereIn('tokens.token_chain_id', $integers) - ->selectRaw('tokens.token_chain_id, sum(token_accounts.balance) as balance') - ->groupBy('tokens.token_chain_id') - ->get(); - - $claims = BeamClaim::whereHas( - 'beam', - fn ($query) => $query->where('collection_chain_id', $this->collectionId)->where('end', '>', now()) - )->where('type', BeamType::TRANSFER_TOKEN->name) - ->whereIn('token_chain_id', $integers) - ->whereNull('wallet_public_key') - ->selectRaw('token_chain_id, sum(quantity) as quantity') - ->groupBy('token_chain_id') - ->pluck('quantity', 'token_chain_id'); - foreach ($accounts as $account) { - if ((int) $account->balance < $value + Arr::get($claims, $account->token_chain_id, 0)) { - $fail($this->maxTokenBalanceMessage)->translate(); - - return; - } - } + if ($collection->max_token_count == 0) { + $fail('enjin-platform-beam::validation.max_token_count')->translate(['limit' => $this->limit]); + + return; } - $ranges = collect($tokenIds)->filter(fn ($val) => $this->integerRange($val) !== false)->all(); - if ($ranges) { - $wallet = Wallet::firstWhere(['public_key' => Account::daemonPublicKey()]); - $collection = Collection::firstWhere(['collection_chain_id' => $this->collectionId]); - if (! $wallet || ! $collection) { - $fail($this->maxTokenSupplyMessage) - ->translate([ - 'limit' => $this->limit, - ]); - - return; - } - foreach ($ranges as $range) { - [$from, $to] = $this->integerRange($range); - $accounts = TokenAccount::join('tokens', 'tokens.id', '=', 'token_accounts.token_id') - ->where('token_accounts.wallet_id', $wallet->id) - ->where('token_accounts.collection_id', $collection->id) - ->whereBetween('tokens.token_chain_id', [(int) $from, (int) $to]) - ->selectRaw('tokens.token_chain_id, sum(token_accounts.balance) as balance') - ->groupBy('tokens.token_chain_id') - ->get(); - - $claims = BeamClaim::whereHas( - 'beam', - fn ($query) => $query->where('collection_chain_id', $this->collectionId)->where('end', '>', now()) - )->where('type', BeamType::TRANSFER_TOKEN->name) - ->whereBetween('token_chain_id', [(int) $from, (int) $to]) - ->whereNull('wallet_public_key') - ->selectRaw('token_chain_id, sum(quantity) as quantity') - ->groupBy('token_chain_id') - ->pluck('quantity', 'token_chain_id'); - foreach ($accounts as $account) { - if ((int) $account->balance < $value + Arr::get($claims, $account->token_chain_id, 0)) { - $fail($this->maxTokenBalanceMessage)->translate(); + $this->limit = $collection->max_token_supply * ($collection->max_token_count ?? 1); + + $balanceCount = TokenAccount::where('token_accounts.collection_id', $collection->id)->sum('balance'); + $claimCount = BeamClaim::where('type', BeamType::MINT_ON_DEMAND->name) + ->whereHas('beam', fn ($query) => $query->where('collection_chain_id', $this->collectionId)->where('end', '>', now())) + ->claimable() + ->sum('quantity'); + + $tokenCount = 0; + $tokenCount = collect($this->data['tokens']) + ->reduce(function ($carry, $token) { + + if (Arr::get($token, 'tokenIds')) { + return collect($token['tokenIds'])->reduce(function ($val, $tokenId) use ($token) { + $range = $this->integerRange($tokenId); + $claimQuantity = Arr::get($token, 'claimQuantity', 1); + $quantityPerClaim = Arr::get($token, 'tokenQuantityPerClaim', 1); + + return $val + ( + $range === false + ? $claimQuantity * $quantityPerClaim + : (($range[1] - $range[0]) + 1) * $claimQuantity * $quantityPerClaim + ); + }, $carry); + } + + if (Arr::get($token, 'tokenIdDataUpload')) { + $total = 0; + $handle = fopen($token['tokenIdDataUpload']->getPathname(), 'r'); + while (($line = fgets($handle)) !== false) { + $range = $this->integerRange(trim($line)); + $claimQuantity = Arr::get($token, 'claimQuantity', 1); + $quantityPerClaim = Arr::get($token, 'tokenQuantityPerClaim', 1); + $total += ( + $range === false + ? $claimQuantity * $quantityPerClaim + : (($range[1] - $range[0]) + 1) * $claimQuantity * $quantityPerClaim + ); } + fclose($handle); + + return $total; } - } + + }, $tokenCount); + + if ($this->limit < $balanceCount + $claimCount + $tokenCount) { + $fail($this->maxTokenSupplyMessage)->translate(['limit' => $this->limit]); } } } diff --git a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php index 9262272..1ba4da0 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -491,18 +491,18 @@ public function test_it_will_fail_with_invalid_claim_quantity(): void ); $this->assertArraySubset(['tokens.0.claimQuantity' => ['The token count exceeded the maximum limit of 0 for this collection.']], $response['error']); - $this->collection->update(['max_token_count' => 2]); $response = $this->graphql( $this->method, $data = array_merge( $this->generateBeamData(BeamType::MINT_ON_DEMAND, 1), ['tokens' => [['tokenIds' => ['1'], 'type' => BeamType::MINT_ON_DEMAND->name]]] - ) + ), + true ); $this->assertNotEmpty($response); $response = $this->graphql($this->method, $data, true); - $this->assertArraySubset(['tokens.0.claimQuantity' => ['The token count exceeded the maximum limit of 2 for this collection.']], $response['error']); + $this->assertArraySubset(['tokens.0.claimQuantity' => ['The token count exceeded the maximum limit of 0 for this collection.']], $response['error']); } /** @@ -518,30 +518,18 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void true ); $this->assertArraySubset( - ['tokens.0.tokenQuantityPerClaim' => ['The tokens.0.tokenQuantityPerClaim exceeded the maximum supply limit of 0 for each token for this collection.']], + ['tokens.0.tokenQuantityPerClaim' => ['The tokens.0.tokenQuantityPerClaim exceeded the maximum supply limit of 0 for unique tokens for this collection.']], $response['error'] ); $response = $this->graphql( $this->method, - $this->generateBeamPackData(BeamType::MINT_ON_DEMAND, 10), + $this->generateBeamData(BeamType::TRANSFER_TOKEN, 1), true ); - $this->assertArraySubset( - ['packs.0.tokens.0.tokenQuantityPerClaim' => ['The packs.0.tokens.0.tokenQuantityPerClaim exceeded the maximum supply limit of 0 for each token for this collection.']], - $response['error'] - ); - - $this->collection->update(['max_token_supply' => 2]); - $response = $this->graphql( - $this->method, - $data = $this->generateBeamData(BeamType::TRANSFER_TOKEN, 1), - ); $this->assertNotEmpty($response); - - $response = $this->graphql($this->method, $data, true); $this->assertArraySubset( - ['tokens.0.tokenQuantityPerClaim' => ['The tokens.0.tokenQuantityPerClaim is invalid, the amount provided is bigger than the token account balance.']], + ['tokens.0.tokenQuantityPerClaim' => ['The tokens.0.tokenQuantityPerClaim exceeded the maximum supply limit of 0 for unique tokens for this collection.']], $response['error'] ); diff --git a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php index 9d01f88..8d723d6 100644 --- a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php @@ -14,7 +14,6 @@ use Enjin\Platform\Beam\Tests\Feature\Traits\SeedBeamData; use Enjin\Platform\Enums\Substrate\TokenMintCapType; use Enjin\Platform\GraphQL\Types\Scalars\Traits\HasIntegerRanges; -use Enjin\Platform\Models\Laravel\Collection; use Enjin\Platform\Models\Laravel\Token; use Enjin\Platform\Support\Hex; use Illuminate\Http\UploadedFile; @@ -311,43 +310,13 @@ public function test_it_will_fail_existing_tokens(): void $response['error'] ); - $collection = Collection::create([ - 'collection_chain_id' => (string) fake()->unique()->numberBetween(2000), - 'owner_wallet_id' => $this->wallet->id, - 'max_token_count' => '1', - 'max_token_supply' => '1', - 'force_single_mint' => true, - 'is_frozen' => false, - 'token_count' => '0', - 'attribute_count' => '0', - 'total_deposit' => '0', - 'network' => 'developer', - ]); - - $create = [ - 'name' => fake()->name(), - 'description' => fake()->word(), - 'image' => fake()->url(), - 'start' => Carbon::now()->toDateTimeString(), - 'end' => Carbon::now()->addDays(random_int(1, 1000))->toDateTimeString(), - 'collectionId' => $collection->collection_chain_id, - 'tokens' => [[ - 'type' => BeamType::MINT_ON_DEMAND->name, - 'tokenIds' => ['0'], - 'tokenQuantityPerClaim' => 1, - 'claimQuantity' => 1, - 'attributes' => null, - ]], - ]; - $this->assertNotEmpty($code = $this->graphql('CreateBeam', $create)); - $updates = array_merge( - Arr::only($create, ['tokens']), - ['code' => $code] + $updates, + ['tokens' => [['tokenIds' => [$token->token_chain_id . '..' . $token->token_chain_id], 'type' => BeamType::MINT_ON_DEMAND->name]]] ); $response = $this->graphql($this->method, $updates, true); $this->assertArraySubset( - ['tokens.0.tokenIds' => ['The tokens.0.tokenIds already exist in beam.']], + ['tokens.0.tokenIds' => ['The tokens.0.tokenIds exists in the specified collection.']], $response['error'] ); }