Skip to content
60 changes: 42 additions & 18 deletions src/Utils/AbiBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,24 @@ abstract class AbiBase

public function __construct(ContractAbiType $type = ContractAbiType::CONSENSUS, ?string $path = null)
{
$abiFilePath = $this->contractAbiPath($type, $path);
$abiFilePath = self::contractAbiPath($type, $path);
$decodedAbi = self::loadAbiJson($abiFilePath);

$abiJson = file_get_contents($abiFilePath);
$this->abi = $decodedAbi['abi'];
}

public static function methodIdentifiers(
ContractAbiType $type = ContractAbiType::CONSENSUS,
?string $path = null
): array {
$abiFilePath = self::contractAbiPath($type, $path);
$decodedAbi = self::loadAbiJson($abiFilePath);

if (! isset($decodedAbi['methodIdentifiers']) || ! is_array($decodedAbi['methodIdentifiers'])) {
throw new \RuntimeException("ABI JSON does not contain methodIdentifiers: {$abiFilePath}");
}

$this->abi = json_decode($abiJson, true)['abi'];
return $decodedAbi['methodIdentifiers'];
}

protected static function getArrayComponents(string $type): ?array
Expand Down Expand Up @@ -75,27 +88,38 @@ protected function toFunctionSelector(array $abiItem): string
return $selector;
}

private function contractAbiPath(ContractAbiType $type, ?string $path = null): ?string
protected static function contractAbiPath(ContractAbiType $type, ?string $path = null): string
{
switch ($type) {
case ContractAbiType::CONSENSUS:
return __DIR__.'/Abi/json/Abi.Consensus.json';
case ContractAbiType::MULTIPAYMENT:
return __DIR__.'/Abi/json/Abi.Multipayment.json';
case ContractAbiType::USERNAMES:
return __DIR__.'/Abi/json/Abi.Usernames.json';
case ContractAbiType::ERC20BATCH_TRANSFER:
return __DIR__.'/Abi/json/Abi.ERC20BatchTransfer.json';
case ContractAbiType::TOKEN:
return __DIR__.'/Abi/json/Abi.Token.json';
case ContractAbiType::CUSTOM:
return match ($type) {
ContractAbiType::CONSENSUS => __DIR__.'/Abi/json/Abi.Consensus.json',
ContractAbiType::MULTIPAYMENT => __DIR__.'/Abi/json/Abi.Multipayment.json',
ContractAbiType::USERNAMES => __DIR__.'/Abi/json/Abi.Usernames.json',
ContractAbiType::ERC20BATCH_TRANSFER => __DIR__.'/Abi/json/Abi.ERC20BatchTransfer.json',
ContractAbiType::TOKEN => __DIR__.'/Abi/json/Abi.Token.json',
ContractAbiType::CUSTOM => (function () use ($path): string {
if ($path === null || $path === '') {
throw new \InvalidArgumentException('A non-empty $path must be provided when using ContractAbiType::CUSTOM.');
}

return $path;
default:
throw new \InvalidArgumentException('Unhandled ContractAbiType: '.$type->name);
})(),
};
}

private static function loadAbiJson(string $path): array
{
$rawJson = file_get_contents($path);

if ($rawJson === false) {
throw new \RuntimeException("Unable to load ABI JSON: {$path}");
}

$decoded = json_decode($rawJson, true);

if (! is_array($decoded) || ! isset($decoded['abi']) || ! is_array($decoded['abi'])) {
throw new \RuntimeException("ABI JSON does not contain a valid abi array: {$path}");
}

return $decoded;
}
}
113 changes: 113 additions & 0 deletions src/Utils/TransactionTypeIdentifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace ArkEcosystem\Crypto\Utils;

use ArkEcosystem\Crypto\Enums\ContractAbiType;
use ArkEcosystem\Crypto\Helpers;

class TransactionTypeIdentifier
{
private const TRANSFER_SIGNATURE = '';

private static ?array $signatures = null;

public static function isTransfer(string $data): bool
{
return $data === self::TRANSFER_SIGNATURE;
}

public static function isVote(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['vote']);
}

public static function isUnvote(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['unvote']);
}

public static function isMultiPayment(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['multiPayment']);
}

public static function isUsernameRegistration(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['registerUsername']);
}

public static function isUsernameResignation(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['resignUsername']);
}

public static function isValidatorRegistration(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['registerValidator']);
}

public static function isValidatorResignation(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['resignValidator']);
}

public static function isUpdateValidator(string $data): bool
{
return self::startsWithSignature($data, self::signatures()['updateValidator']);
}

public static function isTokenTransfer(string $data): bool
{
$decodedData = static::decodeTokenFunction($data);

return $decodedData ? $decodedData['functionName'] === 'transfer' : false;
}

private static function startsWithSignature(string $data, string $signature): bool
{
return str_starts_with(
strtolower(Helpers::removeLeadingHexZero($data)),
strtolower($signature)
);
}

private static function signatures(): array
{
if (self::$signatures !== null) {
return self::$signatures;
}

$consensusMethods = AbiBase::methodIdentifiers(ContractAbiType::CONSENSUS);
$multipaymentMethods = AbiBase::methodIdentifiers(ContractAbiType::MULTIPAYMENT);
$usernamesMethods = AbiBase::methodIdentifiers(ContractAbiType::USERNAMES);

self::$signatures = [
'multiPayment' => $multipaymentMethods['pay(address[],uint256[])'],
'registerUsername' => $usernamesMethods['registerUsername(string)'],
'resignUsername' => $usernamesMethods['resignUsername()'],
'registerValidator' => $consensusMethods['registerValidator(bytes)'],
'resignValidator' => $consensusMethods['resignValidator()'],
'vote' => $consensusMethods['vote(address)'],
'unvote' => $consensusMethods['unvote()'],
'updateValidator' => $consensusMethods['updateValidator(bytes)'],
'transfer' => 'transfer',
];

return self::$signatures;
}

private static function decodeTokenFunction(string $data): ?array
{
try {
$decodedData = (new AbiDecoder(ContractAbiType::TOKEN))->decodeFunctionData($data);

return ['functionName' => $decodedData['functionName'], 'args' => $decodedData['args']];
} catch (\Exception $e) {
// Different abi type. Ignore.
}

return null;
}
}
3 changes: 3 additions & 0 deletions tests/Unit/Enums/AbiFunctionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use ArkEcosystem\Crypto\Enums\AbiFunction;
use ArkEcosystem\Crypto\Transactions\Types\EvmCall;
use ArkEcosystem\Crypto\Transactions\Types\Multipayment;
use ArkEcosystem\Crypto\Transactions\Types\Unvote;
use ArkEcosystem\Crypto\Transactions\Types\UsernameRegistration;
Expand All @@ -21,4 +22,6 @@
'UsernameRegistration' => ['USERNAME_REGISTRATION', UsernameRegistration::class],
'UsernameResignation' => ['USERNAME_RESIGNATION', UsernameResignation::class],
'Multipayment' => ['MULTIPAYMENT', Multipayment::class],
'Transfer' => ['TRANSFER', EvmCall::class],
'Approve' => ['APPROVE', EvmCall::class],
]);
26 changes: 26 additions & 0 deletions tests/Unit/Utils/AbiEncoderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ function testPrivateMethod(string $methodName, &$object): ReflectionMethod
$this->encoder = new AbiEncoder();
});

it('should require a non-empty custom ABI path', function () {
new AbiEncoder(ContractAbiType::CUSTOM);
})->throws(
InvalidArgumentException::class,
'A non-empty $path must be provided when using ContractAbiType::CUSTOM.'
);

it('should fail when custom ABI file cannot be read', function () {
$previousErrorReporting = error_reporting();
error_reporting($previousErrorReporting & ~E_WARNING);

try {
new AbiEncoder(ContractAbiType::CUSTOM, dirname(__DIR__, 3).'/tests/fixtures/does-not-exist.json');
} finally {
error_reporting($previousErrorReporting);
}
})->throws(RuntimeException::class, 'Unable to load ABI JSON');

it('should fail when custom ABI JSON is missing abi array', function () {
new AbiEncoder(ContractAbiType::CUSTOM, dirname(__DIR__, 3).'/tests/fixtures/message-sign.json');
})->throws(RuntimeException::class, 'ABI JSON does not contain a valid abi array');

it('should fail when custom ABI JSON is missing method identifiers', function () {
AbiEncoder::methodIdentifiers(ContractAbiType::CUSTOM, dirname(__DIR__, 3).'/tests/fixtures/mock-abi.json');
})->throws(RuntimeException::class, 'ABI JSON does not contain methodIdentifiers');

it('should encode vote function call', function () {
$functionName = 'vote';
$args = ['0x512F366D524157BcF734546eB29a6d687B762255'];
Expand Down
101 changes: 101 additions & 0 deletions tests/Unit/Utils/TransactionTypeIdentifierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

use ArkEcosystem\Crypto\Utils\TransactionTypeIdentifier;

function loadMethodIdentifiers(string $path): array
{
$json = file_get_contents($path);

expect($json)->not->toBeFalse();

$decoded = json_decode($json, true);

expect($decoded)->toBeArray();
expect($decoded)->toHaveKey('methodIdentifiers');

return $decoded['methodIdentifiers'];
}

beforeEach(function () {
$this->consensusMethods = loadMethodIdentifiers(dirname(__DIR__, 3).'/src/Utils/Abi/json/Abi.Consensus.json');
$this->multipaymentMethods = loadMethodIdentifiers(dirname(__DIR__, 3).'/src/Utils/Abi/json/Abi.Multipayment.json');
$this->usernamesMethods = loadMethodIdentifiers(dirname(__DIR__, 3).'/src/Utils/Abi/json/Abi.Usernames.json');
});

it('identifies transfer by empty payload', function () {
expect(TransactionTypeIdentifier::isTransfer(''))->toBeTrue();
expect(TransactionTypeIdentifier::isTransfer('0x'))->toBeFalse();
expect(TransactionTypeIdentifier::isTransfer('12345678'))->toBeFalse();
});

it('identifies vote signature', function () {
$signature = $this->consensusMethods['vote(address)'];

expect(TransactionTypeIdentifier::isVote($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isVote('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isVote('1234567'))->toBeFalse();
});

it('identifies unvote signature', function () {
$signature = $this->consensusMethods['unvote()'];

expect(TransactionTypeIdentifier::isUnvote($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUnvote('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUnvote('1234567'))->toBeFalse();
});

it('identifies multipayment signature', function () {
$signature = $this->multipaymentMethods['pay(address[],uint256[])'];

expect(TransactionTypeIdentifier::isMultiPayment($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isMultiPayment('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isMultiPayment('1234567'))->toBeFalse();
});

it('identifies username registration signature', function () {
$signature = $this->usernamesMethods['registerUsername(string)'];

expect(TransactionTypeIdentifier::isUsernameRegistration($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUsernameRegistration('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUsernameRegistration('1234567'))->toBeFalse();
});

it('identifies username resignation signature', function () {
$signature = $this->usernamesMethods['resignUsername()'];

expect(TransactionTypeIdentifier::isUsernameResignation($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUsernameResignation('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUsernameResignation('1234567'))->toBeFalse();
});

it('identifies validator registration signature', function () {
$signature = $this->consensusMethods['registerValidator(bytes)'];

expect(TransactionTypeIdentifier::isValidatorRegistration($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isValidatorRegistration('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isValidatorRegistration('1234567'))->toBeFalse();
});

it('identifies validator resignation signature', function () {
$signature = $this->consensusMethods['resignValidator()'];

expect(TransactionTypeIdentifier::isValidatorResignation($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isValidatorResignation('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isValidatorResignation('1234567'))->toBeFalse();
});

it('identifies update validator signature', function () {
$signature = $this->consensusMethods['updateValidator(bytes)'];

expect(TransactionTypeIdentifier::isUpdateValidator($signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUpdateValidator('0x'.$signature))->toBeTrue();
expect(TransactionTypeIdentifier::isUpdateValidator('1234567'))->toBeFalse();
});

it('identifies token transfer payloads', function () {
expect(TransactionTypeIdentifier::isTokenTransfer('0xa9059cbb000000000000000000000000a5cc0bfeb09742c5e4c610f2ebaab82eb142ca10000000000000000000000000000000000000009bd2ffdd71438a49e803314000'))->toBeTrue();
expect(TransactionTypeIdentifier::isTokenTransfer('0x'.str_repeat('0', 64)))->toBeFalse();
expect(TransactionTypeIdentifier::isTokenTransfer('1234567'))->toBeFalse();
});
Loading