diff --git a/src/Enums/AbiFunction.php b/src/Enums/AbiFunction.php index 4ef82d0..0b1ed35 100644 --- a/src/Enums/AbiFunction.php +++ b/src/Enums/AbiFunction.php @@ -4,6 +4,7 @@ namespace ArkEcosystem\Crypto\Enums; +use ArkEcosystem\Crypto\Transactions\Types\Multipayment; use ArkEcosystem\Crypto\Transactions\Types\Unvote; use ArkEcosystem\Crypto\Transactions\Types\UsernameRegistration; use ArkEcosystem\Crypto\Transactions\Types\UsernameResignation; @@ -19,6 +20,7 @@ enum AbiFunction: string case VALIDATOR_RESIGNATION = 'resignValidator'; case USERNAME_REGISTRATION = 'registerUsername'; case USERNAME_RESIGNATION = 'resignUsername'; + case MULTIPAYMENT = 'pay'; public function transactionClass(): string { @@ -29,6 +31,7 @@ public function transactionClass(): string self::VALIDATOR_RESIGNATION => ValidatorResignation::class, self::USERNAME_REGISTRATION => UsernameRegistration::class, self::USERNAME_RESIGNATION => UsernameResignation::class, + self::MULTIPAYMENT => Multipayment::class, }; } } diff --git a/src/Helpers.php b/src/Helpers.php index 1256d00..0166aea 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -56,4 +56,9 @@ public static function isValidUsername(string $username): bool return true; } + + public static function removeLeadingHexZero(string $hex): string + { + return preg_replace('/^0x/', '', $hex); // using ltrim($hex, '0x') also removes leading 0s which is not desired, e.g. 0x0123 -> 123 + } } diff --git a/src/Transactions/Builder/MultipaymentBuilder.php b/src/Transactions/Builder/MultipaymentBuilder.php new file mode 100644 index 0000000..7abebe1 --- /dev/null +++ b/src/Transactions/Builder/MultipaymentBuilder.php @@ -0,0 +1,39 @@ +recipientAddress(ContractAddresses::MULTIPAYMENT->value); + + $this->transaction->data['pay'] = [[], []]; + $this->transaction->refreshPayloadData(); + } + + public function pay(string $address, string $amount): self + { + $this->transaction->data['pay'][0][] = $address; + $this->transaction->data['pay'][1][] = $amount; + + $this->transaction->refreshPayloadData(); + + $this->transaction->data['value'] += $amount; + + return $this; + } + + protected function getTransactionInstance(?array $data = []): AbstractTransaction + { + return new Multipayment($data); + } +} diff --git a/src/Transactions/Types/AbstractTransaction.php b/src/Transactions/Types/AbstractTransaction.php index 02b4623..727806d 100644 --- a/src/Transactions/Types/AbstractTransaction.php +++ b/src/Transactions/Types/AbstractTransaction.php @@ -5,6 +5,8 @@ namespace ArkEcosystem\Crypto\Transactions\Types; use ArkEcosystem\Crypto\Configuration\Network; +use ArkEcosystem\Crypto\Enums\ContractAbiType; +use ArkEcosystem\Crypto\Helpers; use ArkEcosystem\Crypto\Identities\Address; use ArkEcosystem\Crypto\Transactions\Serializer; use ArkEcosystem\Crypto\Utils\AbiDecoder; @@ -33,7 +35,7 @@ abstract public function getPayload(): string; public function refreshPayloadData(): static { - $this->data['data'] = ltrim($this->getPayload(), '0x'); + $this->data['data'] = Helpers::removeLeadingHexZero($this->getPayload()); return $this; } @@ -170,7 +172,7 @@ public function hash(bool $skipSignature): BufferInterface return TransactionHasher::toHash($hashData, $skipSignature); } - protected function decodePayload(array $data): ?array + protected function decodePayload(array $data, ContractAbiType $type = ContractAbiType::CONSENSUS): ?array { if (! isset($data['data'])) { return null; @@ -182,7 +184,7 @@ protected function decodePayload(array $data): ?array return null; } - return (new AbiDecoder())->decodeFunctionData($payload); + return (new AbiDecoder($type))->decodeFunctionData($payload); } private function getSignature(): CompactSignatureInterface diff --git a/src/Transactions/Types/Multipayment.php b/src/Transactions/Types/Multipayment.php new file mode 100644 index 0000000..64dd04c --- /dev/null +++ b/src/Transactions/Types/Multipayment.php @@ -0,0 +1,32 @@ +decodePayload($data, ContractAbiType::MULTIPAYMENT); + + if ($payload !== null) { + $data['pay'] = $payload['args']; + } + + parent::__construct($data); + } + + public function getPayload(): string + { + if (! array_key_exists('pay', $this->data)) { + return ''; + } + + return (new AbiEncoder(ContractAbiType::MULTIPAYMENT))->encodeFunctionCall(AbiFunction::MULTIPAYMENT->value, $this->data['pay']); + } +} diff --git a/src/Transactions/Types/ValidatorRegistration.php b/src/Transactions/Types/ValidatorRegistration.php index e9cd4db..98e5aa6 100644 --- a/src/Transactions/Types/ValidatorRegistration.php +++ b/src/Transactions/Types/ValidatorRegistration.php @@ -5,6 +5,7 @@ namespace ArkEcosystem\Crypto\Transactions\Types; use ArkEcosystem\Crypto\Enums\AbiFunction; +use ArkEcosystem\Crypto\Helpers; use ArkEcosystem\Crypto\Utils\AbiEncoder; class ValidatorRegistration extends AbstractTransaction @@ -14,7 +15,7 @@ public function __construct(?array $data = []) $payload = $this->decodePayload($data); if ($payload !== null) { - $data['validatorPublicKey'] = ltrim($payload['args'][0], '0x'); + $data['validatorPublicKey'] = Helpers::removeLeadingHexZero($payload['args'][0]); } parent::__construct($data); diff --git a/src/Utils/AbiBase.php b/src/Utils/AbiBase.php index d37522b..6fe37b6 100644 --- a/src/Utils/AbiBase.php +++ b/src/Utils/AbiBase.php @@ -81,7 +81,7 @@ private function contractAbiPath(ContractAbiType $type, string $path = null): ?s case ContractAbiType::CONSENSUS: return __DIR__.'/Abi/json/Abi.Consensus.json'; case ContractAbiType::MULTIPAYMENT: - return __DIR__.'/Abi/json/Abi.MultiPayment.json'; + return __DIR__.'/Abi/json/Abi.Multipayment.json'; case ContractAbiType::USERNAMES: return __DIR__.'/Abi/json/Abi.Usernames.json'; case ContractAbiType::CUSTOM: diff --git a/src/Utils/TransactionHasher.php b/src/Utils/TransactionHasher.php index a6da4f2..83ead5a 100644 --- a/src/Utils/TransactionHasher.php +++ b/src/Utils/TransactionHasher.php @@ -4,6 +4,7 @@ namespace ArkEcosystem\Crypto\Utils; +use ArkEcosystem\Crypto\Helpers; use BitWasp\Bitcoin\Crypto\Hash; use BitWasp\Buffertools\Buffer; use BitWasp\Buffertools\BufferInterface; @@ -31,7 +32,7 @@ public static function toHash(array $transaction, bool $skipSignature = false): self::toBeArray($transaction['gasLimit']), $recipientAddress, self::toBeArray($transaction['value']), - isset($transaction['data']) ? hex2bin(ltrim($transaction['data'], '0x')) : '', + isset($transaction['data']) ? hex2bin(Helpers::removeLeadingHexZero($transaction['data'])) : '', [], // accessList is unused ]; diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php index 8d5278c..0eb9d70 100644 --- a/tests/Unit/HelpersTest.php +++ b/tests/Unit/HelpersTest.php @@ -13,6 +13,15 @@ */ class HelpersTest extends TestCase { + /** @test */ + public function it_should_trim_hex_values_properly(): void + { + $this->assertSame('0123', Helpers::removeLeadingHexZero('0x0123')); + $this->assertSame('0123', Helpers::removeLeadingHexZero('0123')); + $this->assertSame('1234', Helpers::removeLeadingHexZero('0x1234')); + $this->assertSame('0000', Helpers::removeLeadingHexZero('0x0000')); + } + /** * @test * @dataProvider validUsernamesProvider diff --git a/tests/Unit/Transactions/Builder/MultipaymentBuilderTest.php b/tests/Unit/Transactions/Builder/MultipaymentBuilderTest.php new file mode 100644 index 0000000..6d6c942 --- /dev/null +++ b/tests/Unit/Transactions/Builder/MultipaymentBuilderTest.php @@ -0,0 +1,95 @@ +getTransactionFixture('evm_call', 'multipayment'); + + $builder = MultipaymentBuilder::new() + ->gasPrice($fixture['data']['gasPrice']) + ->nonce($fixture['data']['nonce']) + ->network($fixture['data']['network']) + ->gasLimit($fixture['data']['gasLimit']) + ->pay('0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5', '1000000000000000000') + ->pay('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', '2000000000000000000') + ->sign($this->passphrase); + + $this->assertSame($fixture['serialized'], $builder->transaction->serialize()->getHex()); + + $this->assertSame($fixture['data']['id'], $builder->transaction->data['id']); + + $this->assertTrue($builder->verify()); + } + + /** @test */ + public function it_should_handle_single_recipient() + { + $fixture = $this->getTransactionFixture('evm_call', 'multipayment-1'); + + $builder = MultipaymentBuilder::new() + ->gasPrice($fixture['data']['gasPrice']) + ->nonce($fixture['data']['nonce']) + ->network($fixture['data']['network']) + ->gasLimit($fixture['data']['gasLimit']) + ->pay('0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5', '1000000000000000000') + ->sign($this->passphrase); + + $this->assertSame($fixture['serialized'], $builder->transaction->serialize()->getHex()); + + $this->assertSame($fixture['data']['id'], $builder->transaction->data['id']); + + $this->assertTrue($builder->verify()); + } + + /** @test */ + public function it_should_handle_empty_payment() + { + $fixture = $this->getTransactionFixture('evm_call', 'multipayment-0'); + + $builder = MultipaymentBuilder::new() + ->gasPrice($fixture['data']['gasPrice']) + ->nonce($fixture['data']['nonce']) + ->network($fixture['data']['network']) + ->gasLimit($fixture['data']['gasLimit']) + ->sign($this->passphrase); + + $this->assertSame($fixture['serialized'], $builder->transaction->serialize()->getHex()); + + $this->assertSame($fixture['data']['id'], $builder->transaction->data['id']); + + $this->assertTrue($builder->verify()); + } + + // TODO: fix decoder issue first + // /** @test */ + // public function it_should_be_possible_to_create_manual_multipayment() + // { + // $fixture = $this->getTransactionFixture('evm_call', 'multipayment-1'); + + // $payload = (new AbiEncoder(ContractAbiType::MULTIPAYMENT))->encodeFunctionCall('pay', [['0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5'], ['1000000000000000000']]); + // $tx = (new Multipayment(['data' => $payload])); + // $tx->data['nonce'] = $fixture['data']['nonce']; + // $tx->data['network'] = $fixture['data']['network']; + // $tx->data['gasLimit'] = $fixture['data']['gasLimit']; + // $tx->data['gasPrice'] = $fixture['data']['gasPrice']; + // $tx->sign(PrivateKey::fromPassphrase($this->passphrase)); + + // $this->assertTrue($tx->verify()); + // } +} diff --git a/tests/fixtures/transactions/evm_call/multipayment-0.json b/tests/fixtures/transactions/evm_call/multipayment-0.json new file mode 100644 index 0000000..d1ccd2e --- /dev/null +++ b/tests/fixtures/transactions/evm_call/multipayment-0.json @@ -0,0 +1,16 @@ +{ + "data": { + "network": 30, + "nonce": "1", + "gasPrice": 5, + "gasLimit": 1000000, + "value": "0", + "recipientAddress": "0x83769BeEB7e5405ef0B7dc3C66C43E3a51A6d27f", + "data": "084ce7080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "signature": "97e06c7c8c2d7d9409d0330113784126ff87e873f777864295c28403e2c8fc1d2bf455c94b8b72e70d6ac414aadbcc5c9a3c3cca82686587345346ec5554d09500", + "senderPublicKey": "023efc1da7f315f3c533a4080e491f32cd4219731cef008976c3876539e1f192d3", + "senderAddress": "0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22", + "id": "8cc750a6260fcf0a56e88a5c0e8e1b8f6edda3747b9517533e3081fa78735290" + }, + "serialized": "1e01000000000000000500000040420f0000000000000000000000000000000000000000000000000000000000000000000183769beeb7e5405ef0b7dc3c66c43e3a51a6d27f84000000084ce708000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000097e06c7c8c2d7d9409d0330113784126ff87e873f777864295c28403e2c8fc1d2bf455c94b8b72e70d6ac414aadbcc5c9a3c3cca82686587345346ec5554d09500" +} diff --git a/tests/fixtures/transactions/evm_call/multipayment-1.json b/tests/fixtures/transactions/evm_call/multipayment-1.json new file mode 100644 index 0000000..ba63673 --- /dev/null +++ b/tests/fixtures/transactions/evm_call/multipayment-1.json @@ -0,0 +1,16 @@ +{ + "data": { + "network": 30, + "nonce": "19", + "gasPrice": 5, + "gasLimit": 1000000, + "value": "1000000000000000000", + "recipientAddress": "0x83769BeEB7e5405ef0B7dc3C66C43E3a51A6d27f", + "data": "084ce7080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000008233f6df6449d7655f4643d2e752dc8d2283fad500000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a7640000", + "signature": "fe5f074906a6afebb80bbcd77b932093667c74bbbec5b9cd32c2653ee7ac593e50c4279ea762dcd4096f90bf95a9aa5574cfb4c278e364c13b281d90356632c400", + "senderPublicKey": "023efc1da7f315f3c533a4080e491f32cd4219731cef008976c3876539e1f192d3", + "senderAddress": "0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22", + "id": "b86d867f1385c9f3c8e6a4b665618c911a28d2cd3d961ee5044491253d64fc5d" + }, + "serialized": "1e13000000000000000500000040420f000000000000000000000000000000000000000000000000000de0b6b3a76400000183769beeb7e5405ef0b7dc3c66c43e3a51a6d27fc4000000084ce7080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000008233f6df6449d7655f4643d2e752dc8d2283fad500000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a7640000fe5f074906a6afebb80bbcd77b932093667c74bbbec5b9cd32c2653ee7ac593e50c4279ea762dcd4096f90bf95a9aa5574cfb4c278e364c13b281d90356632c400" +} diff --git a/tests/fixtures/transactions/evm_call/multipayment.json b/tests/fixtures/transactions/evm_call/multipayment.json new file mode 100644 index 0000000..a7956d9 --- /dev/null +++ b/tests/fixtures/transactions/evm_call/multipayment.json @@ -0,0 +1,16 @@ +{ + "data": { + "network": 30, + "nonce": "18", + "gasPrice": 5, + "gasLimit": 1000000, + "value": "3000000000000000000", + "recipientAddress": "0x83769BeEB7e5405ef0B7dc3C66C43E3a51A6d27f", + "data": "084ce708000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000008233f6df6449d7655f4643d2e752dc8d2283fad50000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb2200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000001bc16d674ec80000", + "signature": "505f18f42724f6654895b230e1b5454000d2bea35140932ec8ffd2c8ee0a09da1ca1f516767b2b0873fcba39f663250ab4d2b1cad2637e657aacfe3ac28896b000", + "senderPublicKey": "023efc1da7f315f3c533a4080e491f32cd4219731cef008976c3876539e1f192d3", + "senderAddress": "0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22", + "id": "a275211280ee214a5e86a70c65b8f0963fcaa93d9bbcd889e7a4cfc0a96ae14b" + }, + "serialized": "1e12000000000000000500000040420f0000000000000000000000000000000000000000000000000029a2241af62c00000183769beeb7e5405ef0b7dc3c66c43e3a51a6d27f04010000084ce708000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000008233f6df6449d7655f4643d2e752dc8d2283fad50000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb2200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000001bc16d674ec80000505f18f42724f6654895b230e1b5454000d2bea35140932ec8ffd2c8ee0a09da1ca1f516767b2b0873fcba39f663250ab4d2b1cad2637e657aacfe3ac28896b000" +}