diff --git a/database/factories/BeamPackFactory.php b/database/factories/BeamPackFactory.php new file mode 100644 index 0000000..a1c4c11 --- /dev/null +++ b/database/factories/BeamPackFactory.php @@ -0,0 +1,28 @@ + + */ + public function definition() + { + return [ + 'is_claimed' => fake()->boolean(), + ]; + } +} diff --git a/database/migrations/2024_07_01_011034_add_is_pack_column_to_beams_table.php b/database/migrations/2024_07_01_011034_add_is_pack_column_to_beams_table.php new file mode 100644 index 0000000..1d10aab --- /dev/null +++ b/database/migrations/2024_07_01_011034_add_is_pack_column_to_beams_table.php @@ -0,0 +1,27 @@ +boolean('is_pack')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('beams', function (Blueprint $table) { + $table->dropColumn('is_pack'); + }); + } +}; diff --git a/database/migrations/2024_07_01_020155_create_beam_packs_table.php b/database/migrations/2024_07_01_020155_create_beam_packs_table.php new file mode 100644 index 0000000..78e5a3f --- /dev/null +++ b/database/migrations/2024_07_01_020155_create_beam_packs_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('beam_id')->constrained()->cascadeOnDelete(); + $table->string('code')->index()->nullable(); + $table->unsignedInteger('nonce')->nullable(); + $table->boolean('is_claimed')->default(false)->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('beam_packs'); + } +}; diff --git a/database/migrations/2024_07_01_020426_add_beam_pack_id_to_beam_claims_table.php b/database/migrations/2024_07_01_020426_add_beam_pack_id_to_beam_claims_table.php new file mode 100644 index 0000000..31836b3 --- /dev/null +++ b/database/migrations/2024_07_01_020426_add_beam_pack_id_to_beam_claims_table.php @@ -0,0 +1,30 @@ +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('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..40ceef3 100644 --- a/lang/en/input_type.php +++ b/lang/en/input_type.php @@ -5,8 +5,9 @@ 'beam_flag.field.flag' => 'The beam flag.', 'beam_flag.field.enabled' => 'The flag enabled status.', 'claim_token.description' => 'The claimable tokens.', + 'claim_token_pack.description' => 'The claimable tokens in a beam pack.', '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. Ignored when creating a beam pack.', 'claim_token.field.tokenQuantityPerClaim' => 'The quantity of token that can be received per claim.', ]; diff --git a/lang/en/mutation.php b/lang/en/mutation.php index 47fa971..17bb2b9 100644 --- a/lang/en/mutation.php +++ b/lang/en/mutation.php @@ -18,6 +18,8 @@ 'create_beam.args.collectionId' => 'The collection ID.', 'create_beam.args.tokenIds' => 'The token chain IDs to claim.', 'create_beam.description' => 'Mutation for creating a beam.', + 'create_beam.args.quantity' => 'The number of beam packs to create. This is ignored when creating a single beam.', + 'create_beam.args.isPack' => 'The flag for creating a beam pack.', 'update_beam.args.flags' => 'The beam flags that should be enabled disabled.', 'update_beam.description' => 'Mutation for updating a beam.', 'claim_beam.field.claimedAt' => 'The claim timestamp.', diff --git a/lang/en/query.php b/lang/en/query.php index 19c30f0..c04342e 100644 --- a/lang/en/query.php +++ b/lang/en/query.php @@ -6,4 +6,5 @@ 'get_single_use_codes.description' => 'Get single use codes.', 'get_claims.description' => 'Get the claims details.', 'get_pending_claims.description' => 'Get a list of pending claims for a Beam.', + 'get_beam_pack_single_use_codes.description' => 'Get single use codes for a Beam Pack.', ]; diff --git a/lang/en/type.php b/lang/en/type.php index ba90731..0bb1360 100644 --- a/lang/en/type.php +++ b/lang/en/type.php @@ -29,4 +29,6 @@ '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.', + 'create_beam_pack.description' => 'The beam bunldle tokens.', + 'beam_pack.description' => 'The beam pack.', ]; diff --git a/src/BeamServiceProvider.php b/src/BeamServiceProvider.php index 84678cf..fb5d044 100644 --- a/src/BeamServiceProvider.php +++ b/src/BeamServiceProvider.php @@ -39,6 +39,9 @@ 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('add_is_pack_column_to_beams_table') + ->hasMigration('create_beam_packs_table') + ->hasMigration('add_beam_pack_id_to_beam_claims_table') ->hasRoute('enjin-platform-beam') ->hasTranslations(); } diff --git a/src/GraphQL/Mutations/CreateBeamMutation.php b/src/GraphQL/Mutations/CreateBeamMutation.php index ff5a6a6..c626e06 100644 --- a/src/GraphQL/Mutations/CreateBeamMutation.php +++ b/src/GraphQL/Mutations/CreateBeamMutation.php @@ -55,6 +55,12 @@ public function args(): array { return [ ...$this->getCommonFields(), + 'isPack' => [ + 'type' => GraphQL::type('Boolean'), + 'alias' => 'is_pack', + 'description' => __('enjin-platform-beam::mutation.create_beam.args.isPack'), + 'defaultValue' => false, + ], 'flags' => [ 'type' => GraphQL::type('[BeamFlagInputType!]'), 'description' => __('enjin-platform-beam::mutation.update_beam.args.flags'), @@ -68,6 +74,11 @@ public function args(): array 'type' => GraphQL::type('[ClaimToken!]!'), 'description' => __('enjin-platform-beam::input_type.claim_token.description'), ], + 'quantity' => [ + 'type' => GraphQL::type('Int'), + 'description' => __('enjin-platform-beam::mutation.create_beam.args.quantity'), + 'defaultValue' => 1, + ], ]; } @@ -90,7 +101,7 @@ public function resolve( */ protected function rules(array $args = []): array { - return [ + $rules = [ 'name' => ['filled', 'max:255'], 'description' => ['filled', 'max:1024'], 'image' => ['filled', 'url', 'max:1024'], @@ -154,14 +165,21 @@ function (string $attribute, mixed $value, Closure $fail) { 'min:1', new MaxTokenSupply($args['collectionId']), ], - 'tokens.*.claimQuantity' => [ + 'flags.*.flag' => ['required', 'distinct'], + ]; + + if ($args['isPack']) { + $rules['quantity'] = ['filled', 'integer', 'min:1', 'max:100']; + } else { + $rules['tokens.*.claimQuantity'] = [ 'bail', 'filled', 'integer', 'min:1', new MaxTokenCount($args['collectionId']), - ], - 'flags.*.flag' => ['required', 'distinct'], - ]; + ]; + } + + return $rules; } } 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/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/BeamType.php b/src/GraphQL/Types/BeamType.php index 222dd5d..36c60ee 100644 --- a/src/GraphQL/Types/BeamType.php +++ b/src/GraphQL/Types/BeamType.php @@ -126,6 +126,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, + ], ]; } } diff --git a/src/GraphQL/Types/Input/ClaimTokenPackInputType.php b/src/GraphQL/Types/Input/ClaimTokenPackInputType.php new file mode 100644 index 0000000..c49558b --- /dev/null +++ b/src/GraphQL/Types/Input/ClaimTokenPackInputType.php @@ -0,0 +1,59 @@ + 'ClaimTokenPack', + 'description' => __('enjin-platform-beam::input_type.claim_token_pack.description'), + ]; + } + + /** + * Get the input type's fields. + */ + public function fields(): array + { + return [ + 'tokenIds' => [ + 'type' => GraphQL::type('[IntegerRangeString!]'), + 'description' => __('enjin-platform-beam::input_type.claim_token.field.tokenId'), + 'rules' => [new MinBigIntIntegerRange(), new MaxBigIntIntegerRange(Hex::MAX_UINT128)], + ], + 'tokenIdDataUpload' => [ + 'type' => GraphQL::type('Upload'), + 'description' => __('enjin-platform-beam::input_type.claim_token.field.tokenIdDataUpload'), + 'rules' => ['file', 'mimes:json,txt'], + ], + 'attributes' => [ + 'type' => GraphQL::type('[AttributeInput]'), + 'description' => __('enjin-platform::input_type.create_token_params.field.attributes'), + 'defaultValue' => [], + ], + 'tokenQuantityPerClaim' => [ + 'alias' => 'quantity', + 'type' => GraphQL::type('Int'), + 'description' => __('enjin-platform-beam::input_type.claim_token.field.tokenQuantityPerClaim'), + 'rules' => ['integer'], + 'defaultValue' => 1, + ], + 'type' => [ + 'type' => GraphQL::type('BeamType'), + 'description' => __('enjin-platform-beam::mutation.common.args.type'), + 'defaultValue' => BeamType::TRANSFER_TOKEN->name, + ], + ]; + } +} 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/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/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(BeamScan::class, 'beam_id'); } + /** + * The beam pack' relationship. + */ + public function packs(): HasMany + { + return $this->hasMany(BeamPack::class, 'beam_id'); + } + /** * The collection relationship. */ diff --git a/src/Models/Laravel/BeamClaim.php b/src/Models/Laravel/BeamClaim.php index b0a82f0..055cb2e 100644 --- a/src/Models/Laravel/BeamClaim.php +++ b/src/Models/Laravel/BeamClaim.php @@ -4,7 +4,6 @@ use Enjin\Platform\Beam\Database\Factories\BeamClaimFactory; use Enjin\Platform\Beam\Enums\BeamFlag; -use Enjin\Platform\Beam\Enums\BeamRoute; use Enjin\Platform\Beam\Models\Laravel\Traits\HasSingleUseCodeScope; use Enjin\Platform\Beam\Services\BeamService; use Enjin\Platform\Models\BaseModel; @@ -12,8 +11,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; use Illuminate\Database\Eloquent\Prunable; @@ -21,7 +18,6 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Str; class BeamClaim extends BaseModel { @@ -61,6 +57,7 @@ class BeamClaim extends BaseModel 'code', 'nonce', 'idempotency_key', + 'beam_pack_id', ]; /** @@ -122,49 +119,19 @@ public function token(): BelongsTo } /** - * The collection's relationship. - */ - public function collection(): BelongsTo - { - return $this->belongsTo(Collection::class); - } - - /** - * 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. + * The beam pack's relationship. */ - public function singleUseCode(): Attribute + public function beamPack(): BelongsTo { - return Attribute::make( - get: fn () => encrypt(implode(':', [$this->code, $this->beam?->code, $this->nonce])) - ); + return $this->belongsTo(BeamPack::class, 'beam_pack_id'); } /** - * The claimable code, encoded with the Platform host url. + * The collection's relationship. */ - public function claimableCode(): Attribute + public function collection(): BelongsTo { - return Attribute::make( - get: fn () => secure_url(Str::replace('{code}', $this->singleUseCode, BeamRoute::CLAIM->value)) - ); + return $this->belongsTo(Collection::class); } /** diff --git a/src/Models/Laravel/BeamPack.php b/src/Models/Laravel/BeamPack.php new file mode 100644 index 0000000..62ecd15 --- /dev/null +++ b/src/Models/Laravel/BeamPack.php @@ -0,0 +1,136 @@ +|bool + */ + public $guarded = []; + + /** + * The fillable fields. + * + * @var array + */ + protected $fillable = [ + 'is_claimed', + 'beam_id', + 'code', + 'nonce', + ]; + + /** + * The hidden fields. + * + * @var array + */ + protected $hidden = [ + 'created_at', + 'updated_at', + 'beam_id', + ]; + + /** + * The beam's relationship. + */ + public function beam(): BelongsTo + { + return $this->belongsTo(Beam::class); + } + + /** + * The beam claim's relationship. + */ + public function claims(): HasMany + { + return $this->hasMany(BeamClaim::class, 'beam_pack_id'); + } + + public function scopeClaimable(Builder $query): Builder + { + return $query->where('is_claimed', false); + } + + /** + * 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(): BeamPackFactory + { + return BeamPackFactory::new(); + } +} diff --git a/src/Models/Laravel/Traits/HasSingleUseCodeScope.php b/src/Models/Laravel/Traits/HasSingleUseCodeScope.php index a499683..25a1e43 100644 --- a/src/Models/Laravel/Traits/HasSingleUseCodeScope.php +++ b/src/Models/Laravel/Traits/HasSingleUseCodeScope.php @@ -5,6 +5,7 @@ use Enjin\Platform\Beam\Services\BeamService; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Support\Arr; +use Illuminate\Database\Eloquent\Casts\Attribute; 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/HasBeamFlag.php b/src/Rules/HasBeamFlag.php index 4c0489a..9adcc57 100644 --- a/src/Rules/HasBeamFlag.php +++ b/src/Rules/HasBeamFlag.php @@ -22,7 +22,6 @@ public function __construct(protected BeamFlag $flag) {} public function validate(string $attribute, mixed $value, Closure $fail): void { $beam = Beam::whereCode($value)->first(); - if (! $beam || ! $beam->hasFlag($this->flag)) { $fail('enjin-platform-beam::validation.has_beam_flag')->translate(); } diff --git a/src/Rules/MaxTokenSupply.php b/src/Rules/MaxTokenSupply.php index be18804..c6ba541 100644 --- a/src/Rules/MaxTokenSupply.php +++ b/src/Rules/MaxTokenSupply.php @@ -37,7 +37,10 @@ class MaxTokenSupply implements DataAwareRule, ValidationRule /** * Create instance of rule. */ - public function __construct(protected ?string $collectionId) {} + public function __construct( + protected ?string $collectionId, + protected int $packQuantity = 1 + ) {} /** * Determine if the validation rule passes. @@ -46,16 +49,15 @@ public function __construct(protected ?string $collectionId) {} */ public function validate(string $attribute, mixed $value, Closure $fail): void { + $value *= $this->packQuantity; + if ($this->collectionId && ($collection = Collection::firstWhere(['collection_chain_id' => $this->collectionId])) && ! is_null($this->limit = $collection->max_token_supply) ) { if (Arr::get($this->data, str_replace('tokenQuantityPerClaim', 'type', $attribute)) == BeamType::MINT_ON_DEMAND->name) { if (! $collection->max_token_supply >= $value) { - $fail($this->maxTokenSupplyMessage) - ->translate([ - 'limit' => $this->limit, - ]); + $fail($this->maxTokenSupplyMessage)->translate(['limit' => $this->limit]); return; } 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 022f35d..1057143 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\Laravel\BeamPack; use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Enjin\Platform\Beam\Support\ClaimProbabilities; use Enjin\Platform\Support\BitMask; @@ -28,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; @@ -80,14 +82,29 @@ public static function getFlagsValue(?array $flags, int $initial = 0): ?int public function create(array $args): Model { $beam = Beam::create([ - ...Arr::except($args, ['tokens', 'flags']), + ...Arr::except($args, ['tokens', 'flags', 'quantity']), 'flags_mask' => static::getFlagsValue(Arr::get($args, 'flags')), 'code' => bin2hex(openssl_random_pseudo_bytes(16)), ]); if ($beam) { + if ($beam->is_pack) { + $quantity = Arr::get($args, 'quantity', 1); + $this->createClaimsPack( + Arr::get($args, 'tokens', []), + $beam->packs()->createMany(Collection::times($quantity, fn () => [ + 'beam_id' => $beam->id, + 'code' => bin2hex(openssl_random_pseudo_bytes(16)), + 'nonce' => 1, + ])), + $beam + ); + } else { + $quantity = $this->createClaims(Arr::get($args, 'tokens', []), $beam); + } + Cache::forever( self::key($beam->code), - $this->createClaims(Arr::get($args, 'tokens', []), $beam) + $quantity ); event(new BeamCreated($beam)); @@ -184,23 +201,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 { @@ -266,22 +282,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); } /** @@ -311,7 +330,7 @@ public static function isSingleUse(?string $code): bool public static function getSingleUseCodeData(string $code): ?object { try { - [$claimCode, $beamCode, $nonce] = explode(':', decrypt($code), 3); + [$claimCode, $beamCode, $nonce] = explode(':', decrypt($code), 4); return (object) [ 'claimCode' => $claimCode, @@ -374,6 +393,43 @@ public function removeTokens(string $code, array $tokens): bool return true; } + /** + * Create beam claims pack. + */ + protected function createClaimsPack(array $tokens, Collection $packs, Model $beam): void + { + $tokens = collect($tokens); + $tokenIds = $tokens->whereNotNull('tokenIds'); + + foreach ($packs as $pack) { + if ($tokenIds->count()) { + DispatchCreateBeamClaimsJobs::dispatch($beam, $tokenIds->all(), $pack->id)->afterCommit(); + } + + $tokenUploads = $tokens->whereNotNull('tokenIdDataUpload'); + if ($tokenUploads->count()) { + $ids = $tokenIds->pluck('tokenIds'); + $tokenUploads->each(function ($token) use ($beam, $ids) { + 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) { + $token['tokenIds'] = $tokenIds->all(); + unset($token['tokenIdDataUpload']); + DispatchCreateBeamClaimsJobs::dispatch($beam, [$token])->afterCommit(); + unset($tokenIds, $token); + }); + }); + } + } + } + /** * Create beam claims. */ @@ -465,6 +521,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..8cb44b6 100644 --- a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php @@ -9,6 +9,7 @@ use Enjin\Platform\Beam\Enums\BeamType; use Enjin\Platform\Beam\Events\BeamClaimPending; use Enjin\Platform\Beam\Jobs\ClaimBeam; +use Enjin\Platform\Beam\Models\Beam; use Enjin\Platform\Beam\Models\BeamClaim; use Enjin\Platform\Beam\Rules\PassesClaimConditions; use Enjin\Platform\Beam\Services\BatchService; @@ -106,6 +107,34 @@ 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 + { + $code = $this->graphql('CreateBeam', $this->generateBeamData( + BeamType::MINT_ON_DEMAND, + 2, + [], + [], + true + )); + $oldBeam = $this->beam; + $this->beam = Beam::where('code', $code)->first(); + $this->genericClaimTest(CryptoSignatureType::ED25519); + + + $code = $this->graphql('CreateBeam', $this->generateBeamData( + BeamType::MINT_ON_DEMAND, + 2, + [], + [['flag' => 'SINGLE_USE']], + true + )); + $response = $this->graphql('GetSingleUseCodes', ['code' => $code]); + $this->assertNotEmpty($response['totalCount']); + $this->genericClaimTest(CryptoSignatureType::ED25519, Arr::get($response, 'edges.0.node.code')); + + $this->beam = $oldBeam; + } + public function test_it_can_claim_beam_job_with_idempotency_key(): void { $data = [ diff --git a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php index 2c102b5..ce23a7c 100644 --- a/tests/Feature/GraphQL/Mutations/CreateBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/CreateBeamTest.php @@ -33,6 +33,7 @@ class CreateBeamTest extends TestCaseGraphQL public function test_it_can_create_beam_with_transfer_token(): void { $this->genericTestCreateBeam(BeamType::TRANSFER_TOKEN, 1); + $this->genericTestCreateBeam(BeamType::TRANSFER_TOKEN, 1, [], [], true); } /** @@ -59,6 +60,26 @@ public function test_it_can_create_beam_with_file_upload(): void 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->generateBeamData(BeamType::MINT_ON_DEMAND, 10, [], [], true), + ['tokens' => [['tokenIdDataUpload' => $file, 'type' => BeamType::MINT_ON_DEMAND->name]]] + )); + $this->assertNotEmpty($response); + + Event::assertDispatched(BeamCreated::class); + $this->assertEquals(10, 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->generateBeamData(BeamType::MINT_ON_DEMAND, 1, [], [], true), + ['tokens' => [['tokenIdDataUpload' => $file]]] + )); + $this->assertNotEmpty($response); + + Event::assertDispatched(BeamCreated::class); + $this->assertEquals(1, Cache::get(BeamService::key($response))); } /** @@ -67,6 +88,7 @@ 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->genericTestCreateBeam(BeamType::MINT_ON_DEMAND, random_int(1, 20), [], [], true); } /** @@ -77,6 +99,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->genericTestCreateBeam(BeamType::MINT_ON_DEMAND, random_int(1, 20), [], [ + ['flag' => 'SINGLE_USE'], + ], true); } /** @@ -89,6 +115,14 @@ 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->genericTestCreateBeam( + BeamType::MINT_ON_DEMAND, + random_int(1, 20), + [['key' => 'key1', 'value' => 'value1'], ['key' => 'key2', 'value' => 'value2']], + [], + true + ); } /** @@ -135,6 +169,7 @@ 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); + } /** @@ -335,6 +370,13 @@ 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']); + + $response = $this->graphql( + $this->method, + array_merge($data, ['isPack' => true, 'quantity' => 1000]), + true + ); + $this->assertArraySubset(['quantity' => ['The quantity field must not be greater than 100.']], $response['error']); } /** @@ -429,16 +471,25 @@ public function test_it_will_fail_with_invalid_token_quantity_per_claim(): void /** * Generic test for create beam. */ - protected function genericTestCreateBeam(BeamType $type = BeamType::MINT_ON_DEMAND, int $count = 1, array $attributes = [], array $singleUse = []): void - { + protected function genericTestCreateBeam( + BeamType $type = + BeamType::MINT_ON_DEMAND, + int $count = 1, + array $attributes = [], + array $singleUse = [], + bool $isPack = false + ): void { $this->truncateBeamTables(); - $response = $this->graphql($this->method, $data = $this->generateBeamData($type, $count, $attributes, $singleUse)); + $response = $this->graphql($this->method, $data = $this->generateBeamData($type, $count, $attributes, $singleUse, $isPack)); $this->assertNotEmpty($response); Event::assertDispatched(BeamCreated::class); $tokenIds = $this->expandRanges(array_column($data['tokens'], 'tokenIds')[0]); - $this->assertEquals(count($tokenIds) * $count, Cache::get(BeamService::key($response))); + $this->assertEquals( + $isPack ? $count : count($tokenIds) * $count, + Cache::get(BeamService::key($response)) + ); } } diff --git a/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php b/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php index 613ce83..36cabda 100644 --- a/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php +++ b/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php @@ -50,4 +50,37 @@ public function test_it_will_fail_with_invalid_code(): void $response = $this->graphql($this->method, ['codes' => [fake()->text(10)]], true); $this->assertArraySubset(['codes' => ['The codes is invalid.']], $response['error']); } + + /** + * Test expire single use code. + */ + public function test_it_can_expire_single_use_codes_beam_pack(): void + { + $this->truncateBeamTables(); + + $code = $this->graphql('CreateBeam', $this->generateBeamData( + 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); + } + + /** + * Test get single use beam with invalid claims. + */ + public function test_it_will_fail_with_invalid_code_beam_pack(): void + { + $response = $this->graphql($this->method, ['codes' => [fake()->text(10)]], true); + $this->assertArraySubset(['codes' => ['The codes is invalid.']], $response['error']); + } } diff --git a/tests/Feature/GraphQL/Resources/CreateBeam.graphql b/tests/Feature/GraphQL/Resources/CreateBeam.graphql index 0fc6927..78cac93 100644 --- a/tests/Feature/GraphQL/Resources/CreateBeam.graphql +++ b/tests/Feature/GraphQL/Resources/CreateBeam.graphql @@ -7,6 +7,8 @@ mutation CreateBeam( $collectionId: BigInt! $tokens: [ClaimToken!]! $flags: [BeamFlagInputType!] + $isPack: Boolean + $quantity: Int ) { CreateBeam( name: $name @@ -17,5 +19,7 @@ mutation CreateBeam( collectionId: $collectionId tokens: $tokens flags: $flags + isPack: $isPack + quantity: $quantity ) } diff --git a/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql b/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql index d47289a..5020ef0 100644 --- a/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql +++ b/tests/Feature/GraphQL/Resources/GetSingleUseCodes.graphql @@ -3,10 +3,19 @@ query GetSingleUseCodes($code: String!) { edges { cursor node { - code - qr { - url - payload + ... on BeamClaim { + code + qr { + url + payload + } + } + ... on BeamPack { + code + qr { + url + payload + } } } } diff --git a/tests/Feature/Traits/CreateBeamData.php b/tests/Feature/Traits/CreateBeamData.php index 35e2b1f..440661a 100644 --- a/tests/Feature/Traits/CreateBeamData.php +++ b/tests/Feature/Traits/CreateBeamData.php @@ -10,8 +10,13 @@ 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 = [], + bool $isPack = false + ): array { return [ 'name' => fake()->name(), 'description' => fake()->word(), @@ -20,6 +25,8 @@ protected function generateBeamData(BeamType $type = BeamType::TRANSFER_TOKEN, i 'end' => Carbon::now()->addDays(random_int(1, 1000))->toDateTimeString(), 'collectionId' => $this->collection->collection_chain_id, 'flags' => $singleUse, + 'isPack' => $isPack, + 'quantity' => $count, 'tokens' => [[ 'type' => $type->name, 'tokenIds' => $type == BeamType::TRANSFER_TOKEN