From 934047445a7f49c6ba1de8cb318048dd725b730a Mon Sep 17 00:00:00 2001 From: Alex Barnsley <8069294+alexbarnsley@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:41:11 +0000 Subject: [PATCH] refactor: calculate fees (#994) --- app/Models/Receipt.php | 89 +++++++++++++++++++ app/Models/Transaction.php | 25 ++++++ app/Providers/RouteServiceProvider.php | 3 +- app/Services/BigNumber.php | 10 +++ app/Services/Cache/StatisticsCache.php | 13 +-- app/Services/Identity.php | 3 +- app/ViewModels/TransactionViewModel.php | 5 +- composer.json | 1 + composer.lock | 26 +++--- database/factories/ReceiptFactory.php | 38 ++++++++ database/factories/TransactionFactory.php | 2 +- tests/Unit/Models/ReceiptTest.php | 20 +++++ tests/Unit/Models/TransactionTest.php | 24 ++++- .../Services/Cache/StatisticsCacheTest.php | 41 +++++++++ .../ViewModels/TransactionViewModelTest.php | 28 +++++- ...10_19_042110_create_transactions_table.php | 2 +- ...024_11_08_015700_create_receipts_table.php | 24 +++++ 17 files changed, 325 insertions(+), 29 deletions(-) create mode 100644 app/Models/Receipt.php create mode 100644 database/factories/ReceiptFactory.php create mode 100644 tests/Unit/Models/ReceiptTest.php create mode 100644 tests/Unit/Services/Cache/StatisticsCacheTest.php create mode 100644 tests/migrations/2024_11_08_015700_create_receipts_table.php diff --git a/app/Models/Receipt.php b/app/Models/Receipt.php new file mode 100644 index 000000000..a4f974d18 --- /dev/null +++ b/app/Models/Receipt.php @@ -0,0 +1,89 @@ + + */ + protected $casts = [ + 'id' => 'string', + 'success' => 'bool', + 'block_height' => BigInteger::class, + 'gas_used' => BigInteger::class, + 'gas_refunded' => BigInteger::class, + 'logs' => 'array', + 'output' => 'string', + ]; + + /** + * A wallet has many blocks if it is a validator. + * + * @return HasOne + */ + public function transaction(): HasOne + { + return $this->hasOne(Transaction::class, 'id', 'id'); + } + + /** + * Get the current connection name for the model. + * + * @return string + */ + public function getConnectionName() + { + return 'explorer'; + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 9256157cd..797d303c9 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Arr; use Laravel\Scout\Searchable; @@ -106,6 +107,10 @@ final class Transaction extends Model 'block_height' => 'int', ]; + protected $with = [ + 'receipt', + ]; + private bool|string|null $vendorFieldContent = false; /** @@ -191,6 +196,16 @@ public function sender(): BelongsTo return $this->belongsTo(Wallet::class, 'sender_public_key', 'public_key'); } + /** + * A receipt belongs to a transaction. + * + * @return HasOne + */ + public function receipt(): HasOne + { + return $this->hasOne(Receipt::class, 'id', 'id'); + } + /** * A transaction belongs to a recipient. * @@ -220,6 +235,16 @@ public function vendorField(): string|null return $this->vendorFieldContent; } + public function fee(): BigNumber + { + $gasPrice = clone $this->gas_price; + if ($this->receipt === null) { + return $gasPrice; + } + + return $gasPrice->multipliedBy($this->receipt->gas_used->valueOf()); + } + /** * Get the current connection name for the model. * diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 545b2f213..61c86bc3a 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -8,7 +8,6 @@ use App\Exceptions\BlockNotFoundException; use App\Exceptions\TransactionNotFoundException; use App\Exceptions\WalletNotFoundException; -use App\Facades\Network; use App\Facades\Wallets; use App\Models\Block; use App\Models\Transaction; @@ -51,7 +50,7 @@ public function boot() Route::bind('wallet', function (string $walletID): Wallet { if (strlen($walletID) === Constants::ADDRESS_LENGTH) { - abort_unless(Address::validate($walletID, Network::config()), 404); + abort_unless(Address::validate($walletID), 404); } try { diff --git a/app/Services/BigNumber.php b/app/Services/BigNumber.php index 4d193b779..0ea928d29 100644 --- a/app/Services/BigNumber.php +++ b/app/Services/BigNumber.php @@ -47,6 +47,16 @@ public function plus($value): self return $this; } + /** + * @param BigDecimal|int|float|string $value + */ + public function multipliedBy($value): self + { + $this->value = $this->value->multipliedBy($value); + + return $this; + } + public function toNumber(): int { return $this->value->toInt(); diff --git a/app/Services/Cache/StatisticsCache.php b/app/Services/Cache/StatisticsCache.php index 8a1680953..bb2d735b5 100644 --- a/app/Services/Cache/StatisticsCache.php +++ b/app/Services/Cache/StatisticsCache.php @@ -8,6 +8,7 @@ use App\Services\BigNumber; use App\Services\Cache\Concerns\ManagesCache; use App\Services\Timestamp; +use ArkEcosystem\Crypto\Utils\UnitConverter; use Carbon\Carbon; use Illuminate\Cache\TaggedCache; use Illuminate\Support\Facades\Cache; @@ -22,23 +23,23 @@ final class StatisticsCache implements Contract public function getTransactionData(): array { return $this->remember('transactions', self::STATS_TTL, function () { - $timestamp = Timestamp::fromUnix(Carbon::now()->subDays(1)->unix())->unix() * 1000; + $timestamp = Carbon::now()->subDays(1)->getTimestampMs(); $data = (array) DB::connection('explorer') ->table('transactions') ->selectRaw('COUNT(*) as transaction_count') ->selectRaw('SUM(amount) as volume') - // TODO: Calculate fee using gas_price and gas_used - https://app.clickup.com/t/86dv41828 - ->selectRaw('SUM(gas_price) as total_fees') - ->selectRaw('AVG(gas_price) as average_fee') + ->selectRaw('SUM(gas_price * COALESCE(receipts.gas_used, 0)) as total_fees') + ->selectRaw('AVG(gas_price * COALESCE(receipts.gas_used, 0)) as average_fee') ->from('transactions') + ->join('receipts', 'transactions.id', '=', 'receipts.id') ->where('timestamp', '>', $timestamp) ->first(); return [ 'transaction_count' => $data['transaction_count'], 'volume' => $data['volume'] ?? 0, - 'total_fees' => $data['total_fees'] ?? 0, - 'average_fee' => $data['average_fee'] ?? 0, + 'total_fees' => UnitConverter::parseUnits($data['total_fees'] ?? 0, 'gwei'), + 'average_fee' => UnitConverter::parseUnits($data['average_fee'] ?? 0, 'gwei'), ]; }); } diff --git a/app/Services/Identity.php b/app/Services/Identity.php index bf085f340..1eef2e174 100644 --- a/app/Services/Identity.php +++ b/app/Services/Identity.php @@ -4,7 +4,6 @@ namespace App\Services; -use App\Facades\Network; use ArkEcosystem\Crypto\Identities\Address; use Illuminate\Support\Facades\Cache; @@ -15,7 +14,7 @@ public static function address(string $publicKey): string return Cache::tags('identity')->remember( $publicKey, now()->addMinutes(10), - fn () => Address::fromPublicKey($publicKey, Network::config()) + fn () => Address::fromPublicKey($publicKey) ); } } diff --git a/app/ViewModels/TransactionViewModel.php b/app/ViewModels/TransactionViewModel.php index fba11054c..5e52d2aff 100644 --- a/app/ViewModels/TransactionViewModel.php +++ b/app/ViewModels/TransactionViewModel.php @@ -22,6 +22,7 @@ use App\ViewModels\Concerns\Transaction\InteractsWithVendorField; use App\ViewModels\Concerns\Transaction\InteractsWithVotes; use App\ViewModels\Concerns\Transaction\InteractsWithWallets; +use ArkEcosystem\Crypto\Utils\UnitConverter; use Carbon\Carbon; use Illuminate\Support\Arr; @@ -93,12 +94,12 @@ public function nonce(): int public function fee(): float { - return $this->transaction->gas_price->toFloat(); // TODO: https://app.clickup.com/t/86dv41828 + return UnitConverter::formatUnits((string) $this->transaction->fee(), 'gwei'); } public function feeFiat(bool $showSmallAmounts = false): string { - return ExchangeRate::convert($this->transaction->gas_price->toFloat(), $this->transaction->timestamp, $showSmallAmounts); // TODO: https://app.clickup.com/t/86dv41828 + return ExchangeRate::convert($this->fee(), $this->transaction->timestamp, $showSmallAmounts); } public function amountForItself(): float diff --git a/composer.json b/composer.json index 52b29fcd7..14b1fabda 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "ardenthq/arkvault-url": "dev-feat/mainsail", "arkecosystem/crypto": "dev-feat/mainsail", "arkecosystem/foundation": "^19.0", + "blade-ui-kit/blade-icons": "^1.7", "brick/math": "^0.12", "danharrin/livewire-rate-limiting": "^1.3", "doctrine/dbal": "^3.0", diff --git a/composer.lock b/composer.lock index 4e2a941c1..471657817 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8af0dbad52a3cbafefe4813e35589742", + "content-hash": "380607006bf22c16e6a7f2940c056d21", "packages": [ { "name": "ardenthq/arkvault-url", @@ -58,12 +58,12 @@ "source": { "type": "git", "url": "https://github.com/ArkEcosystem/php-crypto.git", - "reference": "d7fcebcb4fc20bb9b87816b3ba2d5bd02f0a179e" + "reference": "adaac60fa128987b57590804053c74e39f04d40d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ArkEcosystem/php-crypto/zipball/d7fcebcb4fc20bb9b87816b3ba2d5bd02f0a179e", - "reference": "d7fcebcb4fc20bb9b87816b3ba2d5bd02f0a179e", + "url": "https://api.github.com/repos/ArkEcosystem/php-crypto/zipball/adaac60fa128987b57590804053c74e39f04d40d", + "reference": "adaac60fa128987b57590804053c74e39f04d40d", "shasum": "" }, "require": { @@ -96,9 +96,9 @@ ], "authors": [ { - "name": "Brian Faust", - "email": "hello@brianfaust.me", - "homepage": "https://github.com/faustbrian" + "name": "ARK Ecosystem", + "email": "info@arkscic.com", + "homepage": "https://arkscic.com" } ], "description": "A simple PHP Cryptography Implementation for the Ark Blockchain.", @@ -113,7 +113,7 @@ "issues": "https://github.com/ArkEcosystem/php-crypto/issues", "source": "https://github.com/ArkEcosystem/php-crypto/tree/feat/mainsail" }, - "time": "2024-07-19T13:02:22+00:00" + "time": "2024-10-31T13:39:12+00:00" }, { "name": "arkecosystem/foundation", @@ -308,16 +308,16 @@ }, { "name": "blade-ui-kit/blade-icons", - "version": "1.6.0", + "version": "1.7.2", "source": { "type": "git", "url": "https://github.com/blade-ui-kit/blade-icons.git", - "reference": "89660d93f9897d231e9113ba203cd17f4c5efade" + "reference": "75a54a3f5a2810fcf6574ab23e91b6cc229a1b53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/blade-ui-kit/blade-icons/zipball/89660d93f9897d231e9113ba203cd17f4c5efade", - "reference": "89660d93f9897d231e9113ba203cd17f4c5efade", + "url": "https://api.github.com/repos/blade-ui-kit/blade-icons/zipball/75a54a3f5a2810fcf6574ab23e91b6cc229a1b53", + "reference": "75a54a3f5a2810fcf6574ab23e91b6cc229a1b53", "shasum": "" }, "require": { @@ -385,7 +385,7 @@ "type": "paypal" } ], - "time": "2024-02-07T16:09:20+00:00" + "time": "2024-10-17T17:38:00+00:00" }, { "name": "brianium/paratest", diff --git a/database/factories/ReceiptFactory.php b/database/factories/ReceiptFactory.php new file mode 100644 index 000000000..e13cb278b --- /dev/null +++ b/database/factories/ReceiptFactory.php @@ -0,0 +1,38 @@ + $this->faker->transactionId, + 'success' => $this->faker->boolean, + 'block_height' => $this->faker->numberBetween(1, 10000), + 'gas_used' => $this->faker->numberBetween(1, 100), + 'gas_refunded' => $this->faker->numberBetween(1, 100), + 'deployed_contract_address' => fn () => Wallet::factory()->create()->address, + 'logs' => [], + 'output' => null, + ]; + } + + public function withTransaction() + { + $transaction = Transaction::factory()->create(); + + return $this->state(fn (array $attributes) => [ + 'id' => $transaction->id, + ]); + } +} diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index b206784b1..ebbbf1bb5 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -27,7 +27,7 @@ public function definition() 'sender_public_key' => fn () => $wallet->public_key, 'recipient_id' => fn () => $wallet->address, 'timestamp' => 1603083256000, - 'fee' => $this->faker->numberBetween(1, 100) * 1e18, + 'gas_price' => $this->faker->numberBetween(1, 100), 'amount' => $this->faker->numberBetween(1, 100) * 1e18, 'nonce' => 1, ]; diff --git a/tests/Unit/Models/ReceiptTest.php b/tests/Unit/Models/ReceiptTest.php new file mode 100644 index 000000000..4c8801d33 --- /dev/null +++ b/tests/Unit/Models/ReceiptTest.php @@ -0,0 +1,20 @@ +create(); + $receipt = Receipt::factory()->create(['id' => $transaction->id]); + + expect($receipt->transaction->id)->toBe($transaction->id); +}); + +it('should link to a transaction through factory', function () { + $receipt = Receipt::factory()->withTransaction()->create(); + $transaction = Transaction::first(); + + expect($receipt->transaction->id)->toBe($transaction->id); +}); diff --git a/tests/Unit/Models/TransactionTest.php b/tests/Unit/Models/TransactionTest.php index 6d0ee6f06..4b3b14477 100644 --- a/tests/Unit/Models/TransactionTest.php +++ b/tests/Unit/Models/TransactionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Block; +use App\Models\Receipt; use App\Models\Transaction; use App\Models\Wallet; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,7 +14,7 @@ beforeEach(function () { $this->recipient = Wallet::factory()->create(); $this->subject = Transaction::factory()->create([ - 'fee' => 1 * 1e18, + 'gas_price' => 1, 'amount' => 2 * 1e18, 'recipient_id' => $this->recipient, ]); @@ -132,3 +133,24 @@ // Expect no exception to be thrown expect(true)->toBeTrue(); }); + +it('should calculate fee with receipt', function () { + $transaction = Transaction::factory()->create([ + 'gas_price' => 54, + ]); + + Receipt::factory()->create([ + 'id' => $transaction->id, + 'gas_used' => 21000, + ]); + + expect($transaction->fresh()->fee()->toNumber())->toBe(1134000); +}); + +it('should return gas price if no receipt', function () { + $transaction = Transaction::factory()->create([ + 'gas_price' => 54, + ]); + + expect($transaction->fresh()->fee()->toNumber())->toBe(54); +}); diff --git a/tests/Unit/Services/Cache/StatisticsCacheTest.php b/tests/Unit/Services/Cache/StatisticsCacheTest.php new file mode 100644 index 000000000..c95ee8b6b --- /dev/null +++ b/tests/Unit/Services/Cache/StatisticsCacheTest.php @@ -0,0 +1,41 @@ +create([ + 'timestamp' => Carbon::now()->subHours(1)->getTimestampMs(), + ]) + ->each(function ($transaction, $index) use (&$volume, &$totalFees) { + $transaction->gas_price = $index + 1; + $transaction->save(); + + $volume->plus($transaction->amount->valueOf()); + $totalFees->plus(BigNumber::new($transaction->gas_price->valueOf())->multipliedBy(21000)->valueOf()); + + Receipt::factory()->create([ + 'id' => $transaction->id, + 'gas_used' => 21000, + ]); + }); + + expect((new StatisticsCache())->getTransactionData())->toEqual([ + 'transaction_count' => $transactionCount, + 'volume' => UnitConverter::parseUnits((string) $volume, 'wei'), + 'total_fees' => UnitConverter::parseUnits((string) $totalFees, 'gwei'), + 'average_fee' => BigNumber::new(UnitConverter::parseUnits((string) $totalFees, 'gwei'))->toFloat($transactionCount), + ]); +}); diff --git a/tests/Unit/ViewModels/TransactionViewModelTest.php b/tests/Unit/ViewModels/TransactionViewModelTest.php index 0881cc6d8..160831df7 100644 --- a/tests/Unit/ViewModels/TransactionViewModelTest.php +++ b/tests/Unit/ViewModels/TransactionViewModelTest.php @@ -5,6 +5,7 @@ use App\DTO\Payment; use App\Facades\Settings; use App\Models\Block; +use App\Models\Receipt; use App\Models\Transaction; use App\Models\Wallet; use App\Services\Blockchain\NetworkFactory; @@ -29,7 +30,7 @@ $this->subject = new TransactionViewModel(Transaction::factory()->transfer()->create([ 'block_id' => $this->block->id, 'block_height' => 1, - 'fee' => 1 * 1e18, + 'gas_price' => 1, 'amount' => 2 * 1e18, 'sender_public_key' => $this->sender->public_key, 'recipient_id' => Wallet::factory()->create(['address' => 'recipient'])->address, @@ -1204,3 +1205,28 @@ expect($transaction->formattedPayload())->toBe('MethodID: 0x12341234'); }); }); + +it('should calculate fee with receipt', function () { + $transaction = Transaction::factory()->create([ + 'gas_price' => 54, + ]); + + Receipt::factory()->create([ + 'id' => $transaction->id, + 'gas_used' => 21000, + ]); + + $viewModel = new TransactionViewModel($transaction->fresh()); + + expect($viewModel->fee())->toEqual(0.001134); +}); + +it('should return gas price if no receipt', function () { + $transaction = Transaction::factory()->create([ + 'gas_price' => 54, + ]); + + $viewModel = new TransactionViewModel($transaction->fresh()); + + expect($viewModel->fee())->toEqual(0.000000054); +}); diff --git a/tests/migrations/2020_10_19_042110_create_transactions_table.php b/tests/migrations/2020_10_19_042110_create_transactions_table.php index d39f7999e..f6214c2a5 100644 --- a/tests/migrations/2020_10_19_042110_create_transactions_table.php +++ b/tests/migrations/2020_10_19_042110_create_transactions_table.php @@ -20,7 +20,7 @@ public function up() $table->string('recipient_id')->nullable(); $table->unsignedBigInteger('timestamp'); $table->addColumn('numeric', 'amount'); - $table->addColumn('numeric', 'fee'); + $table->addColumn('numeric', 'gas_price'); $table->unsignedBigInteger('nonce'); $table->binary('vendor_field')->nullable(); $table->jsonb('asset')->nullable(); diff --git a/tests/migrations/2024_11_08_015700_create_receipts_table.php b/tests/migrations/2024_11_08_015700_create_receipts_table.php new file mode 100644 index 000000000..55febdd3a --- /dev/null +++ b/tests/migrations/2024_11_08_015700_create_receipts_table.php @@ -0,0 +1,24 @@ +string('id'); + $table->boolean('success'); + $table->unsignedBigInteger('block_height'); + $table->addColumn('numeric', 'gas_used'); + $table->addColumn('numeric', 'gas_refunded'); + $table->string('deployed_contract_address')->nullable(); + $table->jsonb('logs')->nullable(); + $table->binary('output')->nullable(); + }); + } +}