From 6fa7581a669c7b591245db4102f8e0943f4715ab Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Thu, 1 Aug 2024 15:07:32 +0800 Subject: [PATCH 01/24] support create beam packs --- .../2024_08_01_041409_beam_packs.php | 48 ++++ lang/en/input_type.php | 2 + src/BeamServiceProvider.php | 1 + src/GraphQL/Mutations/CreateBeamMutation.php | 80 ++----- src/GraphQL/Traits/HasTokenInputRules.php | 159 +++++++++++++ src/GraphQL/Types/BeamPackType.php | 74 ++++++ src/GraphQL/Types/Input/BeamPackInputType.php | 36 +++ src/Jobs/DispatchCreateBeamClaimsJobs.php | 10 +- src/Models/BeamPack.php | 7 + src/Models/Laravel/Beam.php | 9 + src/Models/Laravel/BeamPack.php | 194 ++++++++++++++++ src/Rules/BeamPackExistInBeam.php | 33 +++ src/Services/BeamService.php | 87 ++++++- .../GraphQL/Mutations/CreateBeamTest.php | 217 ++++++++++++++++-- .../GraphQL/Resources/CreateBeam.graphql | 4 +- tests/Feature/Traits/CreateBeamData.php | 27 +++ 16 files changed, 891 insertions(+), 97 deletions(-) create mode 100644 database/migrations/2024_08_01_041409_beam_packs.php create mode 100644 src/GraphQL/Traits/HasTokenInputRules.php create mode 100644 src/GraphQL/Types/BeamPackType.php create mode 100644 src/GraphQL/Types/Input/BeamPackInputType.php create mode 100644 src/Models/BeamPack.php create mode 100644 src/Models/Laravel/BeamPack.php create mode 100644 src/Rules/BeamPackExistInBeam.php diff --git a/database/migrations/2024_08_01_041409_beam_packs.php b/database/migrations/2024_08_01_041409_beam_packs.php new file mode 100644 index 0000000..01b1131 --- /dev/null +++ b/database/migrations/2024_08_01_041409_beam_packs.php @@ -0,0 +1,48 @@ +boolean('is_pack')->default(false)->index(); + }); + + Schema::create('beam_packs', function (Blueprint $table) { + $table->id(); + $table->foreignId('beam_id')->constrained()->cascadeOnDelete(); + $table->string('code')->index()->nullable(); + $table->unsignedInteger('nonce')->nullable(); + $table->boolean('is_claimed')->default(false)->index(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::table('beam_claims', function (Blueprint $table) { + $table->foreignId('beam_pack_id')->index()->nullable()->constrained()->cascadeOnDelete(); + $table->dropUnique(['idempotency_key']); + $table->index(['idempotency_key']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('beams', function (Blueprint $table) { + $table->dropColumn('is_pack'); + }); + Schema::dropIfExists('beam_packs'); + Schema::table('beam_claims', function (Blueprint $table) { + $table->dropColumn('beam_pack_id'); + $table->unique(['idempotency_key']); + }); + } +}; diff --git a/lang/en/input_type.php b/lang/en/input_type.php index 9bb7602..dad3880 100644 --- a/lang/en/input_type.php +++ b/lang/en/input_type.php @@ -9,4 +9,6 @@ 'claim_token.field.tokenIdDataUpload' => 'You can use this to upload a txt file that contains a list of token ID ranges, one per line.', 'claim_token.field.claimQuantity' => 'The total amount of times each token ID can be claimed. This is mainly relevant for fungible tokens, where you can specify that there are a certain amount of claims for a token ID, e.g. 10 individual claims to receive 1 token with ID 123 per claim.', 'claim_token.field.tokenQuantityPerClaim' => 'The quantity of token that can be received per claim.', + 'beam_pack.description' => 'The beam pack.', + 'beam_pack.field.id' => 'The beam pack database ID, which can be null when creating a new beam pack.', ]; diff --git a/src/BeamServiceProvider.php b/src/BeamServiceProvider.php index 84678cf..9f35aa3 100644 --- a/src/BeamServiceProvider.php +++ b/src/BeamServiceProvider.php @@ -39,6 +39,7 @@ public function configurePackage(Package $package): void ->hasMigration('update_beams_table') ->hasMigration('add_collection_chain_id_to_beam_batches_table') ->hasMigration('add_idempotency_key_to_beam_claims_table') + ->hasMigration('beam_packs') ->hasRoute('enjin-platform-beam') ->hasTranslations(); } diff --git a/src/GraphQL/Mutations/CreateBeamMutation.php b/src/GraphQL/Mutations/CreateBeamMutation.php index 955d10a..058970f 100644 --- a/src/GraphQL/Mutations/CreateBeamMutation.php +++ b/src/GraphQL/Mutations/CreateBeamMutation.php @@ -3,29 +3,21 @@ namespace Enjin\Platform\Beam\GraphQL\Mutations; use Closure; -use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\GraphQL\Traits\HasBeamCommonFields; -use Enjin\Platform\Beam\Rules\MaxTokenCount; -use Enjin\Platform\Beam\Rules\MaxTokenSupply; -use Enjin\Platform\Beam\Rules\TokensDoNotExistInBeam; -use Enjin\Platform\Beam\Rules\TokensExistInCollection; -use Enjin\Platform\Beam\Rules\TokenUploadExistInCollection; -use Enjin\Platform\Beam\Rules\TokenUploadNotExistInBeam; -use Enjin\Platform\Beam\Rules\UniqueTokenIds; +use Enjin\Platform\Beam\GraphQL\Traits\HasTokenInputRules; use Enjin\Platform\Beam\Services\BeamService; use Enjin\Platform\Models\Collection; -use Enjin\Platform\Rules\DistinctAttributes; use Enjin\Platform\Rules\IsCollectionOwnerOrApproved; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\Rule; use Rebing\GraphQL\Support\Facades\GraphQL; class CreateBeamMutation extends Mutation { use HasBeamCommonFields; + use HasTokenInputRules; /** * Get the mutation's attributes. @@ -63,9 +55,13 @@ public function args(): array 'description' => __('enjin-platform-beam::mutation.create_beam.args.collectionId'), ], 'tokens' => [ - 'type' => GraphQL::type('[ClaimToken!]!'), + 'type' => GraphQL::type('[ClaimToken!]'), 'description' => __('enjin-platform-beam::input_type.claim_token.description'), ], + 'packs' => [ + 'type' => GraphQL::type('[BeamPackInput!]'), + 'description' => __('enjin-platform-beam::input_type.beam_pack.description'), + ], ]; } @@ -80,7 +76,10 @@ public function resolve( Closure $getSelectFields, BeamService $beam ) { - return DB::transaction(fn () => $beam->create($args)->code, 3); + return DB::transaction( + fn () => $beam->create($args, (bool) Arr::get($args, 'packs'))->code, + 3 + ); } /** @@ -104,62 +103,9 @@ function (string $attribute, mixed $value, Closure $fail) { }, new IsCollectionOwnerOrApproved(), ], - 'tokens' => ['bail', 'array', 'min:1', 'max:1000', new UniqueTokenIds()], - 'tokens.*.attributes' => Rule::forEach(function ($value, $attribute) use ($args) { - if (empty($value)) { - return []; - } - - return [ - 'nullable', - 'bail', - 'array', - 'min:1', - 'max:10', - new DistinctAttributes(), - Rule::prohibitedIf(BeamType::getEnumCase(Arr::get($args, str_replace('attributes', 'type', $attribute))) == BeamType::TRANSFER_TOKEN), - ]; - }), - 'tokens.*.attributes.*.key' => 'max:255', - 'tokens.*.attributes.*.value' => 'max:1000', - 'tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args) { - return [ - 'bail', - 'required_without:tokens.*.tokenIdDataUpload', - 'prohibits:tokens.*.tokenIdDataUpload', - 'distinct', - BeamType::getEnumCase(Arr::get($args, str_replace('tokenIds', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokensExistInCollection($args['collectionId']) - : '', - new TokensDoNotExistInBeam(), - ]; - }), - 'tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args) { - return [ - 'bail', - 'required_without:tokens.*.tokenIds', - 'prohibits:tokens.*.tokenIds', - BeamType::getEnumCase(Arr::get($args, str_replace('tokenIdDataUpload', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokenUploadExistInCollection($args['collectionId']) - : '', - new TokenUploadNotExistInBeam(), - ]; - }), - 'tokens.*.tokenQuantityPerClaim' => [ - 'bail', - 'filled', - 'integer', - 'min:1', - new MaxTokenSupply($args['collectionId']), - ], - 'tokens.*.claimQuantity' => [ - 'bail', - 'filled', - 'integer', - 'min:1', - new MaxTokenCount($args['collectionId']), - ], 'flags.*.flag' => ['required', 'distinct'], + ...$this->tokenRules($args), + ...$this->packTokenRules($args, $args['collectionId']), ]; } } diff --git a/src/GraphQL/Traits/HasTokenInputRules.php b/src/GraphQL/Traits/HasTokenInputRules.php new file mode 100644 index 0000000..4d7f8df --- /dev/null +++ b/src/GraphQL/Traits/HasTokenInputRules.php @@ -0,0 +1,159 @@ + [ + 'bail', + ...($withPacks ? ['required_without:packs', 'prohibits:packs'] : []), + 'array', + 'min:1', + 'max:1000', + new UniqueTokenIds(), + ], + 'tokens.*.attributes' => Rule::forEach(function ($value, $attribute) use ($args) { + if (empty($value)) { + return []; + } + + return [ + 'nullable', + 'bail', + 'array', + 'min:1', + 'max:10', + new DistinctAttributes(), + Rule::prohibitedIf(BeamType::getEnumCase(Arr::get($args, str_replace('attributes', 'type', $attribute))) == BeamType::TRANSFER_TOKEN), + ]; + }), + 'tokens.*.attributes.*.key' => 'max:255', + 'tokens.*.attributes.*.value' => 'max:1000', + 'tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args) { + return [ + 'bail', + 'required_without:tokens.*.tokenIdDataUpload', + 'prohibits:tokens.*.tokenIdDataUpload', + 'distinct', + BeamType::getEnumCase(Arr::get($args, str_replace('tokenIds', 'type', $attribute))) == BeamType::TRANSFER_TOKEN + ? new TokensExistInCollection($args['collectionId']) + : '', + new TokensDoNotExistInBeam(), + ]; + }), + 'tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args) { + return [ + 'bail', + 'required_without:tokens.*.tokenIds', + 'prohibits:tokens.*.tokenIds', + BeamType::getEnumCase(Arr::get($args, str_replace('tokenIdDataUpload', 'type', $attribute))) == BeamType::TRANSFER_TOKEN + ? new TokenUploadExistInCollection($args['collectionId']) + : '', + new TokenUploadNotExistInBeam(), + ]; + }), + 'tokens.*.tokenQuantityPerClaim' => [ + 'bail', + 'filled', + 'integer', + 'min:1', + new MaxTokenSupply($args['collectionId']), + ], + 'tokens.*.claimQuantity' => [ + 'bail', + 'filled', + 'integer', + 'min:1', + new MaxTokenCount($args['collectionId']), + ], + ]; + } + + public function packTokenRules(array $args, ?string $collectionId = null, bool $withTokens = true): array + { + return [ + 'packs' => [ + 'bail', + ...($withTokens ? ['required_without:tokens', 'prohibits:tokens'] : []), + 'array', + 'min:1', + 'max:1000', + ], + 'packs.*.id' => [new BeamPackExistInBeam()], + 'packs.*.tokens' => [ + 'bail', + 'array', + 'min:1', + 'max:1000', + new UniqueTokenIds(), + ], + 'packs.*.tokens.*.attributes' => Rule::forEach(function ($value, $attribute) use ($args) { + if (empty($value)) { + return []; + } + + return [ + 'nullable', + 'bail', + 'array', + 'min:1', + 'max:10', + new DistinctAttributes(), + Rule::prohibitedIf(BeamType::getEnumCase(Arr::get($args, str_replace('attributes', 'type', $attribute))) == BeamType::TRANSFER_TOKEN), + ]; + }), + 'packs.*.tokens.*.attributes.*.key' => 'max:255', + 'packs.*.tokens.*.attributes.*.value' => 'max:1000', + 'packs.*.tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args, $collectionId) { + $key = str_replace('tokenIds', 'tokenIdDataUpload', $attribute); + + return [ + 'bail', + "required_without:{$key}", + "prohibits:{$key}", + 'distinct', + BeamType::getEnumCase(Arr::get($args, str_replace('tokenIds', 'type', $attribute))) == BeamType::TRANSFER_TOKEN + ? new TokensExistInCollection($collectionId) + : '', + new TokensDoNotExistInBeam(), + ]; + }), + 'packs.*.tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args, $collectionId) { + $key = str_replace('tokenIdDataUpload', 'tokenIds', $attribute); + + return [ + 'bail', + "required_without:{$key}", + "prohibits:{$key}", + BeamType::getEnumCase(Arr::get($args, str_replace('tokenIdDataUpload', 'type', $attribute))) == BeamType::TRANSFER_TOKEN + ? new TokenUploadExistInCollection($collectionId) + : '', + new TokenUploadNotExistInBeam(), + ]; + }), + 'packs.*.tokens.*.tokenQuantityPerClaim' => [ + 'bail', + 'filled', + 'integer', + 'min:1', + new MaxTokenSupply($collectionId), + ], + ]; + } +} diff --git a/src/GraphQL/Types/BeamPackType.php b/src/GraphQL/Types/BeamPackType.php new file mode 100644 index 0000000..ee83104 --- /dev/null +++ b/src/GraphQL/Types/BeamPackType.php @@ -0,0 +1,74 @@ + 'BeamPack', + 'description' => __('enjin-platform-beam::type.beam_pack.description'), + 'model' => BeamPack::class, + ]; + } + + /** + * Get the type's fields. + */ + public function fields(): array + { + return [ + 'id' => [ + 'type' => GraphQL::type('Int'), + 'description' => __('enjin-platform-beam::type.beam_claim.field.id'), + ], + 'code' => [ + 'type' => GraphQL::type('String!'), + 'description' => __('enjin-platform-beam::type.beam_claim.field.code'), + 'resolve' => fn ($claim) => $claim->code ? $claim->singleUseCode : '', + 'excludeFrom' => ['GetBeam', 'GetBeams', 'GetPendingClaims'], + ], + 'isClaimed' => [ + 'type' => GraphQL::type('Boolean!'), + 'description' => __('enjin-platform-beam::type.beam_claim.field.code'), + 'alias' => 'is_claimed', + ], + 'beam' => [ + 'type' => GraphQL::type('Beam'), + 'description' => __('enjin-platform-beam::type.beam_claim.field.beam'), + 'is_relation' => true, + ], + 'qr' => [ + 'type' => GraphQL::type('BeamQr'), + 'description' => __('enjin-platform-beam::type.beam.field.qr'), + 'resolve' => function ($claim) { + return [ + 'url' => $claim->qrUrl, + 'payload' => $claim->claimableCode, + ]; + }, + 'selectable' => false, + 'is_relation' => false, + 'excludeFrom' => ['GetBeam', 'GetBeams', 'GetPendingClaims'], + ], + 'claims' => [ + 'type' => GraphQL::type('[BeamClaim!]'), + 'description' => __('enjin-platform-beam::type.beam_claim.description'), + 'selectable' => false, + 'is_relation' => true, + ], + ]; + } +} diff --git a/src/GraphQL/Types/Input/BeamPackInputType.php b/src/GraphQL/Types/Input/BeamPackInputType.php new file mode 100644 index 0000000..3e3a083 --- /dev/null +++ b/src/GraphQL/Types/Input/BeamPackInputType.php @@ -0,0 +1,36 @@ + 'BeamPackInput', + 'description' => __('enjin-platform-beam::input_type.beam_pack.description'), + ]; + } + + /** + * Get the input type's fields. + */ + public function fields(): array + { + return [ + 'id' => [ + 'type' => GraphQL::type('Int'), + 'description' => __('enjin-platform-beam::input_type.beam_pack.field.id'), + ], + 'tokens' => [ + 'type' => GraphQL::type('[ClaimToken!]!'), + 'description' => __('enjin-platform-beam::input_type.claim_token.description'), + ], + ]; + } +} diff --git a/src/Jobs/DispatchCreateBeamClaimsJobs.php b/src/Jobs/DispatchCreateBeamClaimsJobs.php index 4480c8c..9137c91 100644 --- a/src/Jobs/DispatchCreateBeamClaimsJobs.php +++ b/src/Jobs/DispatchCreateBeamClaimsJobs.php @@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; +use Illuminate\Support\Arr; class DispatchCreateBeamClaimsJobs implements ShouldQueue { @@ -25,7 +26,8 @@ class DispatchCreateBeamClaimsJobs implements ShouldQueue */ public function __construct( protected Model $beam, - protected ?array $tokens + protected ?array $tokens, + protected ?int $packId = null ) {} /** @@ -43,7 +45,7 @@ public function handle(): void collect($token['tokenIds'])->each(function ($tokenId) use ($beam, $claims, $token) { $range = $this->integerRange($tokenId); if ($range === false) { - for ($i = 0; $i < $token['claimQuantity']; $i++) { + for ($i = 0; $i < Arr::get($token, 'claimQuantity', 1); $i++) { $claims->push([ 'beam_id' => $beam->id, 'token_chain_id' => $tokenId, @@ -53,6 +55,7 @@ public function handle(): void 'nonce' => 1, 'attributes' => json_encode($token['attributes']) ?: null, 'quantity' => $token['quantity'], + 'beam_pack_id' => $this->packId, ]); } } else { @@ -66,7 +69,7 @@ public function handle(): void })->chunk(10000)->each(function (LazyCollection $tokenIds) use ($beam, $token) { $claims = collect(); $tokenIds->each(function ($tokenId) use ($token, $beam, $claims) { - for ($i = 0; $i < $token['claimQuantity']; $i++) { + for ($i = 0; $i < Arr::get($token, 'claimQuantity', 1); $i++) { $claims->push([ 'beam_id' => $beam->id, 'token_chain_id' => $tokenId, @@ -76,6 +79,7 @@ public function handle(): void 'nonce' => 1, 'attributes' => json_encode($token['attributes']) ?: null, 'quantity' => $token['quantity'], + 'beam_pack_id' => $this->packId, ]); } }); diff --git a/src/Models/BeamPack.php b/src/Models/BeamPack.php new file mode 100644 index 0000000..5f690bd --- /dev/null +++ b/src/Models/BeamPack.php @@ -0,0 +1,7 @@ +hasMany(BeamPack::class, 'beam_id'); + } + /** * Interact with the beam's start attribute. */ diff --git a/src/Models/Laravel/BeamPack.php b/src/Models/Laravel/BeamPack.php new file mode 100644 index 0000000..c32a58a --- /dev/null +++ b/src/Models/Laravel/BeamPack.php @@ -0,0 +1,194 @@ +|bool + */ + public $guarded = []; + + /** + * The fillable fields. + * + * @var array + */ + protected $fillable = [ + 'name', + 'code', + 'description', + 'image', + 'start', + 'end', + 'collection_chain_id', + 'flags_mask', + 'is_pack', + 'beam_id', + 'nonce', + ]; + + /** + * Cascade softdeletes. + */ + protected $cascadeDeletes = ['claims', 'scans']; + + /** + * The hidden fields. + * + * @var array + */ + protected $hidden = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + /** + * The beam claim's relationship. + */ + public function claims(): HasMany + { + return $this->hasMany(BeamClaim::class, 'beam_id'); + } + + /** + * The beam scans relationship. + */ + public function scans(): HasMany + { + return $this->hasMany(BeamScan::class, 'beam_id'); + } + + /** + * The collection relationship. + */ + public function collection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'collection_chain_id', 'collection_chain_id'); + } + + /** + * Check if the beam has a flag. + */ + public function hasFlag(BeamFlag $flag): bool + { + return BitMask::getBit($flag->value, $this->flags_mask ?? 0); + } + + /** + * Boot model. + */ + public static function boot() + { + static::deleting(function ($model) { + BeamScan::where('beam_id', $model->id)->update(['deleted_at' => $now = now()]); + BeamClaim::where('beam_id', $model->id)->update(['deleted_at' => $now]); + }); + + static::deleted(function ($model) { + Cache::forget(BeamService::key($model->code)); + }); + + parent::boot(); + } + + /** + * The beam pack' relationship. + */ + public function packs(): HasMany + { + return $this->hasMany(self::class, 'beam_id'); + } + + /** + * Interact with the beam's start attribute. + */ + protected function start(): Attribute + { + return Attribute::make( + get: fn ($value) => $value, + set: fn ($value) => Carbon::parse($value)->toDateTimeString(), + ); + } + + /** + * Interact with the beam's end attribute. + */ + protected function end(): Attribute + { + return $this->start(); + } + + /** + * Interact with the beam's claims remaining attribute. + */ + protected function claimsRemaining(): Attribute + { + return Attribute::make( + get: fn () => Cache::get(BeamService::key($this->code), BeamService::claimsCountResolver($this->code)) + ); + } + + /** + * Interact with the beam's claims remaining attribute. + */ + protected function probabilities(): Attribute + { + return Attribute::make( + get: fn () => ClaimProbabilities::getProbabilities($this->code)['probabilities'] ?? null + ); + } + + /** + * This model's factory. + */ + protected static function newFactory(): BeamFactory + { + return BeamFactory::new(); + } + + /** + * The beam flags attribute. + */ + protected function flags(): Attribute + { + return Attribute::make( + get: fn () => collect(BitMask::getBits($this->flags_mask))->map(function ($flag) { + return BeamFlag::from($flag)->name; + })->toArray() + ); + } + + /** + * This model's specific pivot identifier. + */ + protected function pivotIdentifier(): Attribute + { + return Attribute::make( + get: fn () => $this->code, + ); + } +} diff --git a/src/Rules/BeamPackExistInBeam.php b/src/Rules/BeamPackExistInBeam.php new file mode 100644 index 0000000..1d50bef --- /dev/null +++ b/src/Rules/BeamPackExistInBeam.php @@ -0,0 +1,33 @@ +data['code'])->whereHas('packs', fn ($query) => $query->where('id', $value))->exists()) { + $fail('enjin-platform-beam::validation.beam_pack_exist_in_beam')->translate(); + } + + } +} diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 022f35d..ad27f26 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -19,6 +19,7 @@ use Enjin\Platform\Beam\Jobs\DispatchCreateBeamClaimsJobs; use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Models\BeamClaim; +use Enjin\Platform\Beam\Models\BeamPack; use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Enjin\Platform\Beam\Support\ClaimProbabilities; use Enjin\Platform\Support\BitMask; @@ -77,18 +78,23 @@ public static function getFlagsValue(?array $flags, int $initial = 0): ?int /** * Create a beam. */ - public function create(array $args): Model + public function create(array $args, bool $isPack = false): Model { $beam = Beam::create([ - ...Arr::except($args, ['tokens', 'flags']), + ...Arr::except($args, ['tokens', 'packs', 'flags']), 'flags_mask' => static::getFlagsValue(Arr::get($args, 'flags')), 'code' => bin2hex(openssl_random_pseudo_bytes(16)), + 'is_pack' => $isPack, ]); if ($beam) { - Cache::forever( - self::key($beam->code), - $this->createClaims(Arr::get($args, 'tokens', []), $beam) - ); + if ($isPack) { + $this->createPackClaims($beam, Arr::get($args, 'packs', []), true); + } else { + Cache::forever( + self::key($beam->code), + $this->createClaims($beam, Arr::get($args, 'tokens', [])) + ); + } event(new BeamCreated($beam)); return $beam; @@ -97,6 +103,73 @@ public function create(array $args): Model return throw new BeamException(__('enjin-platform-beam::error.unable_to_save')); } + /** + * Create beam pack claims. + */ + public function createPackClaims(Model $beam, array $packs, bool $isNew = false): bool + { + if (empty($packs)) { + return false; + } + + $quantity = 0; + foreach ($packs as $pack) { + if (!($id = Arr::get($pack, 'id'))) { + $quantity++; + } + + $model = BeamPack::firstOrcreate(['id' => $id], [ + 'beam_id' => $beam->id, + 'code' => bin2hex(openssl_random_pseudo_bytes(16)), + 'nonce' => 1, + ]); + + $tokens = collect($pack['tokens']); + $tokenIds = $tokens->whereNotNull('tokenIds'); + + if ($tokenIds->count()) { + DispatchCreateBeamClaimsJobs::dispatch($beam, $tokenIds->all(), $model->id)->afterCommit(); + } + + $tokenUploads = $tokens->whereNotNull('tokenIdDataUpload'); + if ($tokenUploads->count()) { + $ids = $tokenIds->pluck('tokenIds'); + $tokenUploads->each(function ($token) use ($beam, $ids, $model) { + LazyCollection::make(function () use ($token, $ids) { + $handle = fopen($token['tokenIdDataUpload']->getPathname(), 'r'); + while (($line = fgets($handle)) !== false) { + if (! $this->tokenIdExists($ids->all(), $tokenId = trim($line))) { + $ids->push($tokenId); + yield $tokenId; + } + } + fclose($handle); + })->chunk(10000)->each(function (LazyCollection $tokenIds) use ($beam, $token, $model) { + $token['tokenIds'] = $tokenIds->all(); + unset($token['tokenIdDataUpload']); + DispatchCreateBeamClaimsJobs::dispatch($beam, [$token], $model->id)->afterCommit(); + unset($tokenIds, $token); + }); + }); + } + + } + + if ($isNew) { + return Cache::forever(self::key($beam->code), count($packs)); + } + + TokensAdded::safeBroadcast( + event: [ + 'beamCode' => $beam->code, + 'code' => $beam->code, + 'tokenIds' => collect($packs)->map(fn ($pack) => $pack['tokens'])->flatten()->pluck('tokenIds')->all(), + ] + ); + + return Cache::increment(self::key($beam->code), $quantity); + } + /** * Update beam by code. */ @@ -377,7 +450,7 @@ public function removeTokens(string $code, array $tokens): bool /** * Create beam claims. */ - protected function createClaims(array $tokens, Model $beam): int + protected function createClaims(Model $beam, array $tokens): int { $totalClaimCount = 0; $tokens = collect($tokens); diff --git a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php index 25f2fe2..9262272 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -33,6 +33,8 @@ class CreateBeamTest extends TestCaseGraphQL public function test_it_can_create_beam_with_transfer_token(): void { $this->genericTestCreateBeam(BeamType::TRANSFER_TOKEN, 1); + + $this->genericTestCreateBeamPack(BeamType::TRANSFER_TOKEN, 1); } /** @@ -46,7 +48,6 @@ public function test_it_can_create_beam_with_file_upload(): void ['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]]] )); $this->assertNotEmpty($response); - Event::assertDispatched(BeamCreated::class); $this->assertEquals(10, Cache::get(BeamService::key($response))); @@ -56,7 +57,24 @@ public function test_it_can_create_beam_with_file_upload(): void ['tokens' => [['tokenIdDataUpload' => $file]]] )); $this->assertNotEmpty($response); + Event::assertDispatched(BeamCreated::class); + $this->assertEquals(1, Cache::get(BeamService::key($response))); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', "1\n2..10"); + $response = $this->graphql($this->method, array_merge( + $this->generateBeamPackData(BeamType::MINT_ON_DEMAND), + ['packs' => [['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]]]]] + )); + $this->assertNotEmpty($response); + Event::assertDispatched(BeamCreated::class); + $this->assertEquals(1, Cache::get(BeamService::key($response))); + $file = UploadedFile::fake()->createWithContent('tokens.txt', "{$this->token->token_chain_id}\n{$this->token->token_chain_id}..{$this->token->token_chain_id}"); + $response = $this->graphql($this->method, array_merge( + $this->generateBeamPackData(BeamType::MINT_ON_DEMAND), + ['packs' => [['tokens' => [['tokenIdDataUpload' => $file]]]]] + )); + $this->assertNotEmpty($response); Event::assertDispatched(BeamCreated::class); $this->assertEquals(1, Cache::get(BeamService::key($response))); } @@ -67,6 +85,8 @@ public function test_it_can_create_beam_with_file_upload(): void public function test_it_can_create_beam_with_mint_on_demand(): void { $this->genericTestCreateBeam(BeamType::MINT_ON_DEMAND, random_int(1, 20)); + + $this->genericTestCreateBeamPack(BeamType::MINT_ON_DEMAND, random_int(1, 20)); } /** @@ -77,6 +97,10 @@ public function test_it_can_create_beam_with_single_use_code(): void $this->genericTestCreateBeam(BeamType::MINT_ON_DEMAND, random_int(1, 20), [], [ ['flag' => 'SINGLE_USE'], ]); + + $this->genericTestCreateBeamPack(BeamType::MINT_ON_DEMAND, random_int(1, 20), [], [ + ['flag' => 'SINGLE_USE'], + ]); } /** @@ -89,6 +113,12 @@ public function test_it_can_create_beam_with_attribute_mint_on_demand(): void random_int(1, 20), [['key' => 'key1', 'value' => 'value1'], ['key' => 'key2', 'value' => 'value2']] ); + + $this->genericTestCreateBeamPack( + BeamType::MINT_ON_DEMAND, + random_int(1, 20), + [['key' => 'key1', 'value' => 'value1'], ['key' => 'key2', 'value' => 'value2']] + ); } /** @@ -115,6 +145,26 @@ public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): voi 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload does not exist in the specified collection.'], ], $response['error']); Event::assertNotDispatched(BeamCreated::class); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', '1'); + $response = $this->graphql($this->method, array_merge( + $this->generateBeamPackData(), + ['packs' => [['tokens' => [['tokenIdDataUpload' => $file]]]]] + ), true); + $this->assertArraySubset([ + 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload does not exist in the specified collection.'], + ], $response['error']); + Event::assertNotDispatched(BeamCreated::class); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', '1..10'); + $response = $this->graphql($this->method, array_merge( + $this->generateBeamPackData(), + ['tokens' => [['tokenIdDataUpload' => $file]]] + ), true); + $this->assertArraySubset([ + 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload does not exist in the specified collection.'], + ], $response['error']); + Event::assertNotDispatched(BeamCreated::class); } /** @@ -150,6 +200,31 @@ public function test_it_will_fail_with_token_exist_in_beam(): void $this->assertArraySubset([ 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge( + $data = $this->generateBeamPackData(BeamType::TRANSFER_TOKEN), + ['packs' => [['tokens' => [['tokenIds' => [$claim->token_chain_id], 'type' => BeamType::TRANSFER_TOKEN->name]]]]] + ), + true + ); + $this->assertArraySubset([ + 'packs.0.tokens.0.tokenIds' => ['The packs.0.tokens.0.tokenIds already exist in beam.'], + ], $response['error']); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', $this->token->token_chain_id); + $response = $this->graphql( + $this->method, + array_merge( + $data, + ['packs' => [['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::TRANSFER_TOKEN->name]]]]] + ), + true + ); + $this->assertArraySubset([ + 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload already exist in beam.'], + ], $response['error']); } /** @@ -171,6 +246,21 @@ public function test_it_will_fail_with_max_length_attribute_mint_on_demand(): vo 'tokens.0.attributes.0.key' => ['The tokens.0.attributes.0.key field must not be greater than 255 characters.'], 'tokens.0.attributes.0.value' => ['The tokens.0.attributes.0.value field must not be greater than 1000 characters.'], ], $response['error']); + + $response = $this->graphql( + $this->method, + $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + random_int(1, 20), + [['key' => Str::random(256), 'value' => Str::random(1001)]] + ), + true + ); + + $this->assertArraySubset([ + 'packs.0.tokens.0.attributes.0.key' => ['The packs.0.tokens.0.attributes.0.key field must not be greater than 255 characters.'], + 'packs.0.tokens.0.attributes.0.value' => ['The packs.0.tokens.0.attributes.0.value field must not be greater than 1000 characters.'], + ], $response['error']); } /** @@ -186,7 +276,6 @@ public function test_it_will_fail_with_empty_params(): void ['message' => 'Variable "$start" of required type "DateTime!" was not provided.'], ['message' => 'Variable "$end" of required type "DateTime!" was not provided.'], ['message' => 'Variable "$collectionId" of required type "BigInt!" was not provided.'], - ['message' => 'Variable "$tokens" of required type "[ClaimToken!]!" was not provided.'], ], $response['errors']); } @@ -222,7 +311,24 @@ public function test_it_will_fail_with_empty_token_ids(): void ['tokens' => []] ), true); - $this->assertArraySubset(['tokens' => ['The tokens field must have at least 1 items.']], $response['error']); + $this->assertArraySubset(['tokens' => ['The tokens field is required when packs is not present.']], $response['error']); + + $response = $this->graphql($this->method, array_merge( + $this->generateBeamData(), + ['packs' => []] + ), true); + + $this->assertArraySubset(['packs' => ['The packs field must have at least 1 items.']], $response['error']); + + $response = $this->graphql($this->method, array_merge( + $this->generateBeamData(), + ['packs' => [], 'tokens' => []] + ), true); + + $this->assertArraySubset([ + 'tokens' => ['The tokens field is required when packs is not present.'], + 'packs' => ['The packs field is required when tokens is not present.'], + ], $response['error']); } /** @@ -297,36 +403,78 @@ public function test_it_will_fail_with_invalid_parameters(): void true ); $this->assertArraySubset(['tokens' => ['There are some duplicate token IDs supplied in the data.']], $response['error']); - } - /** - * Test creating beam with invalid ownership. - */ - public function test_it_will_fail_with_invalid_ownership(): void - { - $this->prepareCollectionData(resolve(SubstrateProvider::class)->public_key()); + + + $data = $this->generateBeamPackData(); $response = $this->graphql( $this->method, - $this->generateBeamData(), + array_merge($data, ['packs' => [['tokens' => [['tokenIds' => '1']]]]]), true ); - $this->assertArraySubset(['collectionId' => ['The collection id provided is not owned by you and you are not currently approved to use it.']], $response['error']); + $this->assertArraySubset(['packs.0.tokens.0.tokenIds' => ['The packs.0.tokens.0.tokenIds does not exist in the specified collection.']], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge($data, ['packs' => [['tokens' => [['tokenIds' => '1..10']]]]]), + true + ); + $this->assertArraySubset(['packs.0.tokens.0.tokenIds' => ['The packs.0.tokens.0.tokenIds does not exist in the specified collection.']], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge($data, ['packs' => [['tokens' => [['tokenIds' => '1'], ['tokenIds' => '1']]]]]), + true + ); + $this->assertArraySubset(['packs.0.tokens' => ['There are some duplicate token IDs supplied in the data.']], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge($data, ['packs' => [['tokens' => [['tokenIds' => '1'], ['tokenIds' => '1..10']]]]]), + true + ); + $this->assertArraySubset(['packs.0.tokens' => ['There are some duplicate token IDs supplied in the data.']], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge($data, ['packs' => [['tokens' => [['tokenIds' => '1..10'], ['tokenIds' => '1']]]]]), + true + ); + $this->assertArraySubset(['packs.0.tokens' => ['There are some duplicate token IDs supplied in the data.']], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge($data, ['packs' => [['tokens' => [['tokenIds' => '1..10'], ['tokenIds' => '5..10']]]]]), + true + ); + $this->assertArraySubset(['packs.0.tokens' => ['There are some duplicate token IDs supplied in the data.']], $response['error']); + + $response = $this->graphql( + $this->method, + array_merge($data, [ + 'packs' => [['tokens' => [['tokenIds' => '1..10']]]], + 'tokens' => [['tokenIds' => '1..10']], + ]), + true + ); + $this->assertArraySubset([ + 'tokens' => ['The tokens field prohibits packs from being present.'], + 'packs' => ['The packs field prohibits tokens from being present.'], + ], $response['error']); } /** - * Test creating beam with invalid token collection. + * Test creating beam with invalid ownership. */ - public function test_it_will_fail_with_invalid_token_collection(): void + public function test_it_will_fail_with_invalid_ownership(): void { - $tokenId = $this->token->token_chain_id; - $this->prepareCollectionData(); - + $this->prepareCollectionData(resolve(SubstrateProvider::class)->public_key()); $response = $this->graphql( $this->method, - array_merge($this->generateBeamData(), ['tokens' => [['tokenIds' => [(string) $tokenId]]]]), + $this->generateBeamData(), true ); - $this->assertArraySubset(['tokens.0.tokenIds' => ['The tokens.0.tokenIds does not exist in the specified collection.']], $response['error']); + $this->assertArraySubset(['collectionId' => ['The collection id provided is not owned by you and you are not currently approved to use it.']], $response['error']); } /** @@ -374,6 +522,16 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void $response['error'] ); + $response = $this->graphql( + $this->method, + $this->generateBeamPackData(BeamType::MINT_ON_DEMAND, 10), + 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, @@ -386,6 +544,12 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void ['tokens.0.tokenQuantityPerClaim' => ['The tokens.0.tokenQuantityPerClaim is invalid, the amount provided is bigger than the token account balance.']], $response['error'] ); + + $response = $this->graphql($this->method, $this->generateBeamPackData(), true); + $this->assertArraySubset( + ['packs.0.tokens.0.tokenQuantityPerClaim' => ['The packs.0.tokens.0.tokenQuantityPerClaim is invalid, the amount provided is bigger than the token account balance.']], + $response['error'] + ); } /** @@ -403,4 +567,19 @@ protected function genericTestCreateBeam(BeamType $type = BeamType::MINT_ON_DEMA $tokenIds = $this->expandRanges(array_column($data['tokens'], 'tokenIds')[0]); $this->assertEquals(count($tokenIds) * $count, Cache::get(BeamService::key($response))); } + + /** + * Generic test for create beam pack. + */ + protected function genericTestCreateBeamPack(BeamType $type = BeamType::MINT_ON_DEMAND, int $count = 1, array $attributes = [], array $singleUse = []): void + { + $this->truncateBeamTables(); + + $response = $this->graphql($this->method, $data = $this->generateBeamPackData($type, $count, $attributes, $singleUse)); + + $this->assertNotEmpty($response); + + Event::assertDispatched(BeamCreated::class); + $this->assertEquals($count, Cache::get(BeamService::key($response))); + } } diff --git a/tests/Feature/GraphQL/Resources/CreateBeam.graphql b/tests/Feature/GraphQL/Resources/CreateBeam.graphql index 0fc6927..3e9f5f9 100644 --- a/tests/Feature/GraphQL/Resources/CreateBeam.graphql +++ b/tests/Feature/GraphQL/Resources/CreateBeam.graphql @@ -5,7 +5,8 @@ mutation CreateBeam( $start: DateTime! $end: DateTime! $collectionId: BigInt! - $tokens: [ClaimToken!]! + $tokens: [ClaimToken!] + $packs: [BeamPackInput!] $flags: [BeamFlagInputType!] ) { CreateBeam( @@ -16,6 +17,7 @@ mutation CreateBeam( end: $end collectionId: $collectionId tokens: $tokens + packs: $packs flags: $flags ) } diff --git a/tests/Feature/Traits/CreateBeamData.php b/tests/Feature/Traits/CreateBeamData.php index 35e2b1f..2b22022 100644 --- a/tests/Feature/Traits/CreateBeamData.php +++ b/tests/Feature/Traits/CreateBeamData.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Enjin\Platform\Beam\Enums\BeamType; +use Illuminate\Support\Collection; trait CreateBeamData { @@ -31,4 +32,30 @@ protected function generateBeamData(BeamType $type = BeamType::TRANSFER_TOKEN, i ]], ]; } + + /** + * Generate beam pack data. + */ + protected function generateBeamPackData(BeamType $type = BeamType::TRANSFER_TOKEN, int $count = 1, array $attributes = [], array $flags = []): array + { + $data = [ + 'name' => fake()->name(), + 'description' => fake()->word(), + 'image' => fake()->url(), + 'start' => Carbon::now()->toDateTimeString(), + 'end' => Carbon::now()->addDays(random_int(1, 1000))->toDateTimeString(), + 'collectionId' => $this->collection->collection_chain_id, + 'flags' => $flags, + 'packs' => Collection::times($count, fn () => ['tokens' => [[ + 'type' => $type->name, + 'tokenIds' => $type == BeamType::TRANSFER_TOKEN + ? [(string) $this->token->token_chain_id] + : [(string) fake()->numberBetween(100, 1000), fake()->numberBetween(0, 10) . '..' . fake()->numberBetween(11, 20)], + 'tokenQuantityPerClaim' => random_int(1, $count), + 'attributes' => $attributes ?: null, + ]]])->all(), + ]; + + return $data; + } } From 467d081fd38f209427f5cf2535e76650f2a6f880 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Fri, 2 Aug 2024 16:24:33 +0800 Subject: [PATCH 02/24] Update beam pack --- src/GraphQL/Mutations/CreateBeamMutation.php | 2 +- src/GraphQL/Mutations/UpdateBeamMutation.php | 78 ++------ src/GraphQL/Traits/HasTokenInputRules.php | 18 +- src/Services/BeamService.php | 13 +- .../GraphQL/Mutations/UpdateBeamTest.php | 169 ++++++++++++------ .../GraphQL/Resources/UpdateBeam.graphql | 2 + tests/Feature/Traits/CreateBeamData.php | 25 ++- tests/Feature/Traits/SeedBeamData.php | 30 ++++ 8 files changed, 199 insertions(+), 138 deletions(-) diff --git a/src/GraphQL/Mutations/CreateBeamMutation.php b/src/GraphQL/Mutations/CreateBeamMutation.php index 058970f..439ca0f 100644 --- a/src/GraphQL/Mutations/CreateBeamMutation.php +++ b/src/GraphQL/Mutations/CreateBeamMutation.php @@ -104,7 +104,7 @@ function (string $attribute, mixed $value, Closure $fail) { new IsCollectionOwnerOrApproved(), ], 'flags.*.flag' => ['required', 'distinct'], - ...$this->tokenRules($args), + ...$this->tokenRules($args, $args['collectionId']), ...$this->packTokenRules($args, $args['collectionId']), ]; } diff --git a/src/GraphQL/Mutations/UpdateBeamMutation.php b/src/GraphQL/Mutations/UpdateBeamMutation.php index a0994d5..74a4a6c 100644 --- a/src/GraphQL/Mutations/UpdateBeamMutation.php +++ b/src/GraphQL/Mutations/UpdateBeamMutation.php @@ -3,33 +3,23 @@ namespace Enjin\Platform\Beam\GraphQL\Mutations; use Closure; -use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\GraphQL\Traits\HasBeamCommonFields; +use Enjin\Platform\Beam\GraphQL\Traits\HasTokenInputRules; use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Rules\BeamExists; use Enjin\Platform\Beam\Rules\IsEndDateValid; use Enjin\Platform\Beam\Rules\IsStartDateValid; -use Enjin\Platform\Beam\Rules\MaxTokenCount; -use Enjin\Platform\Beam\Rules\MaxTokenSupply; -use Enjin\Platform\Beam\Rules\TokensDoNotExistInBeam; -use Enjin\Platform\Beam\Rules\TokensDoNotExistInCollection; -use Enjin\Platform\Beam\Rules\TokensExistInCollection; -use Enjin\Platform\Beam\Rules\TokenUploadExistInCollection; -use Enjin\Platform\Beam\Rules\TokenUploadNotExistInBeam; -use Enjin\Platform\Beam\Rules\TokenUploadNotExistInCollection; -use Enjin\Platform\Beam\Rules\UniqueTokenIds; use Enjin\Platform\Beam\Services\BeamService; -use Enjin\Platform\Rules\DistinctAttributes; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\Rule; use Rebing\GraphQL\Support\Facades\GraphQL; class UpdateBeamMutation extends Mutation { use HasBeamCommonFields; + use HasTokenInputRules; /** * Get the mutation's attributes. @@ -69,6 +59,10 @@ public function args(): array 'type' => GraphQL::type('[ClaimToken!]'), 'description' => __('enjin-platform-beam::input_type.claim_token.description'), ], + 'packs' => [ + 'type' => GraphQL::type('[BeamPackInput!]'), + 'description' => __('enjin-platform-beam::input_type.beam_pack.description'), + ], ]; } @@ -111,61 +105,11 @@ protected function rules(array $args = []): array 'flags.*.flag' => ['required', 'distinct'], 'start' => ['filled', 'date', new IsStartDateValid()], 'end' => ['filled', 'date', new IsEndDateValid()], - 'tokens' => ['bail', 'array', 'min:1', new UniqueTokenIds()], - 'tokens.*.attributes' => Rule::forEach(function ($value, $attribute) use ($args) { - if (empty($value)) { - return []; - } - - return [ - 'nullable', - 'bail', - 'array', - 'min:1', - 'max:10', - new DistinctAttributes(), - Rule::prohibitedIf(BeamType::getEnumCase(Arr::get($args, str_replace('attributes', 'type', $attribute))) == BeamType::TRANSFER_TOKEN), - ]; - }), - 'tokens.*.attributes.*.key' => 'max:255', - 'tokens.*.attributes.*.value' => 'max:1000', - 'tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args, $beam) { - return [ - 'bail', - 'required_without:tokens.*.tokenIdDataUpload', - 'prohibits:tokens.*.tokenIdDataUpload', - 'distinct', - BeamType::getEnumCase(Arr::get($args, str_replace('tokenIds', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokensExistInCollection($beam?->collection_chain_id) - : new TokensDoNotExistInCollection($beam?->collection_chain_id), - new TokensDoNotExistInBeam($beam), - ]; - }), - 'tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args, $beam) { - return [ - 'bail', - 'required_without:tokens.*.tokenIds', - 'prohibits:tokens.*.tokenIds', - BeamType::getEnumCase(Arr::get($args, str_replace('tokenIdDataUpload', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokenUploadExistInCollection($beam?->collection_chain_id) - : new TokenUploadNotExistInCollection($beam?->collection_chain_id), - new TokenUploadNotExistInBeam($beam), - ]; - }), - 'tokens.*.tokenQuantityPerClaim' => [ - 'bail', - 'filled', - 'integer', - 'min:1', - new MaxTokenSupply($beam?->collection_chain_id), - ], - 'tokens.*.claimQuantity' => [ - 'bail', - 'filled', - 'integer', - 'min:1', - new MaxTokenCount($beam?->collection_chain_id), - ], + ...match (true) { + !$beam => [], + !$beam?->is_pack => $this->tokenRules($args, $beam?->collection_chain_id, false), + default => $this->packTokenRules($args, $beam?->collection_chain_id, false), + }, ]; } } diff --git a/src/GraphQL/Traits/HasTokenInputRules.php b/src/GraphQL/Traits/HasTokenInputRules.php index 4d7f8df..68de02d 100644 --- a/src/GraphQL/Traits/HasTokenInputRules.php +++ b/src/GraphQL/Traits/HasTokenInputRules.php @@ -17,12 +17,12 @@ trait HasTokenInputRules { - public function tokenRules(array $args, bool $withPacks = true): array + public function tokenRules(array $args, ?string $collectionId = null, bool $withPacks = true): array { return [ 'tokens' => [ 'bail', - ...($withPacks ? ['required_without:packs', 'prohibits:packs'] : []), + ...($withPacks ? ['required_without:packs', 'prohibits:packs'] : ['prohibits:packs']), 'array', 'min:1', 'max:1000', @@ -45,25 +45,25 @@ public function tokenRules(array $args, bool $withPacks = true): array }), 'tokens.*.attributes.*.key' => 'max:255', 'tokens.*.attributes.*.value' => 'max:1000', - 'tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args) { + 'tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args, $collectionId) { return [ 'bail', 'required_without:tokens.*.tokenIdDataUpload', 'prohibits:tokens.*.tokenIdDataUpload', 'distinct', BeamType::getEnumCase(Arr::get($args, str_replace('tokenIds', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokensExistInCollection($args['collectionId']) + ? new TokensExistInCollection($collectionId) : '', new TokensDoNotExistInBeam(), ]; }), - 'tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args) { + 'tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args, $collectionId) { return [ 'bail', 'required_without:tokens.*.tokenIds', 'prohibits:tokens.*.tokenIds', BeamType::getEnumCase(Arr::get($args, str_replace('tokenIdDataUpload', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokenUploadExistInCollection($args['collectionId']) + ? new TokenUploadExistInCollection($collectionId) : '', new TokenUploadNotExistInBeam(), ]; @@ -73,14 +73,14 @@ public function tokenRules(array $args, bool $withPacks = true): array 'filled', 'integer', 'min:1', - new MaxTokenSupply($args['collectionId']), + new MaxTokenSupply($collectionId), ], 'tokens.*.claimQuantity' => [ 'bail', 'filled', 'integer', 'min:1', - new MaxTokenCount($args['collectionId']), + new MaxTokenCount($collectionId), ], ]; } @@ -90,7 +90,7 @@ public function packTokenRules(array $args, ?string $collectionId = null, bool $ return [ 'packs' => [ 'bail', - ...($withTokens ? ['required_without:tokens', 'prohibits:tokens'] : []), + ...($withTokens ? ['required_without:tokens', 'prohibits:tokens'] : ['prohibits:tokens']), 'array', 'min:1', 'max:1000', diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index ad27f26..34d37a6 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -113,6 +113,7 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) } $quantity = 0; + $allTokenIds = []; foreach ($packs as $pack) { if (!($id = Arr::get($pack, 'id'))) { $quantity++; @@ -128,6 +129,7 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) $tokenIds = $tokens->whereNotNull('tokenIds'); if ($tokenIds->count()) { + $allTokenIds = $tokenIds->pluck('tokenIds')->flatten()->all(); DispatchCreateBeamClaimsJobs::dispatch($beam, $tokenIds->all(), $model->id)->afterCommit(); } @@ -151,6 +153,7 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) unset($tokenIds, $token); }); }); + $allTokenIds = $ids->pluck('tokenIds')->flatten()->all(); } } @@ -163,7 +166,7 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) event: [ 'beamCode' => $beam->code, 'code' => $beam->code, - 'tokenIds' => collect($packs)->map(fn ($pack) => $pack['tokens'])->flatten()->pluck('tokenIds')->all(), + 'tokenIds' => $allTokenIds, ] ); @@ -182,10 +185,12 @@ public function updateByCode(string $code, array $values): Model } if ($beam->fill($values)->save()) { - if ($tokens = Arr::get($values, 'tokens', [])) { + if ($beam->is_pack && ($packs = Arr::get($values, 'packs', []))) { + $this->createPackClaims($beam, $packs, false); + } elseif ($tokens = Arr::get($values, 'tokens', [])) { Cache::increment( self::key($beam->code), - $this->createClaims($tokens, $beam) + $this->createClaims($beam, $tokens) ); TokensAdded::safeBroadcast(event: ['beamCode' => $beam->code, 'code' => $code, 'tokenIds' => collect($tokens)->pluck('tokenIds')->all()]); } @@ -205,7 +210,7 @@ public function addTokens(string $code, array $tokens): bool $beam = Beam::whereCode($code)->firstOrFail(); Cache::increment( self::key($beam->code), - $this->createClaims($tokens, $beam) + $this->createClaims($beam, $tokens) ); TokensAdded::safeBroadcast(event: ['beamCode' => $beam->code, 'code' => $code, 'tokenIds' => collect($tokens)->pluck('tokenIds')->all()]); diff --git a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php index 071414a..5ad36ab 100644 --- a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php @@ -10,6 +10,7 @@ use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Enjin\Platform\Beam\Services\BeamService; use Enjin\Platform\Beam\Tests\Feature\GraphQL\TestCaseGraphQL; +use Enjin\Platform\Beam\Tests\Feature\Traits\CreateBeamData; use Enjin\Platform\Beam\Tests\Feature\Traits\SeedBeamData; use Enjin\Platform\Enums\Substrate\TokenMintCapType; use Enjin\Platform\GraphQL\Types\Scalars\Traits\HasIntegerRanges; @@ -23,6 +24,10 @@ class UpdateBeamTest extends TestCaseGraphQL { + use CreateBeamData { + generateBeamData as protected parentGenerateBeamData; + generateBeamPackData as protected parentGenerateBeamPackData; + } use HasIntegerRanges; use IntegerRange; use SeedBeamData; @@ -49,11 +54,12 @@ public function test_it_can_update_beam(): void Event::fake(); $response = $this->graphql($this->method, $updates = $this->generateBeamData(BeamType::MINT_ON_DEMAND)); $this->assertTrue($response); + Event::assertDispatched(BeamUpdated::class); Event::assertDispatched(TokensAdded::class); $this->beam->refresh(); $this->assertEquals( - $expected = Arr::except($updates, ['tokens']), + $expected = Arr::except($updates, ['tokens', 'collectionId']), $this->beam->only(array_keys($expected)) ); @@ -69,6 +75,16 @@ public function test_it_can_update_beam(): void }, 10); }); $this->assertEquals($totalClaims, Cache::get(BeamService::key($this->beam->code))); + + $this->seedBeamPack(1); + $response = $this->graphql( + $this->method, + $updates = $this->generateBeamPackData(BeamType::MINT_ON_DEMAND, $count = random_int(1, 10)) + ); + $this->assertTrue($response); + Event::assertDispatched(BeamUpdated::class); + Event::assertDispatched(TokensAdded::class); + $this->assertEquals(1 + $count, Cache::get(BeamService::key($this->beam->code))); } /** @@ -89,6 +105,23 @@ public function test_it_can_update_beam_with_attributes(): void ) ); $this->assertTrue($response); + Event::assertDispatched(BeamUpdated::class); + Event::assertDispatched(TokensAdded::class); + + $this->seedBeamPack(1); + $response = $this->graphql( + $this->method, + $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 1, + [ + ['key' => 'test', 'value' => 'test'], + ['key' => 'test2', 'value' => 'test2'], + ] + ) + ); + $this->assertTrue($response); + Event::assertDispatched(BeamUpdated::class); Event::assertDispatched(TokensAdded::class); } @@ -97,6 +130,7 @@ public function test_it_can_update_beam_with_attributes(): void */ public function test_it_can_update_beam_with_file_upload(): void { + Event::fake(); $file = UploadedFile::fake()->createWithContent('tokens.txt', "1\n2..10"); $response = $this->graphql($this->method, array_merge( $this->generateBeamData(BeamType::MINT_ON_DEMAND), @@ -104,6 +138,7 @@ public function test_it_can_update_beam_with_file_upload(): void )); $this->assertNotEmpty($response); Event::assertDispatched(BeamUpdated::class); + Event::assertDispatched(TokensAdded::class); $file = UploadedFile::fake()->createWithContent('tokens.txt', "{$this->token->token_chain_id}\n{$this->token->token_chain_id}..{$this->token->token_chain_id}"); $response = $this->graphql($this->method, array_merge( @@ -112,6 +147,26 @@ public function test_it_can_update_beam_with_file_upload(): void )); $this->assertNotEmpty($response); Event::assertDispatched(BeamUpdated::class); + Event::assertDispatched(TokensAdded::class); + + $this->seedBeamPack(1); + $file = UploadedFile::fake()->createWithContent('tokens.txt', "1\n2..10"); + $response = $this->graphql($this->method, array_merge( + $data = Arr::except($this->generateBeamPackData(BeamType::MINT_ON_DEMAND), ['start']), + ['packs' => [['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]]]]] + )); + $this->assertNotEmpty($response); + Event::assertDispatched(BeamUpdated::class); + Event::assertDispatched(TokensAdded::class); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', "{$this->token->token_chain_id}\n{$this->token->token_chain_id}..{$this->token->token_chain_id}"); + $response = $this->graphql($this->method, array_merge( + $data, + ['packs' => [['tokens' => [['tokenIdDataUpload' => $file]]]]] + )); + $this->assertNotEmpty($response); + Event::assertDispatched(BeamUpdated::class); + Event::assertDispatched(TokensAdded::class); } /** @@ -147,33 +202,42 @@ public function test_it_will_fail_with_token_exist_in_beam(): void $this->assertArraySubset([ 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); - } - /** - * Test updating beam with file upload. - */ - public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): void - { - $file = UploadedFile::fake()->createWithContent('tokens.txt', $this->token->token_chain_id); - $response = $this->graphql($this->method, array_merge( - $this->generateBeamData(), - ['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]]] - ), true); + + $this->seedBeamPack(); + $claim = $this->claims->first(); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [['tokenIds' => [$claim->token_chain_id], 'type' => BeamType::TRANSFER_TOKEN->name]]]], + ], + true + ); $this->assertArraySubset([ - 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload exists in the specified collection.'], + 'packs.0.tokens.0.tokenIds' => ['The packs.0.tokens.0.tokenIds already exist in beam.'], ], $response['error']); - Event::assertNotDispatched(BeamUpdated::class); - $file = UploadedFile::fake()->createWithContent('tokens.txt', "{$this->token->token_chain_id}..{$this->token->token_chain_id}"); - $response = $this->graphql($this->method, array_merge( - $this->generateBeamData(), - ['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]]] - ), true); + $file = UploadedFile::fake()->createWithContent('tokens.txt', $claim->token_chain_id); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::TRANSFER_TOKEN->name]]]], + ], + true + ); $this->assertArraySubset([ - 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload exists in the specified collection.'], + 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); - Event::assertNotDispatched(BeamUpdated::class); + } + + /** + * Test updating beam with file upload. + */ + public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): void + { $file = UploadedFile::fake()->createWithContent('tokens.txt', '1'); $response = $this->graphql($this->method, array_merge( $this->generateBeamData(), @@ -246,16 +310,6 @@ public function test_it_will_fail_existing_tokens(): void $response['error'] ); - $updates = array_merge( - $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 exists in the specified collection.']], - $response['error'] - ); - $collection = Collection::create([ 'collection_chain_id' => (string) fake()->unique()->numberBetween(2000), 'owner_wallet_id' => $this->wallet->id, @@ -473,27 +527,40 @@ public function test_it_will_fail_with_invalid_tokens(): void ); } - /** - * Generate beam data. - */ protected function generateBeamData(BeamType $type = BeamType::TRANSFER_TOKEN, int $count = 1, array $attributes = [], array $singleUse = []): array { - return [ - 'code' => $this->beam->code, - 'name' => 'Updated', - 'description' => 'Updated', - 'image' => fake()->url(), - 'start' => Carbon::now()->addDays(10)->toDateTimeString(), - 'end' => Carbon::now()->addDays(20)->toDateTimeString(), - 'tokens' => [[ - 'type' => $type->name, - 'tokenIds' => $type == BeamType::TRANSFER_TOKEN - ? [(string) $this->token->token_chain_id] - : [(string) fake()->unique()->numberBetween(100, 10000), fake()->unique()->numberBetween(0, 10) . '..' . fake()->unique()->numberBetween(11, 20)], - 'tokenQuantityPerClaim' => random_int(1, $count), - 'claimQuantity' => $count, - 'attributes' => $attributes ?: null, - ]], - ]; + return $this->parentGenerateBeamData( + $type, + $count, + $attributes, + $singleUse, + [ + 'code' => $this->beam->code, + 'name' => 'Updated', + 'description' => 'Updated', + 'collectionId' => $this->beam->collection_chain_id, + ] + ); + } + + protected function generateBeamPackData( + BeamType $type = BeamType::TRANSFER_TOKEN, + int $count = 1, + array $attributes = [], + array $flags = [], + array $extra = [] + ): array { + return $this->parentGenerateBeamPackData( + $type, + $count, + $attributes, + $flags, + [ + 'code' => $this->beam->code, + 'name' => 'Updated', + 'description' => 'Updated', + 'collectionId' => $this->beam->collection_chain_id, + ] + ); } } diff --git a/tests/Feature/GraphQL/Resources/UpdateBeam.graphql b/tests/Feature/GraphQL/Resources/UpdateBeam.graphql index f28311f..6e6a1c2 100644 --- a/tests/Feature/GraphQL/Resources/UpdateBeam.graphql +++ b/tests/Feature/GraphQL/Resources/UpdateBeam.graphql @@ -7,6 +7,7 @@ mutation UpdateBeam( $end: DateTime $flags: [BeamFlagInputType!] $tokens: [ClaimToken!] + $packs: [BeamPackInput!] ) { UpdateBeam( code: $code @@ -17,5 +18,6 @@ mutation UpdateBeam( end: $end flags: $flags tokens: $tokens + packs: $packs ) } diff --git a/tests/Feature/Traits/CreateBeamData.php b/tests/Feature/Traits/CreateBeamData.php index 2b22022..4b21958 100644 --- a/tests/Feature/Traits/CreateBeamData.php +++ b/tests/Feature/Traits/CreateBeamData.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Enjin\Platform\Beam\Enums\BeamType; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; trait CreateBeamData @@ -11,14 +12,19 @@ trait CreateBeamData /** * Generate beam data. */ - protected function generateBeamData(BeamType $type = BeamType::TRANSFER_TOKEN, int $count = 1, array $attributes = [], array $singleUse = []): array - { + protected function generateBeamData( + BeamType $type = BeamType::TRANSFER_TOKEN, + int $count = 1, + array $attributes = [], + array $singleUse = [], + array $extra = [], + ): array { return [ 'name' => fake()->name(), 'description' => fake()->word(), 'image' => fake()->url(), - 'start' => Carbon::now()->toDateTimeString(), - 'end' => Carbon::now()->addDays(random_int(1, 1000))->toDateTimeString(), + 'start' => Carbon::now()->addDays(Arr::get($extra, 'code') ? 20 : 0)->toDateTimeString(), + 'end' => Carbon::now()->addDays(random_int(100, 1000))->toDateTimeString(), 'collectionId' => $this->collection->collection_chain_id, 'flags' => $singleUse, 'tokens' => [[ @@ -30,14 +36,20 @@ protected function generateBeamData(BeamType $type = BeamType::TRANSFER_TOKEN, i 'claimQuantity' => $count, 'attributes' => $attributes ?: null, ]], + ...$extra, ]; } /** * Generate beam pack data. */ - protected function generateBeamPackData(BeamType $type = BeamType::TRANSFER_TOKEN, int $count = 1, array $attributes = [], array $flags = []): array - { + protected function generateBeamPackData( + BeamType $type = BeamType::TRANSFER_TOKEN, + int $count = 1, + array $attributes = [], + array $flags = [], + array $extra = [] + ): array { $data = [ 'name' => fake()->name(), 'description' => fake()->word(), @@ -54,6 +66,7 @@ protected function generateBeamPackData(BeamType $type = BeamType::TRANSFER_TOKE 'tokenQuantityPerClaim' => random_int(1, $count), 'attributes' => $attributes ?: null, ]]])->all(), + ...$extra, ]; return $data; diff --git a/tests/Feature/Traits/SeedBeamData.php b/tests/Feature/Traits/SeedBeamData.php index 7fa09d6..55f8b60 100644 --- a/tests/Feature/Traits/SeedBeamData.php +++ b/tests/Feature/Traits/SeedBeamData.php @@ -7,6 +7,7 @@ use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Models\BeamBatch; use Enjin\Platform\Beam\Models\BeamClaim; +use Enjin\Platform\Beam\Models\BeamPack; use Enjin\Platform\Beam\Models\BeamScan; use Enjin\Platform\Beam\Services\BeamService; use Illuminate\Database\Eloquent\Model; @@ -52,6 +53,35 @@ public function seedBeam(?int $claimsCount = null, bool $isClaimed = false, ?Bea Cache::remember(BeamService::key($this->beam->code), 3600, fn () => count($this->claims)); } + /** + * Seed beam claim data. + */ + public function seedBeamPack(?int $claimsCount = null, ?BeamType $type = null, array $beam = []): void + { + + $this->beam = Beam::factory([ + 'collection_chain_id' => $this->collection->collection_chain_id, + 'is_pack' => true, + ...$beam, + ])->create(); + + Collection::times($claimsCount ?: 1, function () use ($type) { + $this->claims = BeamClaim::factory() + ->count(random_int(1, 10)) + ->for(BeamPack::factory()->state(['beam_id' => $this->beam])) + ->create([ + 'collection_id' => $this->collection->id, + 'wallet_public_key' => null, + 'claimed_at' => null, + 'state' => null, + 'beam_id' => $this->beam->id, + ...($type ? ['type' => $type->name] : []), + ]); + }); + + Cache::remember(BeamService::key($this->beam->code), 3600, fn () => $claimsCount); + } + /** * Claim all beam. */ From b50dc3b11b30177ce9d77b4790342d02c33fa93c Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Fri, 2 Aug 2024 16:24:41 +0800 Subject: [PATCH 03/24] fix --- tests/Feature/GraphQL/Mutations/UpdateBeamTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php index 5ad36ab..96f8991 100644 --- a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php @@ -172,7 +172,7 @@ public function test_it_can_update_beam_with_file_upload(): void /** * Test updating beam token exist in beam. */ - public function test_it_will_fail_with_token_exist_in_beam(): void + /*public function test_it_will_fail_with_token_exist_in_beam(): void { $this->collection->update(['max_token_supply' => 1]); $this->seedBeam(1, false, BeamType::TRANSFER_TOKEN); @@ -231,7 +231,7 @@ public function test_it_will_fail_with_token_exist_in_beam(): void 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); - } + }*/ /** * Test updating beam with file upload. From befef64f45eb0bd98890e28cf761db3011808759 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 13:11:24 +0800 Subject: [PATCH 04/24] Add tokens pack --- src/GraphQL/Mutations/AddTokensMutation.php | 88 +++-------- src/GraphQL/Mutations/CreateBeamMutation.php | 4 +- src/GraphQL/Mutations/UpdateBeamMutation.php | 4 +- src/GraphQL/Traits/HasTokenInputRules.php | 4 +- src/Services/BeamService.php | 19 ++- .../GraphQL/Mutations/AddTokensTest.php | 141 +++++++++++++++--- .../GraphQL/Resources/AddTokens.graphql | 8 +- 7 files changed, 162 insertions(+), 106 deletions(-) diff --git a/src/GraphQL/Mutations/AddTokensMutation.php b/src/GraphQL/Mutations/AddTokensMutation.php index 3b5e3cc..8c89dfa 100644 --- a/src/GraphQL/Mutations/AddTokensMutation.php +++ b/src/GraphQL/Mutations/AddTokensMutation.php @@ -3,31 +3,21 @@ namespace Enjin\Platform\Beam\GraphQL\Mutations; use Closure; -use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\GraphQL\Traits\HasBeamCommonFields; +use Enjin\Platform\Beam\GraphQL\Traits\HasTokenInputRules; use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Rules\BeamExists; -use Enjin\Platform\Beam\Rules\MaxTokenCount; -use Enjin\Platform\Beam\Rules\MaxTokenSupply; -use Enjin\Platform\Beam\Rules\TokensDoNotExistInBeam; -use Enjin\Platform\Beam\Rules\TokensDoNotExistInCollection; -use Enjin\Platform\Beam\Rules\TokensExistInCollection; -use Enjin\Platform\Beam\Rules\TokenUploadExistInCollection; -use Enjin\Platform\Beam\Rules\TokenUploadNotExistInBeam; -use Enjin\Platform\Beam\Rules\TokenUploadNotExistInCollection; -use Enjin\Platform\Beam\Rules\UniqueTokenIds; use Enjin\Platform\Beam\Services\BeamService; -use Enjin\Platform\Rules\DistinctAttributes; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\Rule; use Rebing\GraphQL\Support\Facades\GraphQL; class AddTokensMutation extends Mutation { use HasBeamCommonFields; + use HasTokenInputRules; /** * Get the mutation's attributes. @@ -59,9 +49,13 @@ public function args(): array 'description' => __('enjin-platform-beam::mutation.claim_beam.args.code'), ], 'tokens' => [ - 'type' => GraphQL::type('[ClaimToken!]!'), + 'type' => GraphQL::type('[ClaimToken!]'), 'description' => __('enjin-platform-beam::input_type.claim_token.description'), ], + 'packs' => [ + 'type' => GraphQL::type('[BeamPackInput!]'), + 'description' => __('enjin-platform-beam::input_type.beam_pack.description'), + ], ]; } @@ -76,7 +70,13 @@ public function resolve( Closure $getSelectFields, BeamService $beam ) { - return DB::transaction(fn () => $beam->addTokens($args['code'], $args['tokens'])); + return DB::transaction( + fn () => $beam->addTokens( + $args['code'], + Arr::get($args, 'tokens', []), + Arr::get($args, 'packs', []) + ) + ); } /** @@ -92,61 +92,11 @@ protected function rules(array $args = []): array 'max:1024', new BeamExists(), ], - 'tokens' => ['bail', 'array', 'min:1', new UniqueTokenIds()], - 'tokens.*.attributes' => Rule::forEach(function ($value, $attribute) use ($args) { - if (empty($value)) { - return []; - } - - return [ - 'nullable', - 'bail', - 'array', - 'min:1', - 'max:10', - new DistinctAttributes(), - Rule::prohibitedIf(BeamType::getEnumCase(Arr::get($args, str_replace('attributes', 'type', $attribute))) == BeamType::TRANSFER_TOKEN), - ]; - }), - 'tokens.*.attributes.*.key' => 'max:255', - 'tokens.*.attributes.*.value' => 'max:1000', - 'tokens.*.tokenIds' => Rule::forEach(function ($value, $attribute) use ($args, $beam) { - return [ - 'bail', - 'required_without:tokens.*.tokenIdDataUpload', - 'prohibits:tokens.*.tokenIdDataUpload', - 'distinct', - BeamType::getEnumCase(Arr::get($args, str_replace('tokenIds', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokensExistInCollection($beam?->collection_chain_id) - : new TokensDoNotExistInCollection($beam?->collection_chain_id), - new TokensDoNotExistInBeam($beam), - ]; - }), - 'tokens.*.tokenIdDataUpload' => Rule::forEach(function ($value, $attribute) use ($args, $beam) { - return [ - 'bail', - 'required_without:tokens.*.tokenIds', - 'prohibits:tokens.*.tokenIds', - BeamType::getEnumCase(Arr::get($args, str_replace('tokenIdDataUpload', 'type', $attribute))) == BeamType::TRANSFER_TOKEN - ? new TokenUploadExistInCollection($beam?->collection_chain_id) - : new TokenUploadNotExistInCollection($beam?->collection_chain_id), - new TokenUploadNotExistInBeam($beam), - ]; - }), - 'tokens.*.tokenQuantityPerClaim' => [ - 'bail', - 'filled', - 'integer', - 'min:1', - new MaxTokenSupply($beam?->collection_chain_id), - ], - 'tokens.*.claimQuantity' => [ - 'bail', - 'filled', - 'integer', - 'min:1', - new MaxTokenCount($beam?->collection_chain_id), - ], + ...match (true) { + !$beam => [], + !$beam?->is_pack => $this->tokenRules($args, $beam?->collection_chain_id), + default => $this->packTokenRules($args, $beam?->collection_chain_id), + }, ]; } } diff --git a/src/GraphQL/Mutations/CreateBeamMutation.php b/src/GraphQL/Mutations/CreateBeamMutation.php index 439ca0f..0f35b10 100644 --- a/src/GraphQL/Mutations/CreateBeamMutation.php +++ b/src/GraphQL/Mutations/CreateBeamMutation.php @@ -104,8 +104,8 @@ function (string $attribute, mixed $value, Closure $fail) { new IsCollectionOwnerOrApproved(), ], 'flags.*.flag' => ['required', 'distinct'], - ...$this->tokenRules($args, $args['collectionId']), - ...$this->packTokenRules($args, $args['collectionId']), + ...$this->tokenRules($args, $args['collectionId'], true), + ...$this->packTokenRules($args, $args['collectionId'], true), ]; } } diff --git a/src/GraphQL/Mutations/UpdateBeamMutation.php b/src/GraphQL/Mutations/UpdateBeamMutation.php index 74a4a6c..a08ca6f 100644 --- a/src/GraphQL/Mutations/UpdateBeamMutation.php +++ b/src/GraphQL/Mutations/UpdateBeamMutation.php @@ -107,8 +107,8 @@ protected function rules(array $args = []): array 'end' => ['filled', 'date', new IsEndDateValid()], ...match (true) { !$beam => [], - !$beam?->is_pack => $this->tokenRules($args, $beam?->collection_chain_id, false), - default => $this->packTokenRules($args, $beam?->collection_chain_id, false), + !$beam?->is_pack => $this->tokenRules($args, $beam?->collection_chain_id), + default => $this->packTokenRules($args, $beam?->collection_chain_id), }, ]; } diff --git a/src/GraphQL/Traits/HasTokenInputRules.php b/src/GraphQL/Traits/HasTokenInputRules.php index 68de02d..82b4f41 100644 --- a/src/GraphQL/Traits/HasTokenInputRules.php +++ b/src/GraphQL/Traits/HasTokenInputRules.php @@ -17,7 +17,7 @@ trait HasTokenInputRules { - public function tokenRules(array $args, ?string $collectionId = null, bool $withPacks = true): array + public function tokenRules(array $args, ?string $collectionId = null, bool $withPacks = false): array { return [ 'tokens' => [ @@ -85,7 +85,7 @@ public function tokenRules(array $args, ?string $collectionId = null, bool $with ]; } - public function packTokenRules(array $args, ?string $collectionId = null, bool $withTokens = true): array + public function packTokenRules(array $args, ?string $collectionId = null, bool $withTokens = false): array { return [ 'packs' => [ diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 34d37a6..ca95b8f 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -186,7 +186,7 @@ public function updateByCode(string $code, array $values): Model if ($beam->fill($values)->save()) { if ($beam->is_pack && ($packs = Arr::get($values, 'packs', []))) { - $this->createPackClaims($beam, $packs, false); + $this->createPackClaims($beam, $packs); } elseif ($tokens = Arr::get($values, 'tokens', [])) { Cache::increment( self::key($beam->code), @@ -205,14 +205,19 @@ public function updateByCode(string $code, array $values): Model /** * Update beam by code. */ - public function addTokens(string $code, array $tokens): bool + public function addTokens(string $code, ?array $tokens = [], ?array $packs = []): bool { $beam = Beam::whereCode($code)->firstOrFail(); - Cache::increment( - self::key($beam->code), - $this->createClaims($beam, $tokens) - ); - TokensAdded::safeBroadcast(event: ['beamCode' => $beam->code, 'code' => $code, 'tokenIds' => collect($tokens)->pluck('tokenIds')->all()]); + + if ($beam->is_pack && $packs) { + $this->createPackClaims($beam, $packs); + } elseif ($tokens) { + Cache::increment( + self::key($beam->code), + $this->createClaims($beam, $tokens) + ); + TokensAdded::safeBroadcast(event: ['beamCode' => $beam->code, 'code' => $code, 'tokenIds' => collect($tokens)->pluck('tokenIds')->all()]); + } return true; } diff --git a/tests/Feature/GraphQL/Mutations/AddTokensTest.php b/tests/Feature/GraphQL/Mutations/AddTokensTest.php index 13a6706..739f858 100644 --- a/tests/Feature/GraphQL/Mutations/AddTokensTest.php +++ b/tests/Feature/GraphQL/Mutations/AddTokensTest.php @@ -44,6 +44,17 @@ public function test_it_add_tokens(): void ); $this->assertTrue($response); Event::assertDispatched(TokensAdded::class); + + $this->seedBeamPack(); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [['tokenIds' => ['1..5'], 'type' => BeamType::MINT_ON_DEMAND->name]]]], + ] + ); + $this->assertTrue($response); + Event::assertDispatched(TokensAdded::class); } public function test_it_can_add_token_with_attributes(): void @@ -67,10 +78,31 @@ public function test_it_can_add_token_with_attributes(): void ); $this->assertTrue($response); Event::assertDispatched(TokensAdded::class); + + $this->seedBeamPack(); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [ + [ + 'tokenIds' => ['1..5'], + 'type' => BeamType::MINT_ON_DEMAND->name, + 'attributes' => [ + ['key' => 'test', 'value' => 'test'], + ['key' => 'test2', 'value' => 'test2'], + ], + ], + ], + ]]], + ); + $this->assertTrue($response); + Event::assertDispatched(TokensAdded::class); } public function test_it_can_update_beam_with_file_upload(): void { + Event::fake(); $file = UploadedFile::fake()->createWithContent('tokens.txt', "1\n2..10"); $response = $this->graphql( $this->method, @@ -92,6 +124,31 @@ public function test_it_can_update_beam_with_file_upload(): void ); $this->assertTrue($response); Event::assertDispatched(TokensAdded::class); + + $this->seedBeamPack(); + $file = UploadedFile::fake()->createWithContent('tokens.txt', "1\n2..10"); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [[ + 'tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]], + ]], + ] + ); + $this->assertTrue($response); + Event::assertDispatched(TokensAdded::class); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', "{$this->token->token_chain_id}\n{$this->token->token_chain_id}..{$this->token->token_chain_id}"); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [['tokenIdDataUpload' => $file]]]], + ] + ); + $this->assertTrue($response); + Event::assertDispatched(TokensAdded::class); } public function test_it_will_fail_with_token_exist_in_beam(): void @@ -124,38 +181,44 @@ public function test_it_will_fail_with_token_exist_in_beam(): void $this->assertArraySubset([ 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); - } - public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): void - { - $file = UploadedFile::fake()->createWithContent('tokens.txt', $this->token->token_chain_id); + + $this->seedBeamPack(); + $claim = $this->claims->first(); + $claim->forceFill(['token_chain_id' => $this->token->token_chain_id])->save(); $response = $this->graphql( $this->method, [ 'code' => $this->beam->code, - 'tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]], + 'packs' => [[ + 'tokens' => [['tokenIds' => [$claim->token_chain_id], 'type' => BeamType::TRANSFER_TOKEN->name]], + ]], ], true ); $this->assertArraySubset([ - 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload exists in the specified collection.'], + 'packs.0.tokens.0.tokenIds' => ['The packs.0.tokens.0.tokenIds already exist in beam.'], ], $response['error']); - Event::assertNotDispatched(BeamUpdated::class); - $file = UploadedFile::fake()->createWithContent('tokens.txt', "{$this->token->token_chain_id}..{$this->token->token_chain_id}"); + $file = UploadedFile::fake()->createWithContent('tokens.txt', $this->token->token_chain_id); $response = $this->graphql( $this->method, [ 'code' => $this->beam->code, - 'tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]], + 'packs' => [[ + 'tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::TRANSFER_TOKEN->name]], + ]], ], true ); $this->assertArraySubset([ - 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload exists in the specified collection.'], + 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); - Event::assertNotDispatched(BeamUpdated::class); + } + + public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): void + { $file = UploadedFile::fake()->createWithContent('tokens.txt', '1'); $response = $this->graphql( $this->method, @@ -168,7 +231,6 @@ public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): voi $this->assertArraySubset([ 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload does not exist in the specified collection.'], ], $response['error']); - Event::assertNotDispatched(BeamUpdated::class); $file = UploadedFile::fake()->createWithContent('tokens.txt', '1..10'); $response = $this->graphql( @@ -182,7 +244,33 @@ public function test_it_will_fail_to_create_beam_with_invalid_file_upload(): voi $this->assertArraySubset([ 'tokens.0.tokenIdDataUpload' => ['The tokens.0.tokenIdDataUpload does not exist in the specified collection.'], ], $response['error']); - Event::assertNotDispatched(BeamUpdated::class); + + $this->seedBeamPack(); + $file = UploadedFile::fake()->createWithContent('tokens.txt', '1'); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [['tokenIdDataUpload' => $file]]]], + ], + true + ); + $this->assertArraySubset([ + 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload does not exist in the specified collection.'], + ], $response['error']); + + $file = UploadedFile::fake()->createWithContent('tokens.txt', '1..10'); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => [['tokenIdDataUpload' => $file]]]], + ], + true + ); + $this->assertArraySubset([ + 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload does not exist in the specified collection.'], + ], $response['error']); } /** @@ -200,28 +288,30 @@ public function test_it_will_fail_with_invalid_parameters(): void ); $this->assertArraySubset([ 'code' => ['The selected code is invalid.'], - 'tokens' => ['The tokens field must have at least 1 items.'], ], $response['error']); + $response = $this->graphql( $this->method, [ - 'code' => null, - 'tokens' => null, + 'code' => $this->beam->code, + 'tokens' => [], ], true ); - $this->assertEquals($response['error'], 'Variable "$code" of non-null type "String!" must not be null.'); + $this->assertArraySubset([ + 'tokens' => ['The tokens field must have at least 1 items.'], + ], $response['error']); $response = $this->graphql( $this->method, [ - 'code' => $this->beam->code, + 'code' => null, 'tokens' => null, ], true ); - $this->assertEquals($response['error'], 'Variable "$tokens" of non-null type "[ClaimToken!]!" must not be null.'); + $this->assertEquals($response['error'], 'Variable "$code" of non-null type "String!" must not be null.'); $response = $this->graphql($this->method, [ 'code' => Str::random(1500), @@ -231,5 +321,18 @@ public function test_it_will_fail_with_invalid_parameters(): void ['code' => ['The code field must not be greater than 1024 characters.']], $response['error'] ); + + $this->seedBeamPack(); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [], + ], + true + ); + $this->assertArraySubset([ + 'packs' => ['The packs field must have at least 1 items.'], + ], $response['error']); } } diff --git a/tests/Feature/GraphQL/Resources/AddTokens.graphql b/tests/Feature/GraphQL/Resources/AddTokens.graphql index 3d0b5c5..fffeb22 100644 --- a/tests/Feature/GraphQL/Resources/AddTokens.graphql +++ b/tests/Feature/GraphQL/Resources/AddTokens.graphql @@ -1,9 +1,7 @@ mutation AddTokens( $code: String! - $tokens: [ClaimToken!]! + $tokens: [ClaimToken!] + $packs: [BeamPackInput!] ) { - AddTokens( - code: $code - tokens: $tokens - ) + AddTokens(code: $code, tokens: $tokens, packs: $packs) } From 0fd5c1537c3f3f2ee2c2039aa9c2e6b11593681a Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 16:04:44 +0800 Subject: [PATCH 05/24] Add remove beam pack tokens --- database/factories/BeamPackFactory.php | 30 +++ lang/en/validation.php | 2 + .../Mutations/RemoveTokensMutation.php | 66 +++++- .../Types/Input/RemoveBeamPackType.php | 36 ++++ src/Models/Laravel/BeamClaim.php | 9 + src/Models/Laravel/BeamPack.php | 202 +++++++----------- src/Rules/TokensExistInBeamPack.php | 62 ++++++ src/Services/BeamService.php | 81 ++++++- .../GraphQL/Mutations/RemoveTokensTest.php | 69 +++++- .../GraphQL/Resources/RemoveTokens.graphql | 8 +- tests/Feature/Traits/SeedBeamData.php | 24 ++- 11 files changed, 432 insertions(+), 157 deletions(-) create mode 100644 database/factories/BeamPackFactory.php create mode 100644 src/GraphQL/Types/Input/RemoveBeamPackType.php create mode 100644 src/Rules/TokensExistInBeamPack.php diff --git a/database/factories/BeamPackFactory.php b/database/factories/BeamPackFactory.php new file mode 100644 index 0000000..022db4b --- /dev/null +++ b/database/factories/BeamPackFactory.php @@ -0,0 +1,30 @@ + + */ + public function definition() + { + return [ + 'is_claimed' => fake()->boolean(), + 'code' => fake()->text(), + 'nonce' => 1, + ]; + } +} diff --git a/lang/en/validation.php b/lang/en/validation.php index 47a65e5..7ec3e58 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -24,4 +24,6 @@ 'tokens_doesnt_exist_in_beam' => 'The :attribute already exist in beam.', 'tokens_exist_in_beam' => 'The :attribute doesn\'t exist in beam.', 'not_owner' => 'The :attribute should not be the owner of the collection.', + 'beam_pack_exist_in_beam' => 'The :attribute doesn\'t exist in beam.', + 'tokens_exist_in_beam_pack' => 'The :attribute doesn\'t exist in beam pack.', ]; diff --git a/src/GraphQL/Mutations/RemoveTokensMutation.php b/src/GraphQL/Mutations/RemoveTokensMutation.php index 9d7df97..cb18ba4 100644 --- a/src/GraphQL/Mutations/RemoveTokensMutation.php +++ b/src/GraphQL/Mutations/RemoveTokensMutation.php @@ -3,11 +3,15 @@ namespace Enjin\Platform\Beam\GraphQL\Mutations; use Closure; +use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Rules\BeamExists; +use Enjin\Platform\Beam\Rules\BeamPackExistInBeam; use Enjin\Platform\Beam\Rules\TokensExistInBeam; +use Enjin\Platform\Beam\Rules\TokensExistInBeamPack; use Enjin\Platform\Beam\Services\BeamService; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Rebing\GraphQL\Support\Facades\GraphQL; @@ -43,9 +47,13 @@ public function args(): array 'description' => __('enjin-platform-beam::mutation.claim_beam.args.code'), ], 'tokenIds' => [ - 'type' => GraphQL::type('[IntegerRangeString!]!'), + 'type' => GraphQL::type('[IntegerRangeString!]'), 'description' => __('enjin-platform-beam::mutation.remove_tokens.args.tokenIds'), ], + 'packs' => [ + 'type' => GraphQL::type('[RemoveBeamPack!]'), + 'description' => __('enjin-platform-beam::input_type.remove_beam_pack.description'), + ], ]; } @@ -60,7 +68,13 @@ public function resolve( Closure $getSelectFields, BeamService $beam ) { - return DB::transaction(fn () => $beam->removeTokens($args['code'], $args['tokenIds'])); + return DB::transaction( + fn () => $beam->removeTokens( + $args['code'], + Arr::get($args, 'tokenIds', []), + Arr::get($args, 'packs', []), + ) + ); } /** @@ -68,13 +82,29 @@ public function resolve( */ protected function rules(array $args = []): array { + $beam = Beam::whereCode($args['code'])->first(); + return [ 'code' => [ 'filled', 'max:1024', new BeamExists(), ], + ...match (true) { + !$beam => [], + !$beam?->is_pack => $this->tokenIdRules(), + default => $this->packTokenIdRules(), + }, + ]; + } + + protected function tokenIdRules(): array + { + return [ 'tokenIds' => [ + 'bail', + 'required', + 'prohibits:packs', 'array', 'min:1', 'max:1000', @@ -87,4 +117,36 @@ protected function rules(array $args = []): array ], ]; } + + protected function packTokenIdRules(): array + { + return [ + 'packs' => [ + 'bail', + 'required', + 'prohibits:tokens', + 'array', + 'min:1', + 'max:1000', + ], + 'packs.*.id' => [ + 'filled', + 'integer', + 'distinct', + new BeamPackExistInBeam(), + ], + 'packs.*.tokenIds' => [ + 'array', + 'min:1', + 'max:1000', + 'distinct', + ], + 'packs.*.tokenIds.*' => [ + 'bail', + 'filled', + 'distinct', + new TokensExistInBeamPack(), + ], + ]; + } } diff --git a/src/GraphQL/Types/Input/RemoveBeamPackType.php b/src/GraphQL/Types/Input/RemoveBeamPackType.php new file mode 100644 index 0000000..58830e0 --- /dev/null +++ b/src/GraphQL/Types/Input/RemoveBeamPackType.php @@ -0,0 +1,36 @@ + 'RemoveBeamPack', + 'description' => __('enjin-platform-beam::input_type.remove_beam_pack.description'), + ]; + } + + /** + * Get the input type's fields. + */ + public function fields(): array + { + return [ + 'id' => [ + 'type' => GraphQL::type('Int!'), + 'description' => __('enjin-platform-beam::input_type.beam_pack.field.id'), + ], + 'tokenIds' => [ + 'type' => GraphQL::type('[IntegerRangeString!]'), + 'description' => __('enjin-platform-beam::mutation.remove_tokens.args.tokenIds'), + ], + ]; + } +} diff --git a/src/Models/Laravel/BeamClaim.php b/src/Models/Laravel/BeamClaim.php index b0a82f0..93f90fa 100644 --- a/src/Models/Laravel/BeamClaim.php +++ b/src/Models/Laravel/BeamClaim.php @@ -77,6 +77,7 @@ class BeamClaim extends BaseModel 'beam_id', 'nonce', 'code', + 'beam_pack_id', ]; /** @@ -129,6 +130,14 @@ public function collection(): BelongsTo return $this->belongsTo(Collection::class); } + /** + * The Beam Pack's relationship. + */ + public function beamPack(): BelongsTo + { + return $this->belongsTo(BeamPack::class, 'beam_pack_id'); + } + /** * Local scope for single use. */ diff --git a/src/Models/Laravel/BeamPack.php b/src/Models/Laravel/BeamPack.php index c32a58a..62ecd15 100644 --- a/src/Models/Laravel/BeamPack.php +++ b/src/Models/Laravel/BeamPack.php @@ -2,27 +2,23 @@ namespace Enjin\Platform\Beam\Models\Laravel; -use Carbon\Carbon; -use Enjin\Platform\Beam\Database\Factories\BeamFactory; -use Enjin\Platform\Beam\Enums\BeamFlag; -use Enjin\Platform\Beam\Services\BeamService; -use Enjin\Platform\Beam\Support\ClaimProbabilities; +use Enjin\Platform\Beam\Database\Factories\BeamPackFactory; +use Enjin\Platform\Beam\GraphQL\Types\BeamPackType; use Enjin\Platform\Models\BaseModel; -use Enjin\Platform\Models\Laravel\Collection; -use Enjin\Platform\Support\BitMask; -use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Facades\Cache; +use Illuminate\Pagination\Cursor; +use Illuminate\Support\Arr; class BeamPack extends BaseModel { use HasFactory; - use SoftDeletes; use Traits\EagerLoadSelectFields; use Traits\HasBeamQr; + use Traits\HasCodeScope; + use Traits\HasSingleUseCodeScope; /** * The attributes that aren't mass assignable. @@ -37,24 +33,12 @@ class BeamPack extends BaseModel * @var array */ protected $fillable = [ - 'name', - 'code', - 'description', - 'image', - 'start', - 'end', - 'collection_chain_id', - 'flags_mask', - 'is_pack', + 'is_claimed', 'beam_id', + 'code', 'nonce', ]; - /** - * Cascade softdeletes. - */ - protected $cascadeDeletes = ['claims', 'scans']; - /** * The hidden fields. * @@ -63,132 +47,90 @@ class BeamPack extends BaseModel protected $hidden = [ 'created_at', 'updated_at', - 'deleted_at', + 'beam_id', ]; /** - * The beam claim's relationship. - */ - public function claims(): HasMany - { - return $this->hasMany(BeamClaim::class, 'beam_id'); - } - - /** - * The beam scans relationship. - */ - public function scans(): HasMany - { - return $this->hasMany(BeamScan::class, 'beam_id'); - } - - /** - * The collection relationship. - */ - public function collection(): BelongsTo - { - return $this->belongsTo(Collection::class, 'collection_chain_id', 'collection_chain_id'); - } - - /** - * Check if the beam has a flag. - */ - public function hasFlag(BeamFlag $flag): bool - { - return BitMask::getBit($flag->value, $this->flags_mask ?? 0); - } - - /** - * Boot model. + * The beam's relationship. */ - public static function boot() + public function beam(): BelongsTo { - static::deleting(function ($model) { - BeamScan::where('beam_id', $model->id)->update(['deleted_at' => $now = now()]); - BeamClaim::where('beam_id', $model->id)->update(['deleted_at' => $now]); - }); - - static::deleted(function ($model) { - Cache::forget(BeamService::key($model->code)); - }); - - parent::boot(); - } - - /** - * The beam pack' relationship. - */ - public function packs(): HasMany - { - return $this->hasMany(self::class, 'beam_id'); + return $this->belongsTo(Beam::class); } /** - * Interact with the beam's start attribute. - */ - protected function start(): Attribute - { - return Attribute::make( - get: fn ($value) => $value, - set: fn ($value) => Carbon::parse($value)->toDateTimeString(), - ); - } - - /** - * Interact with the beam's end attribute. + * The beam claim's relationship. */ - protected function end(): Attribute + public function claims(): HasMany { - return $this->start(); + return $this->hasMany(BeamClaim::class, 'beam_pack_id'); } - /** - * Interact with the beam's claims remaining attribute. - */ - protected function claimsRemaining(): Attribute + public function scopeClaimable(Builder $query): Builder { - return Attribute::make( - get: fn () => Cache::get(BeamService::key($this->code), BeamService::claimsCountResolver($this->code)) - ); + return $query->where('is_claimed', false); } /** - * Interact with the beam's claims remaining attribute. - */ - protected function probabilities(): Attribute - { - return Attribute::make( - get: fn () => ClaimProbabilities::getProbabilities($this->code)['probabilities'] ?? null - ); + * Load beam claim's select and relationship fields. + */ + public static function loadClaims( + array $selections, + string $attribute, + array $args = [], + ?string $key = null, + bool $isParent = false + ): array { + $fields = Arr::get($selections, $attribute, $selections); + $select = array_filter([ + 'id', + 'beam_id', + ...(isset($fields['qr']) ? ['code'] : []), + ...(static::$query == 'GetSingleUseCodes' ? ['code', 'nonce'] : ['nonce']), + ...BeamPackType::getSelectFields($fieldKeys = array_keys($fields)), + ]); + + $with = []; + $withCount = []; + + if (! $isParent) { + $with = [ + $key => function ($query) use ($select, $args) { + $query->select(array_unique($select)) + ->when($cursor = Cursor::fromEncoded(Arr::get($args, 'after')), fn ($q) => $q->where('id', '>', $cursor->parameter('id'))) + ->orderBy('beam_packs.id'); + // This must be done this way to load eager limit correctly. + if ($limit = Arr::get($args, 'first')) { + $query->limit($limit + 1); + } + }, + ]; + } + + foreach ([ + ...BeamPackType::getRelationFields($fieldKeys), + ...(isset($fields['code']) ? ['beam'] : []), + ] as $relation) { + $with = array_merge( + $with, + static::getRelationQuery( + BeamPackType::class, + $relation, + $fields, + $key, + $with + ) + ); + } + + return [$select, $with, $withCount]; } /** * This model's factory. */ - protected static function newFactory(): BeamFactory - { - return BeamFactory::new(); - } - - /** - * The beam flags attribute. - */ - protected function flags(): Attribute - { - return Attribute::make( - get: fn () => collect(BitMask::getBits($this->flags_mask))->map(function ($flag) { - return BeamFlag::from($flag)->name; - })->toArray() - ); - } - - /** - * This model's specific pivot identifier. - */ - protected function pivotIdentifier(): Attribute + protected static function newFactory(): BeamPackFactory { - return Attribute::make( - get: fn () => $this->code, - ); + return BeamPackFactory::new(); } } diff --git a/src/Rules/TokensExistInBeamPack.php b/src/Rules/TokensExistInBeamPack.php new file mode 100644 index 0000000..651bbe4 --- /dev/null +++ b/src/Rules/TokensExistInBeamPack.php @@ -0,0 +1,62 @@ +data, $key)) { + [$integers, $ranges] = collect($value)->partition(fn ($val) => $this->integerRange($val) === false); + if (count($integers)) { + $count = BeamClaim::whereIn('token_chain_id', $integers) + ->whereNull('claimed_at') + ->where('beam_pack_id', $id) + ->count(); + if ($count != count($integers)) { + $fail($this->message())->translate(); + + return; + } + } + foreach ($ranges as $range) { + [$from, $to] = $this->integerRange($range); + $count = BeamClaim::whereBetween('token_chain_id', [(int) $from, (int) $to]) + ->whereNull('claimed_at') + ->where('beam_pack_id', $id) + ->count(); + if ($count !== ($to - $from) + 1) { + $fail($this->message())->translate(); + } + } + } + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message() + { + return 'enjin-platform-beam::validation.tokens_exist_in_beam_pack'; + } +} diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index ca95b8f..9909463 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -424,34 +424,97 @@ public function deleteByCode(string $code): bool /** * Remove tokens from a beam. */ - public function removeTokens(string $code, array $tokens): bool + public function removeTokens(string $code, ?array $tokens = [], ?array $packs = []): bool { - $integers = collect($tokens)->filter(fn ($val) => $this->integerRange($val) === false)->all(); + $beam = Beam::whereCode($code)->firstOrFail(); + if ($beam->is_pack) { + $this->removeBeamPack($packs, $beam); + } else { + $this->removeClaimTokens($tokens, $beam); + } + + return true; + } + + public function removeClaimTokens(array $tokens, Model $beam): void + { + [$integers, $ranges] = collect($tokens)->partition(fn ($val) => $this->integerRange($val) === false)->all(); if ($integers) { Cache::decrement( - self::key($code), + self::key($beam->code), BeamClaim::whereIn('token_chain_id', $integers) - ->whereHas('beam', fn ($query) => $query->where('code', $code)) + ->where('beam_id', $beam->id) ->whereNull('claimed_at') ->delete() ); } - $ranges = collect($tokens)->filter(fn ($val) => $this->integerRange($val) !== false)->all(); foreach ($ranges as $range) { [$from, $to] = $this->integerRange($range); Cache::decrement( - self::key($code), + self::key($beam->code), BeamClaim::whereBetween('token_chain_id', [(int) $from, (int) $to]) - ->whereHas('beam', fn ($query) => $query->where('code', $code)) + ->where('beam_id', $beam->id) ->whereNull('claimed_at') ->delete() ); } if ($tokens) { - $this->probability->removeTokens($code, $tokens); - TokensRemoved::safeBroadcast(event: ['code' => $code, 'tokenIds' => $tokens]); + $this->probability->removeTokens($beam->code, $tokens); + TokensRemoved::safeBroadcast(event: ['code' => $beam->code, 'tokenIds' => $tokens]); + } + } + + /** + * Remove beam pack tokens. + */ + public function removeBeamPack(array $packs, Model $beam): bool + { + $packCollection = collect($packs)->keyBy('id'); + $deletedTokens = 0; + $forDeletion = []; + foreach ($packCollection as $pack) { + if (empty($pack['tokenIds'])) { + $forDeletion[] = $pack['id']; + + continue; + } + + [$tokenIds, $tokenIdRanges] = collect($pack['tokenIds'])->partition(fn ($val) => $this->integerRange($val) === false); + if ($tokenIds) { + $deletedTokens += BeamClaim::where('beam_pack_id', $pack['id']) + ->whereNull('claimed_at') + ->whereIn('token_chain_id', $tokenIds) + ->delete(); + } + + if ($tokenIdRanges) { + $deletedTokens += BeamClaim::where('beam_pack_id', $pack['id']) + ->whereNull('claimed_at') + ->where(function ($query) use ($tokenIdRanges) { + $tokenIdRanges->each(function ($tokenString) use ($query) { + $ranges = $this->integerRange($tokenString); + $query->orWhereBetween('token_chain_id', [(int) $ranges[0], (int) $ranges[1]]); + }); + }) + ->delete(); + } + } + + $beamPacks = BeamPack::where('beam_id', $beam->id) + ->whereIn('id', $packCollection->pluck('id')) + ->withCount('claims') + ->get(['id']); + $forDeletion = array_merge($forDeletion, $beamPacks->where('claims_count', 0)->pluck('id')->all()); + if (count($forDeletion)) { + BeamPack::whereIn('id', $forDeletion) + ->whereDoesntHave('claims', fn ($query) => $query->whereNotNull('claimed_at')) + ->delete(); + } + + if ($deletedTokens) { + TokensRemoved::safeBroadcast(event: ['code' => $beam->code, 'tokenIds' => $packCollection->pluck('tokenIds')->flatten()->all()]); } return true; diff --git a/tests/Feature/GraphQL/Mutations/RemoveTokensTest.php b/tests/Feature/GraphQL/Mutations/RemoveTokensTest.php index d7d6ce5..524a9ea 100644 --- a/tests/Feature/GraphQL/Mutations/RemoveTokensTest.php +++ b/tests/Feature/GraphQL/Mutations/RemoveTokensTest.php @@ -31,7 +31,7 @@ public function test_it_can_remove_tokens(): void $this->assertTrue($response); Event::assertDispatched(TokensRemoved::class); - Event::fake(); + $claim = $this->claims->shift(); $response = $this->graphql($this->method, [ 'code' => $this->beam->code, @@ -39,6 +39,37 @@ public function test_it_can_remove_tokens(): void ]); $this->assertTrue($response); Event::assertDispatched(TokensRemoved::class); + + $this->seedBeamPack(3); + $claim = $this->claims->shift(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [ + ['id' => $claim->beam_pack_id, 'tokenIds' => [$claim->token_chain_id]], + ], + ]); + $this->assertTrue($response); + Event::assertDispatched(TokensRemoved::class); + + $claim = $this->claims->shift(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [ + ['id' => $claim->beam_pack_id, 'tokenIds' => ["{$claim->token_chain_id}..{$claim->token_chain_id}"]], + ], + ]); + $this->assertTrue($response); + Event::assertDispatched(TokensRemoved::class); + + $claim = $this->claims->shift(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [ + ['id' => $claim->beam_pack_id], + ], + ]); + $this->assertTrue($response); + Event::assertDispatched(TokensRemoved::class); } /** @@ -70,7 +101,7 @@ public function test_it_will_fail_with_invalid_paramters(): void 'tokenIds' => null, ], true); $this->assertEquals( - 'Variable "$tokenIds" of non-null type "[IntegerRangeString!]!" must not be null.', + ['tokenIds' => ['The token ids field is required.']], $response['error'] ); @@ -79,7 +110,7 @@ public function test_it_will_fail_with_invalid_paramters(): void 'tokenIds' => [], ], true); $this->assertArraySubset( - ['tokenIds' => ['The token ids field must have at least 1 items.']], + ['tokenIds' => ['The token ids field is required.']], $response['error'] ); @@ -140,5 +171,37 @@ public function test_it_will_fail_with_invalid_paramters(): void ['tokenIds.0' => ["The tokenIds.0 doesn't exist in beam."]], $response['error'] ); + + $this->seedBeamPack(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [], + ], true); + $this->assertArraySubset( + ['packs' => ['The packs field is required.']], + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['tokens' => null]], + ], true); + $this->assertEquals( + 'Variable "$packs" got invalid value {"tokens":null} at "packs[0]"; Field "id" of required type "Int!" was not provided.', + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => 100000, 'tokenIds' => ['10000']]], + ], true); + $this->assertArraySubset( + [ + 'packs.0.id' => ['The packs.0.id doesn\'t exist in beam.'], + 'packs.0.tokenIds.0' => ['The packs.0.tokenIds.0 doesn\'t exist in beam pack.'], + ], + $response['error'] + ); + } } diff --git a/tests/Feature/GraphQL/Resources/RemoveTokens.graphql b/tests/Feature/GraphQL/Resources/RemoveTokens.graphql index a1a550f..3dddb64 100644 --- a/tests/Feature/GraphQL/Resources/RemoveTokens.graphql +++ b/tests/Feature/GraphQL/Resources/RemoveTokens.graphql @@ -1,3 +1,7 @@ -mutation RemoveTokens($code: String!, $tokenIds: [IntegerRangeString!]!) { - RemoveTokens(code: $code, tokenIds: $tokenIds) +mutation RemoveTokens( + $code: String! + $tokenIds: [IntegerRangeString!] + $packs: [RemoveBeamPack!] +) { + RemoveTokens(code: $code, tokenIds: $tokenIds, packs: $packs) } diff --git a/tests/Feature/Traits/SeedBeamData.php b/tests/Feature/Traits/SeedBeamData.php index 55f8b60..e88780d 100644 --- a/tests/Feature/Traits/SeedBeamData.php +++ b/tests/Feature/Traits/SeedBeamData.php @@ -65,18 +65,20 @@ public function seedBeamPack(?int $claimsCount = null, ?BeamType $type = null, a ...$beam, ])->create(); + $this->claims = collect(); Collection::times($claimsCount ?: 1, function () use ($type) { - $this->claims = BeamClaim::factory() - ->count(random_int(1, 10)) - ->for(BeamPack::factory()->state(['beam_id' => $this->beam])) - ->create([ - 'collection_id' => $this->collection->id, - 'wallet_public_key' => null, - 'claimed_at' => null, - 'state' => null, - 'beam_id' => $this->beam->id, - ...($type ? ['type' => $type->name] : []), - ]); + $this->claims->push( + BeamClaim::factory() + ->create([ + 'collection_id' => $this->collection->id, + 'wallet_public_key' => null, + 'claimed_at' => null, + 'state' => null, + 'beam_id' => $this->beam->id, + 'beam_pack_id' => BeamPack::factory(['beam_id' => $this->beam])->create()->id, + ...($type ? ['type' => $type->name] : []), + ]) + ); }); Cache::remember(BeamService::key($this->beam->code), 3600, fn () => $claimsCount); From 19492249c9b9fbcfc58d2705d754c87f4555840d Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 20:24:29 +0800 Subject: [PATCH 06/24] Update single use codes --- .../Queries/GetSingleUseCodesQuery.php | 10 +++- src/GraphQL/Traits/HasTokenInputRules.php | 2 +- src/GraphQL/Unions/ClaimUnion.php | 41 ++++++++++++++++ src/Models/Laravel/BeamClaim.php | 29 ----------- .../Laravel/Traits/HasSingleUseCodeScope.php | 29 +++++++++++ src/Rules/SingleUseCodeExist.php | 16 +++--- src/Services/BeamService.php | 31 ++++++------ .../Mutations/ExpireSingleUseCodesTest.php | 49 +++++++++++++++++++ .../GraphQL/Queries/GetSingleUseCodesTest.php | 31 ++++++++++++ .../Resources/GetSingleUseCodes.graphql | 18 +++++-- 10 files changed, 200 insertions(+), 56 deletions(-) create mode 100644 src/GraphQL/Unions/ClaimUnion.php diff --git a/src/GraphQL/Queries/GetSingleUseCodesQuery.php b/src/GraphQL/Queries/GetSingleUseCodesQuery.php index 2a43d69..5a31480 100644 --- a/src/GraphQL/Queries/GetSingleUseCodesQuery.php +++ b/src/GraphQL/Queries/GetSingleUseCodesQuery.php @@ -4,7 +4,9 @@ use Closure; use Enjin\Platform\Beam\Enums\BeamFlag; +use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Models\BeamClaim; +use Enjin\Platform\Beam\Models\BeamPack; use Enjin\Platform\Beam\Rules\HasBeamFlag; use Enjin\Platform\GraphQL\Middleware\ResolvePage; use Enjin\Platform\GraphQL\Types\Pagination\ConnectionInput; @@ -34,7 +36,7 @@ public function attributes(): array */ public function type(): Type { - return GraphQL::paginate('BeamClaim', 'BeamClaimConnection'); + return GraphQL::paginate('ClaimUnion', 'ClaimUnionConnection'); } /** @@ -60,8 +62,12 @@ public function resolve( ResolveInfo $resolveInfo, Closure $getSelectFields ) { - return BeamClaim::loadSelectFields($resolveInfo, $this->name) + $beam = Beam::whereCode($args['code'])->firstOrFail(); + + return ($beam->is_pack ? new BeamPack() : new BeamClaim()) + ->loadSelectFields($resolveInfo, $this->name) ->hasCode($args['code']) + ->where('nonce', 1) ->with('beam') ->claimable() ->cursorPaginateWithTotalDesc('id', $args['first']); diff --git a/src/GraphQL/Traits/HasTokenInputRules.php b/src/GraphQL/Traits/HasTokenInputRules.php index 82b4f41..fa8ff78 100644 --- a/src/GraphQL/Traits/HasTokenInputRules.php +++ b/src/GraphQL/Traits/HasTokenInputRules.php @@ -95,7 +95,7 @@ public function packTokenRules(array $args, ?string $collectionId = null, bool $ 'min:1', 'max:1000', ], - 'packs.*.id' => [new BeamPackExistInBeam()], + $withTokens ? '' : 'packs.*.id' => [new BeamPackExistInBeam()], 'packs.*.tokens' => [ 'bail', 'array', diff --git a/src/GraphQL/Unions/ClaimUnion.php b/src/GraphQL/Unions/ClaimUnion.php new file mode 100644 index 0000000..60d03d2 --- /dev/null +++ b/src/GraphQL/Unions/ClaimUnion.php @@ -0,0 +1,41 @@ + 'ClaimUnion', + 'description' => __('enjin-platform-marketplace::union.listing_data.description'), + ]; + } + + /** + * The possible types that this union can be. + */ + public function types(): array + { + return [ + GraphQL::type('BeamClaim'), + GraphQL::type('BeamPack'), + ]; + } + + /** + * Resolves concrete ObjectType for given object value. + */ + public function resolveType($objectValue, $context, ResolveInfo $info) + { + return GraphQL::type($objectValue?->is_pack ? 'BeamPack' : 'BeamClaim'); + } +} diff --git a/src/Models/Laravel/BeamClaim.php b/src/Models/Laravel/BeamClaim.php index 93f90fa..c6293f8 100644 --- a/src/Models/Laravel/BeamClaim.php +++ b/src/Models/Laravel/BeamClaim.php @@ -12,7 +12,6 @@ use Enjin\Platform\Models\Laravel\Token; use Enjin\Platform\Models\Laravel\Transaction; use Enjin\Platform\Models\Laravel\Wallet; -use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\MassPrunable; @@ -138,34 +137,6 @@ public function beamPack(): BelongsTo return $this->belongsTo(BeamPack::class, 'beam_pack_id'); } - /** - * Local scope for single use. - */ - public function scopeSingleUse(Builder $query): Builder - { - return $query->whereNotNull('code'); - } - - /** - * Local scope for single use code. - */ - public function scopeWithSingleUseCode(Builder $query, string $code): Builder - { - $parsed = BeamService::getSingleUseCodeData($code); - - return $query->where(['code' => $parsed->claimCode, 'nonce' => $parsed->nonce]); - } - - /** - * The claimable code, encoded with the open platform host url. - */ - public function singleUseCode(): Attribute - { - return Attribute::make( - get: fn () => encrypt(implode(':', [$this->code, $this->beam?->code, $this->nonce])) - ); - } - /** * The claimable code, encoded with the Platform host url. */ diff --git a/src/Models/Laravel/Traits/HasSingleUseCodeScope.php b/src/Models/Laravel/Traits/HasSingleUseCodeScope.php index a499683..a758046 100644 --- a/src/Models/Laravel/Traits/HasSingleUseCodeScope.php +++ b/src/Models/Laravel/Traits/HasSingleUseCodeScope.php @@ -4,6 +4,7 @@ use Enjin\Platform\Beam\Services\BeamService; use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Arr; trait HasSingleUseCodeScope @@ -27,4 +28,32 @@ public function scopeHasSingleUseCode(Builder $query, string|array|null $code): return $query; } } + + /** + * Local scope for single use. + */ + public function scopeSingleUse(Builder $query): Builder + { + return $query->whereNotNull('code'); + } + + /** + * Local scope for single use code. + */ + public function scopeWithSingleUseCode(Builder $query, string $code): Builder + { + $parsed = BeamService::getSingleUseCodeData($code); + + return $query->where(['code' => $parsed->claimCode, 'nonce' => $parsed->nonce]); + } + + /** + * The claimable code, encoded with the open platform host url. + */ + public function singleUseCode(): Attribute + { + return Attribute::make( + get: fn () => encrypt(implode(':', [$this->code, $this->beam?->code, $this->nonce])) + ); + } } diff --git a/src/Rules/SingleUseCodeExist.php b/src/Rules/SingleUseCodeExist.php index 35bc924..d025058 100644 --- a/src/Rules/SingleUseCodeExist.php +++ b/src/Rules/SingleUseCodeExist.php @@ -3,7 +3,9 @@ namespace Enjin\Platform\Beam\Rules; use Closure; +use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Models\BeamClaim; +use Enjin\Platform\Beam\Models\BeamPack; use Enjin\Platform\Beam\Services\BeamService; use Illuminate\Contracts\Validation\ValidationRule; @@ -18,12 +20,14 @@ public function __construct(protected bool $isClaiming = false) {} */ public function validate(string $attribute, mixed $value, Closure $fail): void { - if (BeamService::isSingleUse($value) && - BeamClaim::withSingleUseCode($value) - ->when($this->isClaiming, fn ($query) => $query->claimable()) - ->exists() - ) { - return; + $beamCode = BeamService::getSingleUseCodeData($value)?->beamCode; + if ($beamCode && ($beam = Beam::where('code', $beamCode)->first())) { + if (($beam->is_pack ? new BeamPack() : new BeamClaim()) + ->withSingleUseCode($value) + ->when($this->isClaiming, fn ($query) => $query->claimable()) + ->exists()) { + return; + } } $fail('enjin-platform-beam::validation.verify_signed_message')->translate(); diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 9909463..512fa94 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -349,22 +349,25 @@ public static function key(string $name, ?string $suffix = null): string */ public function expireSingleUseCodes(array $codes): int { - $beams = []; - collect($codes)->each(function ($code) use (&$beams) { - if ($claim = BeamClaim::claimable()->withSingleUseCode($code)->first()) { - if (! isset($beams[$claim->beam_id])) { - $beams[$claim->beam_id] = 0; + $beamCodes = collect($codes) + ->keyBy(fn ($code) => static::getSingleUseCodeData($code)->beamCode) + ->all(); + + Beam::whereIn('code', array_keys($beamCodes)) + ->get(['id', 'code', 'is_pack']) + ->each(function ($beam) use ($beamCodes) { + if ($claim = ($beam->is_pack ? new BeamPack() : new BeamClaim()) + ->claimable() + ->where('beam_id', $beam->id) + ->withSingleUseCode($beamCodes[$beam->code]) + ->first() + ) { + $claim->increment('nonce'); + Cache::decrement($this->key($beam->code)); } - $beams[$claim->beam_id] += $claim->increment('nonce'); - } - }); - - if ($beams) { - Beam::findMany(array_keys($beams), ['id', 'code']) - ->each(fn ($beam) => Cache::decrement($this->key($beam->code, $beams[$beam->id]))); - } + }); - return array_sum($beams); + return count($codes); } /** diff --git a/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php b/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php index 613ce83..7747041 100644 --- a/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php +++ b/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php @@ -6,6 +6,7 @@ use Enjin\Platform\Beam\Tests\Feature\GraphQL\TestCaseGraphQL; use Enjin\Platform\Beam\Tests\Feature\Traits\CreateBeamData; use Enjin\Platform\Beam\Tests\Feature\Traits\SeedBeamData; +use Faker\Generator; use Illuminate\Support\Arr; class ExpireSingleUseCodesTest extends TestCaseGraphQL @@ -42,6 +43,54 @@ public function test_it_can_expire_single_use_codes(): void $this->assertTrue($response); } + public function test_it_can_expire_single_use_codes_beam_pack(): void + { + $this->truncateBeamTables(); + + $code = $this->graphql('CreateBeam', $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 1, + [], + [['flag' => 'SINGLE_USE']], + )); + $this->assertNotEmpty($code); + + $singleUseCodes = $this->graphql('GetSingleUseCodes', ['code' => $code]); + $this->assertNotEmpty($singleUseCodes['totalCount']); + + $response = $this->graphql($this->method, [ + 'codes' => [Arr::get($singleUseCodes, 'edges.0.node.code')], + ]); + $this->assertTrue($response); + } + + public function test_it_cannot_claim_expire_single_use_codes_beam_pack(): void + { + $this->truncateBeamTables(); + + $code = $this->graphql('CreateBeam', $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 1, + [], + [['flag' => 'SINGLE_USE']], + )); + $this->assertNotEmpty($code); + + $singleUseCodes = $this->graphql('GetSingleUseCodes', ['code' => $code]); + $this->assertNotEmpty($singleUseCodes['totalCount']); + + $response = $this->graphql($this->method, [ + 'codes' => [Arr::get($singleUseCodes, 'edges.0.node.code')], + ]); + $this->assertTrue($response); + + $response = $this->graphql('ClaimBeam', [ + 'code' => Arr::get($singleUseCodes, 'edges.0.node.code'), + 'account' => app(Generator::class)->public_key(), + ], true); + + } + /** * Test get single use beam with invalid claims. */ diff --git a/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php b/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php index cdc9eb1..dea0861 100644 --- a/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php +++ b/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php @@ -5,6 +5,7 @@ use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\Tests\Feature\GraphQL\TestCaseGraphQL; use Enjin\Platform\Beam\Tests\Feature\Traits\CreateBeamData; +use Illuminate\Support\Arr; class GetSingleUseCodesTest extends TestCaseGraphQL { @@ -29,6 +30,36 @@ public function test_it_can_get_single_use_codes(): void $response = $this->graphql($this->method, ['code' => $code]); $this->assertNotEmpty($response['totalCount']); + + $code = $this->graphql('CreateBeam', $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 10, + [], + [['flag' => 'SINGLE_USE']] + )); + + $response = $this->graphql($this->method, ['code' => $code]); + $this->assertNotEmpty($response['totalCount']); + } + + public function test_it_cannot_get_expired_single_use_codes(): void + { + $code = $this->graphql('CreateBeam', $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 1, + [], + [['flag' => 'SINGLE_USE']] + )); + + $response = $this->graphql($this->method, ['code' => $code]); + $this->assertNotEmpty($response['totalCount']); + + $singleCode = Arr::get($response, 'edges.0.node.code'); + $response = $this->graphql('ExpireSingleUseCodes', ['codes' => [$singleCode]]); + $this->assertTrue($response); + + $response = $this->graphql($this->method, ['code' => $code]); + $this->assertEmpty($response['edges']); } /** diff --git a/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql b/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql index d47289a..03c6144 100644 --- a/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql +++ b/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql @@ -3,10 +3,20 @@ query GetSingleUseCodes($code: String!) { edges { cursor node { - code - qr { - url - payload + ... on BeamClaim { + code + qr { + url + payload + } + } + + ... on BeamPack { + code + qr { + url + payload + } } } } From 952be3d39187df003caae5b616a280b9f953cee9 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 20:28:56 +0800 Subject: [PATCH 07/24] Remove test --- .../GraphQL/Queries/GetSingleUseCodesTest.php | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php b/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php index dea0861..b5ca6e4 100644 --- a/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php +++ b/tests/Feature/GraphQL/Queries/GetSingleUseCodesTest.php @@ -5,7 +5,6 @@ use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\Tests\Feature\GraphQL\TestCaseGraphQL; use Enjin\Platform\Beam\Tests\Feature\Traits\CreateBeamData; -use Illuminate\Support\Arr; class GetSingleUseCodesTest extends TestCaseGraphQL { @@ -42,26 +41,6 @@ public function test_it_can_get_single_use_codes(): void $this->assertNotEmpty($response['totalCount']); } - public function test_it_cannot_get_expired_single_use_codes(): void - { - $code = $this->graphql('CreateBeam', $this->generateBeamPackData( - BeamType::MINT_ON_DEMAND, - 1, - [], - [['flag' => 'SINGLE_USE']] - )); - - $response = $this->graphql($this->method, ['code' => $code]); - $this->assertNotEmpty($response['totalCount']); - - $singleCode = Arr::get($response, 'edges.0.node.code'); - $response = $this->graphql('ExpireSingleUseCodes', ['codes' => [$singleCode]]); - $this->assertTrue($response); - - $response = $this->graphql($this->method, ['code' => $code]); - $this->assertEmpty($response['edges']); - } - /** * Test get single use beam with invalid claims. */ From 069a53eca92daddee3c0225c737915911ad7ba16 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 21:09:50 +0800 Subject: [PATCH 08/24] Update claims beam pack --- src/Jobs/ClaimBeam.php | 43 +++++++++++++----- src/Services/BeamService.php | 44 ++++++++++--------- .../GraphQL/Mutations/ClaimBeamTest.php | 29 +++++++++++- 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/Jobs/ClaimBeam.php b/src/Jobs/ClaimBeam.php index 7d9ddec..fa14506 100644 --- a/src/Jobs/ClaimBeam.php +++ b/src/Jobs/ClaimBeam.php @@ -4,7 +4,9 @@ use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\Enums\PlatformBeamCache; +use Enjin\Platform\Beam\Exceptions\BeamException; use Enjin\Platform\Beam\Models\BeamClaim; +use Enjin\Platform\Beam\Models\BeamPack; use Enjin\Platform\Beam\Models\BeamScan; use Enjin\Platform\Beam\Services\BatchService; use Enjin\Platform\Beam\Services\BeamService; @@ -13,6 +15,7 @@ use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -45,17 +48,23 @@ public function handle(BatchService $batch, WalletService $wallet): void try { $lock->block(5); - if ($claim = $this->claimQuery($data)->first()) { - DB::beginTransaction(); + $claims = $this->claims($data); + if (count($claims)) { $wallet->firstOrStore(['public_key' => $data['wallet_public_key']]); - $claim->forceFill($this->buildBeamClaimAttributes($batch, $claim))->save(); + $isPackUpdated = false; + DB::beginTransaction(); + foreach ($claims as $claim) { + $claim->forceFill($this->buildBeamClaimAttributes($batch, $claim))->save(); + Log::info('ClaimBeamJob: Claim assigned.', $claim->toArray()); + if ($claim->beamPack && Arr::get($this->data, 'is_pack') && ! $isPackUpdated) { + $claim->beamPack->update(['is_claimed' => true]); + $isPackUpdated = true; + } + } // Delete scan after claim is set up so the signed data can't be used to claim again. BeamScan::firstWhere(['wallet_public_key' => $data['wallet_public_key'], 'beam_id' => $data['beam']['id']])?->delete(); - DB::commit(); - - Log::info('ClaimBeamJob: Claim assigned.', $claim->toArray()); } else { Cache::put(BeamService::key(Arr::get($data, 'beam.code')), 0); Log::info('ClaimBeamJob: No claim available, setting remaining count to 0', $data); @@ -66,7 +75,7 @@ public function handle(BatchService $batch, WalletService $wallet): void } catch (Throwable $e) { DB::rollBack(); - Log::error('ClaimBeamJob: Claim error, message:' . $e->getMessage(), $data); + Log::error('ClaimBeamJob: Claim error, message: ' . $e->getMessage(), $data); throw $e; } finally { @@ -81,7 +90,7 @@ public function handle(BatchService $batch, WalletService $wallet): void public function failed(Throwable $exception): void { if ($data = $this->data) { - if ($this->claimQuery($data)->count() > 0) { + if (count($this->claims($data)) > 0) { // Idempotency key prevents incrementing cache on same claim request even with manual retry on horizon $key = Arr::get($data, 'idempotency_key'); if (! Cache::get(PlatformBeamCache::IDEMPOTENCY_KEY->key($key))) { @@ -99,12 +108,24 @@ public function failed(Throwable $exception): void /** * Get the claim query. */ - protected function claimQuery(array $data): Builder + protected function claims(array $data): Collection { return BeamClaim::where('beam_id', $data['beam']['id']) + ->with(['beam:id,collection_chain_id', 'beamPack:id,beam_id']) ->claimable() - ->when($data['code'], fn ($query) => $query->withSingleUseCode($data['code'])) - ->unless($data['code'], fn ($query) => $query->inRandomOrder()); + ->when($isPack = Arr::get($data, 'is_pack'), function (Builder $query) use ($data) { + if (!($pack = BeamPack::where('is_claimed', false) + ->where('beam_id', $data['beam']['id']) + ->when($data['code'], fn ($subquery) => $subquery->where('code', $data['code'])) + ->inRandomOrder() + ->first())) { + throw new BeamException('No available packs to claim.'); + } + $query->where('beam_pack_id', $pack->id); + }) + ->when(!$isPack && $data['code'], fn ($query) => $query->withSingleUseCode($data['code'])) + ->when(!$isPack, fn ($query) => $query->inRandomOrder()) + ->get(['id', 'beam_id', 'type', 'beam_pack_id']); } /** diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 512fa94..1f187e3 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -235,14 +235,16 @@ public function findByCode(string $code): ?Model */ public function scanByCode(string $code, ?string $wallet = null): ?Model { - $isSingleUse = static::isSingleUse($code); + $beamCode = static::getSingleUseCodeData($code)?->beamCode; + $beam = Beam::whereCode($beamCode ?? $code)->firstOrFail(); + + if ($beamCode) { + ($beam->is_pack ? new BeamPack() : new BeamClaim()) + ->withSingleUseCode($code) + ->firstOrFail(); + } + - $beam = $isSingleUse - ? BeamClaim::withSingleUseCode($code) - ->with('beam') - ->first() - ->beam - : $this->findByCode($code); if ($wallet) { // Pushing this to the queue for performance CreateClaim::dispatch($claim = [ @@ -254,7 +256,7 @@ public function scanByCode(string $code, ?string $wallet = null): ?Model $beam->setRelation('scans', collect(json_decode(json_encode([$claim])))); } - if ($isSingleUse) { + if ($beamCode) { $beam['code'] = $code; } @@ -267,23 +269,22 @@ public function scanByCode(string $code, ?string $wallet = null): ?Model public function claim(string $code, string $wallet, ?string $idempotencyKey = null): bool { $singleUseCode = null; - $singleUse = static::isSingleUse($code); - - if ($singleUse) { - $singleUseCode = $code; - $beam = BeamClaim::withSingleUseCode($singleUseCode) - ->with('beam') - ->first() - ->beam; - $code = $beam?->code; - } else { - $beam = $this->findByCode($code); - } - + $singleUse = static::getSingleUseCodeData($code); + $beam = $this->findByCode($singleUse ? $singleUse->beamCode : $code); if (! $beam) { throw new BeamException(__('enjin-platform-beam::error.beam_not_found', ['code' => $code])); } + if ($singleUse) { + if (!($beam->is_pack ? new BeamPack() : new BeamClaim()) + ->withSingleUseCode($code) + ->first()) { + throw new BeamException(__('enjin-platform-beam::error.beam_not_found', ['code' => $code])); + } + $singleUseCode = $singleUse->claimCode; + $code = $singleUse->beamCode; + } + $lock = Cache::lock(self::key($code, 'claim-lock'), 5); try { @@ -614,6 +615,7 @@ protected function buildRequiredClaimBeamData( 'state' => ClaimStatus::PENDING->name, 'beam' => $beam->toArray(), 'beam_id' => $beam->id, + 'is_pack' => $beam->is_pack, 'ip_address' => request()->getClientIp(), 'code' => $singleUseCode, 'idempotency_key' => $idempotencyKey ?: Str::uuid()->toString(), diff --git a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php index b49a2f5..4a1a9de 100644 --- a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php @@ -22,6 +22,7 @@ use Enjin\Platform\Support\BitMask; use Enjin\Platform\Support\SS58Address; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; @@ -98,6 +99,20 @@ public function test_it_can_claim_beam_with_sr25519_single_use_codes(): void $this->genericClaimTest(CryptoSignatureType::SR25519, Arr::get($response, 'edges.0.node.code')); } + public function test_it_can_claim_beam_pack_with_sr25519_single_use_codes(): void + { + $code = $this->graphql('CreateBeam', $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 1, + [], + [['flag' => 'SINGLE_USE']], + )); + $response = $this->graphql('GetSingleUseCodes', ['code' => $code]); + $this->assertNotEmpty($response['totalCount']); + + $this->genericClaimTest(CryptoSignatureType::SR25519, Arr::get($response, 'edges.0.node.code')); + } + /** * Test claiming beam with ed25519. */ @@ -106,6 +121,18 @@ public function test_it_can_claim_beam_with_ed25519(): void $this->genericClaimTest(CryptoSignatureType::ED25519); } + public function test_it_can_claim_beam_pack_with_ed25519(): void + { + $this->seedBeamPack(); + $this->genericClaimTest(CryptoSignatureType::ED25519); + } + + public function test_it_can_claim_beam_pack_with_sr25519(): void + { + $this->seedBeamPack(); + $this->genericClaimTest(CryptoSignatureType::SR25519); + } + public function test_it_can_claim_beam_job_with_idempotency_key(): void { $data = [ @@ -488,7 +515,7 @@ protected function genericClaimTest(CryptoSignatureType $type = CryptoSignatureT ]); $this->assertNotEmpty($response['message']); if (! $singleUseCode) { - $this->assertEquals(1, $this->beam->scans()->count()); + $this->assertEquals(1, DB::table('beam_scans')->where('beam_id', $this->beam->id)->count()); } $message = $response['message']['message']; From 82f8af21f5c8ab9a55aae24fd61c8b99e3acbc2a Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 21:21:42 +0800 Subject: [PATCH 09/24] Add more tests --- .../GraphQL/Mutations/ClaimBeamTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php index 4a1a9de..f58179c 100644 --- a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php @@ -133,6 +133,32 @@ public function test_it_can_claim_beam_pack_with_sr25519(): void $this->genericClaimTest(CryptoSignatureType::SR25519); } + public function test_it_cannot_claim_beam_pack_with_expired_single_use(): void + { + $code = $this->graphql('CreateBeam', $this->generateBeamPackData( + BeamType::MINT_ON_DEMAND, + 1, + [], + [['flag' => 'SINGLE_USE']], + )); + $response = $this->graphql('GetSingleUseCodes', ['code' => $code]); + $this->assertNotEmpty($response['totalCount']); + + $singleUse = Arr::get($response, 'edges.0.node.code'); + $response = $this->graphql('ExpireSingleUseCodes', ['codes' => [$singleUse]]); + $this->assertTrue($response); + + [$keypair, $publicKey, $privateKey] = $this->getKeyPair(CryptoSignatureType::ED25519); + + $response = $this->graphql($this->method, [ + 'code' => $singleUse, + 'account' => $publicKey, + 'signature' => '', + ], true); + + $this->assertArraySubset(['code' => ['The selected code is invalid.']], $response['error']); + } + public function test_it_can_claim_beam_job_with_idempotency_key(): void { $data = [ From 6116c51f5ccf8fd56edf0b585ffc0a60b3dfa148 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 21:27:27 +0800 Subject: [PATCH 10/24] Fix tests --- tests/Feature/GraphQL/Mutations/ClaimBeamTest.php | 2 +- tests/Feature/GraphQL/Mutations/UpdateBeamTest.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php index f58179c..b8455d4 100644 --- a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php @@ -156,7 +156,7 @@ public function test_it_cannot_claim_beam_pack_with_expired_single_use(): void 'signature' => '', ], true); - $this->assertArraySubset(['code' => ['The selected code is invalid.']], $response['error']); + $this->assertArraySubset(['code' => ['The code is invalid.']], $response['error']); } public function test_it_can_claim_beam_job_with_idempotency_key(): void diff --git a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php index 96f8991..9d01f88 100644 --- a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php @@ -172,7 +172,7 @@ public function test_it_can_update_beam_with_file_upload(): void /** * Test updating beam token exist in beam. */ - /*public function test_it_will_fail_with_token_exist_in_beam(): void + public function test_it_will_fail_with_token_exist_in_beam(): void { $this->collection->update(['max_token_supply' => 1]); $this->seedBeam(1, false, BeamType::TRANSFER_TOKEN); @@ -206,6 +206,7 @@ public function test_it_can_update_beam_with_file_upload(): void $this->seedBeamPack(); $claim = $this->claims->first(); + $claim->forceFill(['token_chain_id' => $this->token->token_chain_id])->save(); $response = $this->graphql( $this->method, [ @@ -231,7 +232,7 @@ public function test_it_can_update_beam_with_file_upload(): void 'packs.0.tokens.0.tokenIdDataUpload' => ['The packs.0.tokens.0.tokenIdDataUpload already exist in beam.'], ], $response['error']); - }*/ + } /** * Test updating beam with file upload. From 2726b289c51f9b747fd9a33e5e73b05c4be5f268 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 5 Aug 2024 21:28:52 +0800 Subject: [PATCH 11/24] Add packs relation --- src/GraphQL/Types/BeamType.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/GraphQL/Types/BeamType.php b/src/GraphQL/Types/BeamType.php index 1272b0d..9373ffa 100644 --- a/src/GraphQL/Types/BeamType.php +++ b/src/GraphQL/Types/BeamType.php @@ -128,6 +128,12 @@ public function fields(): array 'selectable' => false, 'is_relation' => false, ], + 'packs' => [ + 'type' => GraphQL::type('[BeamPack!]'), + 'description' => __('enjin-platform-beam::type.beam_pack.description'), + 'selectable' => false, + 'is_relation' => true, + ], ]; } } From c63896ac8edfcb51183c50e83f8d25bcd07b7696 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Thu, 15 Aug 2024 15:08:09 +0800 Subject: [PATCH 12/24] Update translation --- lang/en/input_type.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en/input_type.php b/lang/en/input_type.php index dad3880..8521004 100644 --- a/lang/en/input_type.php +++ b/lang/en/input_type.php @@ -7,7 +7,7 @@ 'claim_token.description' => 'The claimable tokens.', 'claim_token.field.tokenId' => 'The token chain IDs available to claim.', 'claim_token.field.tokenIdDataUpload' => 'You can use this to upload a txt file that contains a list of token ID ranges, one per line.', - 'claim_token.field.claimQuantity' => 'The total amount of times each token ID can be claimed. This is mainly relevant for fungible tokens, where you can specify that there are a certain amount of claims for a token ID, e.g. 10 individual claims to receive 1 token with ID 123 per claim.', + 'claim_token.field.claimQuantity' => 'The total amount of times each token ID can be claimed. This is mainly relevant for fungible tokens, where you can specify that there are a certain amount of claims for a token ID, e.g. 10 individual claims to receive 1 token with ID 123 per claim. This field will be ingored when creating beam packs.', 'claim_token.field.tokenQuantityPerClaim' => 'The quantity of token that can be received per claim.', 'beam_pack.description' => 'The beam pack.', 'beam_pack.field.id' => 'The beam pack database ID, which can be null when creating a new beam pack.', From fc643333933ba5c4e3c4ddc2776c7ef7134f5713 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 19 Aug 2024 16:27:22 +0800 Subject: [PATCH 13/24] Fix max token count validation --- src/Rules/MaxTokenCount.php | 69 +++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 1572d11..272e976 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -7,10 +7,10 @@ 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; class MaxTokenCount implements DataAwareRule, ValidationRule { @@ -33,35 +33,52 @@ 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); + /** + * The sum of existing tokens, tokens in beams and tokens to be created + * must not exceed the collection's max token count. + */ + if ($this->collectionId + && ($collection = Collection::withCount('tokens')->firstWhere(['collection_chain_id' => $this->collectionId])) + && ! is_null($this->limit = $collection->max_token_count) + ) { + $existingCount = 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(); - return $val + ( - $range === false - ? $token['claimQuantity'] - : (($range[1] - $range[0]) + 1) * $token['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) - ->count(); + [$integers, $ranges] = collect($this->data['tokens']) + ->pluck('tokenIds') + ->flatten() + ->partition(fn ($val) => $this->integerRange($val) === false); + + $createTokenTotal = 0; + if (count($integers)) { + $createTokenTotal = Token::where('collection_id', $collection->id) + ->whereNotIn('token_chain_id', $integers->pluck('tokenIds')) + ->count(); + } - if (! $passes) { - $fail('enjin-platform-beam::validation.max_token_count') - ->translate([ - 'limit' => $this->limit, - ]); + if (count($ranges)) { + foreach ($ranges as $range) { + [$from, $to] = $this->integerRange($range); + $count = Token::where('collection_id', $collection->id) + ->whereBetween('token_chain_id', [(int) $from, (int) $to]) + ->count(); + $createTokenTotal += (($to - $from) + 1) - $count; } } + + $passes = $collection->max_token_count >= $collection->tokens_count + $existingCount + $createTokenTotal; + if (! $passes) { + $fail('enjin-platform-beam::validation.max_token_count')->translate(['limit' => $this->limit]); + } } } } From a1aac1c2ab074b930d08d0bde2a3969662a228a4 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Tue, 20 Aug 2024 16:53:18 +0800 Subject: [PATCH 14/24] PR comments --- lang/en/input_type.php | 3 ++- lang/en/type.php | 3 +++ lang/en/union.php | 5 +++++ src/GraphQL/Traits/HasTokenInputRules.php | 9 +++++++++ src/GraphQL/Types/BeamPackType.php | 4 ++-- src/GraphQL/Types/Input/BeamPackInputType.php | 5 +++++ .../Types/Input/ClaimTokenInputType.php | 1 - src/GraphQL/Unions/ClaimUnion.php | 2 +- src/Rules/MaxTokenCount.php | 6 ++++-- src/Services/BeamService.php | 18 +++++++++++++----- 10 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 lang/en/union.php diff --git a/lang/en/input_type.php b/lang/en/input_type.php index 8521004..6d059fd 100644 --- a/lang/en/input_type.php +++ b/lang/en/input_type.php @@ -7,8 +7,9 @@ 'claim_token.description' => 'The claimable tokens.', 'claim_token.field.tokenId' => 'The token chain IDs available to claim.', 'claim_token.field.tokenIdDataUpload' => 'You can use this to upload a txt file that contains a list of token ID ranges, one per line.', - 'claim_token.field.claimQuantity' => 'The total amount of times each token ID can be claimed. This is mainly relevant for fungible tokens, where you can specify that there are a certain amount of claims for a token ID, e.g. 10 individual claims to receive 1 token with ID 123 per claim. This field will be ingored when creating beam packs.', + 'claim_token.field.claimQuantity' => 'The total amount of times each token ID can be claimed. This is mainly relevant for fungible tokens, where you can specify that there are a certain amount of claims for a token ID, e.g. 10 individual claims to receive 1 token with ID 123 per claim.', 'claim_token.field.tokenQuantityPerClaim' => 'The quantity of token that can be received per claim.', 'beam_pack.description' => 'The beam pack.', 'beam_pack.field.id' => 'The beam pack database ID, which can be null when creating a new beam pack.', + 'beam_pack.field.beam_pack' => 'The number of times this pack can be claimed.', ]; diff --git a/lang/en/type.php b/lang/en/type.php index ba90731..7e878d0 100644 --- a/lang/en/type.php +++ b/lang/en/type.php @@ -29,4 +29,7 @@ 'beam_scan.field.walletPublicKey' => 'The wallet public key.', 'integer_range.description' => "A string value that can be used to represent a range of integer numbers. Use a double full stop to supply a range between 2 integers. \n\nExample \[\"1\",\"2\",\"3..8\"\]", 'attribute.description' => 'An initial attribute to set for the token when minting on demand.', + 'beam_pack.description' => 'The beam pack.', + 'beam_pack.field.id' => 'The beam pack internal ID.', + 'beam_pack.field.isClaimed' => 'The flag that determines if the beam pack is claimed.', ]; diff --git a/lang/en/union.php b/lang/en/union.php new file mode 100644 index 0000000..099163e --- /dev/null +++ b/lang/en/union.php @@ -0,0 +1,5 @@ + 'The beam claim can be of either type: BeamPack or BeamClaim', +]; diff --git a/src/GraphQL/Traits/HasTokenInputRules.php b/src/GraphQL/Traits/HasTokenInputRules.php index fa8ff78..4506d89 100644 --- a/src/GraphQL/Traits/HasTokenInputRules.php +++ b/src/GraphQL/Traits/HasTokenInputRules.php @@ -154,6 +154,15 @@ public function packTokenRules(array $args, ?string $collectionId = null, bool $ 'min:1', new MaxTokenSupply($collectionId), ], + 'packs.*.tokens.*.claimQuantity' => [ + 'prohibited', + ], + 'packs.claimQuantity' => [ + 'bail', + 'integer', + 'min:1', + 'max:1000', + ], ]; } } diff --git a/src/GraphQL/Types/BeamPackType.php b/src/GraphQL/Types/BeamPackType.php index ee83104..859f39f 100644 --- a/src/GraphQL/Types/BeamPackType.php +++ b/src/GraphQL/Types/BeamPackType.php @@ -32,7 +32,7 @@ public function fields(): array return [ 'id' => [ 'type' => GraphQL::type('Int'), - 'description' => __('enjin-platform-beam::type.beam_claim.field.id'), + 'description' => __('enjin-platform-beam::type.beam_pack.field.id'), ], 'code' => [ 'type' => GraphQL::type('String!'), @@ -42,7 +42,7 @@ public function fields(): array ], 'isClaimed' => [ 'type' => GraphQL::type('Boolean!'), - 'description' => __('enjin-platform-beam::type.beam_claim.field.code'), + 'description' => __('enjin-platform-beam::type.beam_pack.field.isClaimed'), 'alias' => 'is_claimed', ], 'beam' => [ diff --git a/src/GraphQL/Types/Input/BeamPackInputType.php b/src/GraphQL/Types/Input/BeamPackInputType.php index 3e3a083..02b73f1 100644 --- a/src/GraphQL/Types/Input/BeamPackInputType.php +++ b/src/GraphQL/Types/Input/BeamPackInputType.php @@ -31,6 +31,11 @@ public function fields(): array 'type' => GraphQL::type('[ClaimToken!]!'), 'description' => __('enjin-platform-beam::input_type.claim_token.description'), ], + 'claimQuantity' => [ + 'type' => GraphQL::type('Int'), + 'description' => __('enjin-platform-beam::input_type.beam_pack.field.beam_pack'), + 'defaultValue' => 1, + ], ]; } } diff --git a/src/GraphQL/Types/Input/ClaimTokenInputType.php b/src/GraphQL/Types/Input/ClaimTokenInputType.php index f90b001..e7f3d65 100644 --- a/src/GraphQL/Types/Input/ClaimTokenInputType.php +++ b/src/GraphQL/Types/Input/ClaimTokenInputType.php @@ -53,7 +53,6 @@ public function fields(): array 'type' => GraphQL::type('Int'), 'description' => __('enjin-platform-beam::input_type.claim_token.field.claimQuantity'), 'rules' => ['integer'], - 'defaultValue' => 1, ], 'type' => [ 'type' => GraphQL::type('BeamType'), diff --git a/src/GraphQL/Unions/ClaimUnion.php b/src/GraphQL/Unions/ClaimUnion.php index 60d03d2..a4a43d5 100644 --- a/src/GraphQL/Unions/ClaimUnion.php +++ b/src/GraphQL/Unions/ClaimUnion.php @@ -16,7 +16,7 @@ public function attributes(): array { return [ 'name' => 'ClaimUnion', - 'description' => __('enjin-platform-marketplace::union.listing_data.description'), + 'description' => __('enjin-platform-beam::union.claim_union.description'), ]; } diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 1572d11..7041319 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -42,10 +42,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 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 - ? $token['claimQuantity'] - : (($range[1] - $range[0]) + 1) * $token['claimQuantity'] + ? $claimQuantity + : (($range[1] - $range[0]) + 1) * $claimQuantity ); }, $carry); }, 0) diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 1f187e3..c4fbdbd 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -29,6 +29,7 @@ use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\LazyCollection; @@ -125,7 +126,10 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) 'nonce' => 1, ]); - $tokens = collect($pack['tokens']); + $tokens = Collection::times( + Arr::get($pack, 'claimQuantity', 1), + fn () => $pack['tokens'] + )->flatMap(fn ($rows) => $rows); $tokenIds = $tokens->whereNotNull('tokenIds'); if ($tokenIds->count()) { @@ -537,10 +541,12 @@ protected function createClaims(Model $beam, array $tokens): int return collect($token['tokenIds'])->reduce(function ($val, $tokenId) use ($token) { $range = $this->integerRange($tokenId); + $claimQuantity = Arr::get($token, 'claimQuantity', 1); + return $val + ( $range === false - ? $token['claimQuantity'] - : (($range[1] - $range[0]) + 1) * $token['claimQuantity'] + ? $claimQuantity + : (($range[1] - $range[0]) + 1) * $claimQuantity ); }, $carry); }, $totalClaimCount); @@ -567,10 +573,12 @@ protected function createClaims(Model $beam, array $tokens): int $totalClaimCount = $tokenIds->reduce(function ($carry, $tokenId) use ($token) { $range = $this->integerRange($tokenId); + $claimQuantity = Arr::get($token, 'claimQuantity', 1); + return $carry + ( $range === false - ? $token['claimQuantity'] - : (($range[1] - $range[0]) + 1) * $token['claimQuantity'] + ? $claimQuantity + : (($range[1] - $range[0]) + 1) * $claimQuantity ); }, $totalClaimCount); unset($token['tokenIdDataUpload']); From b21e04eff1fd5ccc1b28024155f57ab9d6cc0a73 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Tue, 20 Aug 2024 17:23:02 +0800 Subject: [PATCH 15/24] PR comments --- src/Services/BeamService.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index c4fbdbd..c933634 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -120,7 +120,7 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) $quantity++; } - $model = BeamPack::firstOrcreate(['id' => $id], [ + $beamPack = BeamPack::firstOrcreate(['id' => $id], [ 'beam_id' => $beam->id, 'code' => bin2hex(openssl_random_pseudo_bytes(16)), 'nonce' => 1, @@ -134,13 +134,13 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) if ($tokenIds->count()) { $allTokenIds = $tokenIds->pluck('tokenIds')->flatten()->all(); - DispatchCreateBeamClaimsJobs::dispatch($beam, $tokenIds->all(), $model->id)->afterCommit(); + DispatchCreateBeamClaimsJobs::dispatch($beam, $tokenIds->all(), $beamPack->id)->afterCommit(); } $tokenUploads = $tokens->whereNotNull('tokenIdDataUpload'); if ($tokenUploads->count()) { $ids = $tokenIds->pluck('tokenIds'); - $tokenUploads->each(function ($token) use ($beam, $ids, $model) { + $tokenUploads->each(function ($token) use ($beam, $ids, $beamPack) { LazyCollection::make(function () use ($token, $ids) { $handle = fopen($token['tokenIdDataUpload']->getPathname(), 'r'); while (($line = fgets($handle)) !== false) { @@ -150,10 +150,10 @@ public function createPackClaims(Model $beam, array $packs, bool $isNew = false) } } fclose($handle); - })->chunk(10000)->each(function (LazyCollection $tokenIds) use ($beam, $token, $model) { + })->chunk(10000)->each(function (LazyCollection $tokenIds) use ($beam, $token, $beamPack) { $token['tokenIds'] = $tokenIds->all(); unset($token['tokenIdDataUpload']); - DispatchCreateBeamClaimsJobs::dispatch($beam, [$token], $model->id)->afterCommit(); + DispatchCreateBeamClaimsJobs::dispatch($beam, [$token], $beamPack->id)->afterCommit(); unset($tokenIds, $token); }); }); From 733748b013a368aaeb1e60e018523398615e74ca Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Wed, 21 Aug 2024 12:35:33 +0800 Subject: [PATCH 16/24] Fix test --- src/Rules/MaxTokenCount.php | 28 ++++++++++++++++++- .../GraphQL/Mutations/CreateBeamTest.php | 6 ++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 272e976..fa3c6aa 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -11,6 +11,7 @@ use Enjin\Platform\Rules\Traits\HasDataAwareRule; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Support\Arr; class MaxTokenCount implements DataAwareRule, ValidationRule { @@ -41,6 +42,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void && ($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; + } + $existingCount = BeamClaim::where('type', BeamType::MINT_ON_DEMAND->name) ->whereHas( 'beam', @@ -53,7 +60,26 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ->groupBy('token_chain_id') ->count(); - [$integers, $ranges] = collect($this->data['tokens']) + $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) + ->filter(fn ($val) => !empty(Arr::get($val, 'tokenIds'))) ->pluck('tokenIds') ->flatten() ->partition(fn ($val) => $this->integerRange($val) === false); diff --git a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php index 25f2fe2..44ba5c3 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -343,18 +343,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']); } /** From 7d3d3278c85d7dc4e8c661daa6e570ecf04ef6c5 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Wed, 21 Aug 2024 13:54:20 +0800 Subject: [PATCH 17/24] Refactor max token supply rule --- src/Rules/MaxTokenCount.php | 4 +- src/Rules/MaxTokenSupply.php | 147 ++++++++---------- .../GraphQL/Mutations/CreateBeamTest.php | 8 +- 3 files changed, 68 insertions(+), 91 deletions(-) diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index fa3c6aa..373b5d8 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -48,7 +48,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $existingCount = BeamClaim::where('type', BeamType::MINT_ON_DEMAND->name) + $claimCount = BeamClaim::where('type', BeamType::MINT_ON_DEMAND->name) ->whereHas( 'beam', fn ($query) => $query->where('collection_chain_id', $this->collectionId)->where('end', '>', now()) @@ -101,7 +101,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } - $passes = $collection->max_token_count >= $collection->tokens_count + $existingCount + $createTokenTotal; + $passes = $collection->max_token_count >= $collection->tokens_count + $claimCount + $createTokenTotal; if (! $passes) { $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..8bc2d9b 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])) + && ($collection = Collection::withCount('tokens')->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 44ba5c3..315279e 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -374,16 +374,14 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void $response['error'] ); - $this->collection->update(['max_token_supply' => 2]); $response = $this->graphql( $this->method, - $data = $this->generateBeamData(BeamType::TRANSFER_TOKEN, 1), + $this->generateBeamData(BeamType::TRANSFER_TOKEN, 1), + true ); $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 each token for this collection.']], $response['error'] ); } From de2ab5b59bc1fd3c6dfa2c166b899f3520055a5f Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Wed, 21 Aug 2024 15:44:54 +0800 Subject: [PATCH 18/24] Fix tests --- lang/en/validation.php | 2 +- src/Rules/MaxTokenCount.php | 60 +++++++++++++------ src/Rules/MaxTokenSupply.php | 2 +- .../GraphQL/Mutations/CreateBeamTest.php | 4 +- .../GraphQL/Mutations/UpdateBeamTest.php | 41 ------------- 5 files changed, 47 insertions(+), 62 deletions(-) diff --git a/lang/en/validation.php b/lang/en/validation.php index 47a65e5..aafac27 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 373b5d8..282d863 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -12,6 +12,7 @@ use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Arr; +use Illuminate\Support\LazyCollection; class MaxTokenCount implements DataAwareRule, ValidationRule { @@ -35,8 +36,8 @@ public function __construct(protected ?string $collectionId) {} public function validate(string $attribute, mixed $value, Closure $fail): void { /** - * The sum of existing tokens, tokens in beams and tokens to be created - * must not exceed the collection's max token count. + * 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])) @@ -65,7 +66,6 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ->pluck('tokenIds') ->flatten(); - collect($this->data['tokens']) ->filter(fn ($data) => !empty(Arr::get($data, 'tokenIdDataUpload'))) ->map(function ($data) use ($tokens) { @@ -78,31 +78,57 @@ public function validate(string $attribute, mixed $value, Closure $fail): void fclose($handle); }); - [$integers, $ranges] = collect($tokens) - ->filter(fn ($val) => !empty(Arr::get($val, 'tokenIds'))) - ->pluck('tokenIds') - ->flatten() - ->partition(fn ($val) => $this->integerRange($val) === false); + [$integers, $ranges] = collect($tokens)->partition(fn ($val) => $this->integerRange($val) === false); $createTokenTotal = 0; - if (count($integers)) { - $createTokenTotal = Token::where('collection_id', $collection->id) - ->whereNotIn('token_chain_id', $integers->pluck('tokenIds')) - ->count(); + 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 (count($ranges)) { + if ($ranges->count()) { foreach ($ranges as $range) { [$from, $to] = $this->integerRange($range); - $count = Token::where('collection_id', $collection->id) + $existingTokensCount = Token::where('collection_id', $collection->id) ->whereBetween('token_chain_id', [(int) $from, (int) $to]) ->count(); - $createTokenTotal += (($to - $from) + 1) - $count; + + 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(); + } + }); } } - $passes = $collection->max_token_count >= $collection->tokens_count + $claimCount + $createTokenTotal; - if (! $passes) { + $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 8bc2d9b..480c70e 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -114,7 +114,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void }, $tokenCount); - if (! $this->limit >= $balanceCount + $claimCount + $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 315279e..0c80ccc 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -370,7 +370,7 @@ 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'] ); @@ -381,7 +381,7 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void ); $this->assertNotEmpty($response); $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'] ); } diff --git a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php index 071414a..c7eef17 100644 --- a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php @@ -13,7 +13,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; @@ -255,46 +254,6 @@ public function test_it_will_fail_existing_tokens(): void ['tokens.0.tokenIds' => ['The tokens.0.tokenIds exists in the specified collection.']], $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] - ); - $response = $this->graphql($this->method, $updates, true); - $this->assertArraySubset( - ['tokens.0.tokenIds' => ['The tokens.0.tokenIds already exist in beam.']], - $response['error'] - ); } /** From c4233d18cd0804fc5ee50d485af6adc7e826be4e Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Wed, 21 Aug 2024 15:48:02 +0800 Subject: [PATCH 19/24] Remove unused data --- src/Rules/MaxTokenSupply.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rules/MaxTokenSupply.php b/src/Rules/MaxTokenSupply.php index 480c70e..963102a 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -50,7 +50,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void * the total circulating supply must not exceed 100. */ if ($this->collectionId - && ($collection = Collection::withCount('tokens')->firstWhere(['collection_chain_id' => $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 From 4ee7a84193b29d20cdcbe13c91f923e90ab2d365 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Wed, 21 Aug 2024 15:49:50 +0800 Subject: [PATCH 20/24] Make sure tokens are unique --- src/Rules/MaxTokenCount.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 282d863..3cb1d1f 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -78,7 +78,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void fclose($handle); }); - [$integers, $ranges] = collect($tokens)->partition(fn ($val) => $this->integerRange($val) === false); + [$integers, $ranges] = collect($tokens)->unique()->partition(fn ($val) => $this->integerRange($val) === false); $createTokenTotal = 0; if ($integers->count()) { From a3483802c47b12120b70bbd970cd27be19f68cdf Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Thu, 5 Sep 2024 07:19:38 +0800 Subject: [PATCH 21/24] Fix validation --- src/Rules/MaxTokenSupply.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Rules/MaxTokenSupply.php b/src/Rules/MaxTokenSupply.php index 963102a..cfb0a09 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -49,6 +49,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void * 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 (!Arr::get($this->data, 'tokens')) { + return; + } + + + if ($this->collectionId && ($collection = Collection::firstWhere(['collection_chain_id' => $this->collectionId])) && ! is_null($this->limit = $collection->max_token_supply) From 023739bc377a8a25bafe26d4e65e3ffaa78500b7 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 9 Sep 2024 12:25:36 +0800 Subject: [PATCH 22/24] Fix unit test --- src/Rules/MaxTokenCount.php | 4 ++++ src/Rules/MaxTokenSupply.php | 2 -- src/Support/ClaimProbabilities.php | 6 +++--- .../GraphQL/Mutations/CreateBeamTest.php | 18 +++++++++++------- .../GraphQL/Mutations/UpdateBeamTest.php | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 3cb1d1f..597689b 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -39,6 +39,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void * 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 (!Arr::get($this->data, 'tokens')) { + return; + } + if ($this->collectionId && ($collection = Collection::withCount('tokens')->firstWhere(['collection_chain_id' => $this->collectionId])) && ! is_null($this->limit = $collection->max_token_count) diff --git a/src/Rules/MaxTokenSupply.php b/src/Rules/MaxTokenSupply.php index cfb0a09..2dd8a7e 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -53,8 +53,6 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - - if ($this->collectionId && ($collection = Collection::firstWhere(['collection_chain_id' => $this->collectionId])) && ! is_null($this->limit = $collection->max_token_supply) diff --git a/src/Support/ClaimProbabilities.php b/src/Support/ClaimProbabilities.php index 1bd248c..ba86639 100644 --- a/src/Support/ClaimProbabilities.php +++ b/src/Support/ClaimProbabilities.php @@ -167,8 +167,8 @@ protected function formatClaims(Collection $claims): array foreach ($claim['tokenIds'] as $tokenId) { $range = $this->integerRange($tokenId); $tokens[$tokenId] = $range !== false - ? (($range[1] - $range[0]) + 1) * $claim['claimQuantity'] - : $claim['claimQuantity']; + ? (($range[1] - $range[0]) + 1) * Arr::get($claim, 'claimQuantity', 1) + : Arr::get($claim, 'claimQuantity', 1); } } @@ -181,7 +181,7 @@ protected function formatClaims(Collection $claims): array protected function filterClaims(array $claims): array { [$nfts, $fts] = collect($claims)->partition( - fn ($claim) => $claim['claimQuantity'] == 1 && Arr::get($claim, 'tokenQuantityPerClaim', 1) == 1 + fn ($claim) => Arr::get($claim, 'claimQuantity', 1) == 1 && Arr::get($claim, 'tokenQuantityPerClaim', 1) == 1 ); return [ diff --git a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php index 1ba4da0..1eb349a 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -483,7 +483,7 @@ public function test_it_will_fail_with_invalid_ownership(): void public function test_it_will_fail_with_invalid_claim_quantity(): void { $this->prepareCollectionData(); - $this->collection->update(['max_token_count' => 0]); + $this->collection->update(['max_token_count' => 0, 'max_token_supply' => 0]); $response = $this->graphql( $this->method, $this->generateBeamData(BeamType::MINT_ON_DEMAND, 10), @@ -502,7 +502,11 @@ public function test_it_will_fail_with_invalid_claim_quantity(): void $this->assertNotEmpty($response); $response = $this->graphql($this->method, $data, true); - $this->assertArraySubset(['tokens.0.claimQuantity' => ['The token count exceeded the maximum limit of 0 for this collection.']], $response['error']); + $this->assertArraySubset([ + 'tokens.0.tokenQuantityPerClaim' => [ + 'The tokens.0.tokenQuantityPerClaim exceeded the maximum supply limit of 0 for unique tokens for this collection.', + ], + ], $response['error']); } /** @@ -533,11 +537,11 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void $response['error'] ); - $response = $this->graphql($this->method, $this->generateBeamPackData(), true); - $this->assertArraySubset( - ['packs.0.tokens.0.tokenQuantityPerClaim' => ['The packs.0.tokens.0.tokenQuantityPerClaim is invalid, the amount provided is bigger than the token account balance.']], - $response['error'] - ); + // $response = $this->graphql($this->method, $this->generateBeamPackData(), true); + // $this->assertArraySubset( + // ['packs.0.tokens.0.tokenQuantityPerClaim' => ['The packs.0.tokens.0.tokenQuantityPerClaim is invalid, the amount provided is bigger than the token account balance.']], + // $response['error'] + // ); } /** diff --git a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php index 8d723d6..77ca95e 100644 --- a/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/UpdateBeamTest.php @@ -316,7 +316,7 @@ public function test_it_will_fail_existing_tokens(): void ); $response = $this->graphql($this->method, $updates, true); $this->assertArraySubset( - ['tokens.0.tokenIds' => ['The tokens.0.tokenIds exists in the specified collection.']], + ['tokens.0.tokenIds' => ['The tokens.0.tokenIds already exist in beam.']], $response['error'] ); } From f9f3d893d057015b37ca73e908f0acc4bb7c19a6 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 9 Sep 2024 12:32:49 +0800 Subject: [PATCH 23/24] lint --- src/Rules/BeamPackMaxTokenSupply.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Rules/BeamPackMaxTokenSupply.php b/src/Rules/BeamPackMaxTokenSupply.php index 87b9fc7..35c2573 100644 --- a/src/Rules/BeamPackMaxTokenSupply.php +++ b/src/Rules/BeamPackMaxTokenSupply.php @@ -5,12 +5,8 @@ use Closure; use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\Models\BeamClaim; -use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Enjin\Platform\Models\Collection; use Enjin\Platform\Models\TokenAccount; -use Enjin\Platform\Rules\Traits\HasDataAwareRule; -use Illuminate\Contracts\Validation\DataAwareRule; -use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Arr; class BeamPackMaxTokenSupply extends MaxTokenSupply From 43129a0cc8c5aea9bd8f5c8017455ba5126cfb95 Mon Sep 17 00:00:00 2001 From: Abner Tudtud Date: Mon, 9 Sep 2024 14:42:31 +0800 Subject: [PATCH 24/24] Fix validation --- src/GraphQL/Traits/HasTokenInputRules.php | 7 +- src/Rules/BeamPackMaxTokenCount.php | 13 +++ src/Rules/BeamPackMaxTokenSupply.php | 87 +------------------ src/Rules/MaxTokenCount.php | 11 ++- src/Rules/MaxTokenSupply.php | 9 +- .../GraphQL/Mutations/CreateBeamTest.php | 10 +-- 6 files changed, 40 insertions(+), 97 deletions(-) create mode 100644 src/Rules/BeamPackMaxTokenCount.php diff --git a/src/GraphQL/Traits/HasTokenInputRules.php b/src/GraphQL/Traits/HasTokenInputRules.php index 4506d89..2996ade 100644 --- a/src/GraphQL/Traits/HasTokenInputRules.php +++ b/src/GraphQL/Traits/HasTokenInputRules.php @@ -14,6 +14,8 @@ use Illuminate\Support\Arr; use Illuminate\Validation\Rule; use Enjin\Platform\Beam\Rules\BeamPackExistInBeam; +use Enjin\Platform\Beam\Rules\BeamPackMaxTokenCount; +use Enjin\Platform\Beam\Rules\BeamPackMaxTokenSupply; trait HasTokenInputRules { @@ -152,16 +154,17 @@ public function packTokenRules(array $args, ?string $collectionId = null, bool $ 'filled', 'integer', 'min:1', - new MaxTokenSupply($collectionId), + new BeamPackMaxTokenSupply($collectionId), ], 'packs.*.tokens.*.claimQuantity' => [ 'prohibited', ], - 'packs.claimQuantity' => [ + 'packs.*.claimQuantity' => [ 'bail', 'integer', 'min:1', 'max:1000', + new BeamPackMaxTokenCount($collectionId), ], ]; } diff --git a/src/Rules/BeamPackMaxTokenCount.php b/src/Rules/BeamPackMaxTokenCount.php new file mode 100644 index 0000000..23043ed --- /dev/null +++ b/src/Rules/BeamPackMaxTokenCount.php @@ -0,0 +1,13 @@ +data, 'packs'))->flatMap(fn ($row) => Arr::get($row, 'tokens', []))->toArray(); + } +} diff --git a/src/Rules/BeamPackMaxTokenSupply.php b/src/Rules/BeamPackMaxTokenSupply.php index 35c2573..cf03634 100644 --- a/src/Rules/BeamPackMaxTokenSupply.php +++ b/src/Rules/BeamPackMaxTokenSupply.php @@ -2,95 +2,12 @@ namespace Enjin\Platform\Beam\Rules; -use Closure; -use Enjin\Platform\Beam\Enums\BeamType; -use Enjin\Platform\Beam\Models\BeamClaim; -use Enjin\Platform\Models\Collection; -use Enjin\Platform\Models\TokenAccount; use Illuminate\Support\Arr; class BeamPackMaxTokenSupply extends MaxTokenSupply { - /** - * Determine if the validation rule passes. - * - * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail - */ - public function validate(string $attribute, mixed $value, Closure $fail): void + protected function getInputTokens(): array { - /** - * 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 - && !$collection->max_token_supply >= $value) - || $this->limit == 0 - ) { - $fail($this->maxTokenSupplyMessage)->translate(['limit' => $this->limit]); - - return; - } - - if ($collection->max_token_count == 0) { - $fail('enjin-platform-beam::validation.max_token_count')->translate(['limit' => $this->limit]); - - return; - } - - $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]); - } - } + return collect(Arr::get($this->data, 'packs'))->flatMap(fn ($row) => Arr::get($row, 'tokens', []))->toArray(); } } diff --git a/src/Rules/MaxTokenCount.php b/src/Rules/MaxTokenCount.php index 597689b..cf0f7b0 100644 --- a/src/Rules/MaxTokenCount.php +++ b/src/Rules/MaxTokenCount.php @@ -39,7 +39,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void * 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 (!Arr::get($this->data, 'tokens')) { + if (!$inputTokens = $this->getInputTokens()) { return; } @@ -65,12 +65,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ->groupBy('token_chain_id') ->count(); - $tokens = collect($this->data['tokens']) + $tokens = collect($inputTokens) ->filter(fn ($data) => !empty(Arr::get($data, 'tokenIds'))) ->pluck('tokenIds') ->flatten(); - collect($this->data['tokens']) + collect($inputTokens) ->filter(fn ($data) => !empty(Arr::get($data, 'tokenIdDataUpload'))) ->map(function ($data) use ($tokens) { $handle = fopen($data['tokenIdDataUpload']->getPathname(), 'r'); @@ -137,4 +137,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } } + + protected function getInputTokens(): array + { + return Arr::get($this->data, 'tokens', []); + } } diff --git a/src/Rules/MaxTokenSupply.php b/src/Rules/MaxTokenSupply.php index 2dd8a7e..a876a9e 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -49,7 +49,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void * 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 (!Arr::get($this->data, 'tokens')) { + if (!$inputTokens = $this->getInputTokens()) { return; } @@ -81,7 +81,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ->sum('quantity'); $tokenCount = 0; - $tokenCount = collect($this->data['tokens']) + $tokenCount = collect($inputTokens) ->reduce(function ($carry, $token) { if (Arr::get($token, 'tokenIds')) { @@ -123,4 +123,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } } + + protected function getInputTokens(): array + { + return Arr::get($this->data, 'tokens', []); + } } diff --git a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php index 1eb349a..b726032 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -537,11 +537,11 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void $response['error'] ); - // $response = $this->graphql($this->method, $this->generateBeamPackData(), true); - // $this->assertArraySubset( - // ['packs.0.tokens.0.tokenQuantityPerClaim' => ['The packs.0.tokens.0.tokenQuantityPerClaim is invalid, the amount provided is bigger than the token account balance.']], - // $response['error'] - // ); + $response = $this->graphql($this->method, $this->generateBeamPackData(), true); + $this->assertArraySubset( + ['packs.0.tokens.0.tokenQuantityPerClaim' => ['The packs.0.tokens.0.tokenQuantityPerClaim exceeded the maximum supply limit of 0 for unique tokens for this collection.']], + $response['error'] + ); } /**