From 090eb9a1063271ed134763f0e0ad4c8fdafbb531 Mon Sep 17 00:00:00 2001 From: Alfonso Bribiesca Date: Mon, 8 Jun 2026 11:53:25 -0600 Subject: [PATCH] feat: add batch transfer builder --- src/Enums/AbiFunction.php | 2 + src/Enums/ContractAddresses.php | 7 +- .../Builder/BatchTransferBuilder.php | 77 ++++++++++++ .../Builder/BatchTransferBuilderTest.php | 116 ++++++++++++++++++ .../fixtures/transactions/batch-transfer.json | 17 +++ 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 src/Transactions/Builder/BatchTransferBuilder.php create mode 100644 tests/Unit/Transactions/Builder/BatchTransferBuilderTest.php create mode 100644 tests/fixtures/transactions/batch-transfer.json diff --git a/src/Enums/AbiFunction.php b/src/Enums/AbiFunction.php index afe0466..ca5f960 100644 --- a/src/Enums/AbiFunction.php +++ b/src/Enums/AbiFunction.php @@ -24,6 +24,7 @@ enum AbiFunction: string case MULTIPAYMENT = 'pay'; case TRANSFER = 'transfer'; case APPROVE = 'approve'; + case BATCH_TRANSFER_FROM = 'batchTransferFrom'; public function transactionClass(): string { @@ -37,6 +38,7 @@ public function transactionClass(): string self::MULTIPAYMENT => Multipayment::class, self::TRANSFER => EvmCall::class, self::APPROVE => EvmCall::class, + self::BATCH_TRANSFER_FROM => EvmCall::class, }; } } diff --git a/src/Enums/ContractAddresses.php b/src/Enums/ContractAddresses.php index aa73c0c..cd86c32 100644 --- a/src/Enums/ContractAddresses.php +++ b/src/Enums/ContractAddresses.php @@ -6,7 +6,8 @@ enum ContractAddresses: string { - case CONSENSUS = '0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1'; - case MULTIPAYMENT = '0x00EFd0D4639191C49908A7BddbB9A11A994A8527'; - case USERNAMES = '0x2c1DE3b4Dbb4aDebEbB5dcECAe825bE2a9fc6eb6'; + case CONSENSUS = '0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1'; + case MULTIPAYMENT = '0x00EFd0D4639191C49908A7BddbB9A11A994A8527'; + case USERNAMES = '0x2c1DE3b4Dbb4aDebEbB5dcECAe825bE2a9fc6eb6'; + case BATCH_TRANSFER = '0x5a223F4434D5Bd8478100EEb3b0166a57A26350d'; } diff --git a/src/Transactions/Builder/BatchTransferBuilder.php b/src/Transactions/Builder/BatchTransferBuilder.php new file mode 100644 index 0000000..428545c --- /dev/null +++ b/src/Transactions/Builder/BatchTransferBuilder.php @@ -0,0 +1,77 @@ + */ + private array $recipients = []; + + public function __construct(?array $data = null) + { + parent::__construct($data); + + $this->to(ContractAddresses::BATCH_TRANSFER->value); + } + + public function tokenAddress(string $tokenAddress): self + { + $this->tokenAddress = $tokenAddress; + + return $this; + } + + public function addRecipient(string $recipient, BigDecimal $amount): self + { + $this->recipients[] = ['recipient' => $recipient, 'amount' => $amount]; + + return $this; + } + + public function sign(string $passphrase): static + { + $this->encode(); + + return parent::sign($passphrase); + } + + protected function getTransactionInstance(array $data): AbstractTransaction + { + return new EvmCall($data); + } + + private function encode(): void + { + if (count($this->recipients) === 0) { + throw new Exception('Must add at least one recipient before encoding.'); + } + + if ($this->tokenAddress === null) { + throw new Exception('Must set tokenAddress before encoding.'); + } + + $addresses = array_map(fn (array $recipient) => $recipient['recipient'], $this->recipients); + $amounts = array_map(fn (array $recipient) => $recipient['amount'], $this->recipients); + + $payload = (new AbiEncoder(ContractAbiType::ERC20BATCH_TRANSFER))->encodeFunctionCall( + AbiFunction::BATCH_TRANSFER_FROM->value, + [$this->tokenAddress, $addresses, $amounts] + ); + + $this->transaction->data['data'] = Helpers::removeLeadingHexZero($payload); + } +} diff --git a/tests/Unit/Transactions/Builder/BatchTransferBuilderTest.php b/tests/Unit/Transactions/Builder/BatchTransferBuilderTest.php new file mode 100644 index 0000000..3c8258a --- /dev/null +++ b/tests/Unit/Transactions/Builder/BatchTransferBuilderTest.php @@ -0,0 +1,116 @@ +fixture = $this->getFixture('transactions/batch-transfer'); +}); + +it('should build a batch transfer', function () { + $builder = BatchTransferBuilder::new() + ->nonce($this->fixture['data']['nonce']) + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->addRecipient('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', BigDecimal::of('100000')) + ->addRecipient('0xc3bbe9b1cee1ff85ad72b87414b0e9b7f2366763', BigDecimal::of('200000')) + ->tokenAddress($this->fixture['data']['tokenAddress']) + ->sign($this->passphrase); + + expect((string) $builder->transaction->data['gasPrice'])->toBe($this->fixture['data']['gasPrice']); + expect((string) $builder->transaction->data['gasLimit'])->toBe($this->fixture['data']['gasLimit']); + expect($builder->transaction->data['nonce'])->toBe($this->fixture['data']['nonce']); + expect($builder->transaction->data['value']->isZero())->toBeTrue(); + expect($builder->transaction->data['to'])->toBe(ContractAddresses::BATCH_TRANSFER->value); + expect($builder->transaction->data['data'])->toBe($this->fixture['data']['data']); + expect($builder->transaction->data['v'])->toBe($this->fixture['data']['v']); + expect($builder->transaction->data['r'])->toBe($this->fixture['data']['r']); + expect($builder->transaction->data['s'])->toBe($this->fixture['data']['s']); + expect($builder->transaction->data['hash'])->toBe($this->fixture['data']['hash']); + + expect($builder->transaction->serialize()->getHex())->toBe($this->fixture['serialized']); + expect($builder->verify())->toBeTrue(); +}); + +it('should encode a single recipient', function () { + $builder = BatchTransferBuilder::new() + ->nonce($this->fixture['data']['nonce']) + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->addRecipient('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', BigDecimal::of('100000')) + ->tokenAddress($this->fixture['data']['tokenAddress']) + ->sign($this->passphrase); + + expect($builder->verify())->toBeTrue(); + expect($builder->transaction->data['value']->isZero())->toBeTrue(); + expect($builder->transaction->data['data'])->toStartWith('4885b254'); +}); + +it('should encode large amounts', function () { + $builder = BatchTransferBuilder::new() + ->nonce($this->fixture['data']['nonce']) + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->addRecipient('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', BigDecimal::of('1000000000000000000000')) + ->tokenAddress($this->fixture['data']['tokenAddress']) + ->sign($this->passphrase); + + expect($builder->verify())->toBeTrue(); +}); + +it('should convert to json when casting to string', function () { + $builder = BatchTransferBuilder::new() + ->nonce($this->fixture['data']['nonce']) + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->addRecipient('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', BigDecimal::of('100000')) + ->tokenAddress($this->fixture['data']['tokenAddress']) + ->sign($this->passphrase); + + expect((string) $builder)->toBe($builder->toJson()); +}); + +it('should convert to an array', function () { + $builder = BatchTransferBuilder::new() + ->nonce($this->fixture['data']['nonce']) + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->addRecipient('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', BigDecimal::of('100000')) + ->addRecipient('0xc3bbe9b1cee1ff85ad72b87414b0e9b7f2366763', BigDecimal::of('200000')) + ->tokenAddress($this->fixture['data']['tokenAddress']) + ->sign($this->passphrase); + + expect($builder->toArray())->toBe([ + 'gasPrice' => $builder->transaction->data['gasPrice'], + 'gasLimit' => $builder->transaction->data['gasLimit'], + 'hash' => $builder->transaction->data['hash'], + 'nonce' => $builder->transaction->data['nonce'], + 'senderPublicKey' => $builder->transaction->data['senderPublicKey'], + 'to' => $builder->transaction->data['to'], + 'value' => $builder->transaction->data['value'], + 'data' => $builder->transaction->data['data'], + 'r' => $builder->transaction->data['r'], + 's' => $builder->transaction->data['s'], + 'v' => $builder->transaction->data['v'], + ]); +}); + +it('should throw when signing with no recipients', function () { + BatchTransferBuilder::new() + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->tokenAddress($this->fixture['data']['tokenAddress']) + ->sign($this->passphrase); +})->throws(Exception::class, 'Must add at least one recipient before encoding.'); + +it('should throw when signing without a token address', function () { + BatchTransferBuilder::new() + ->gasPrice(UnitConverter::parseUnits($this->fixture['data']['gasPrice'], 'wei')) + ->gasLimit(UnitConverter::parseUnits($this->fixture['data']['gasLimit'], 'wei')) + ->addRecipient('0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22', BigDecimal::of('100000')) + ->sign($this->passphrase); +})->throws(Exception::class, 'Must set tokenAddress before encoding.'); diff --git a/tests/fixtures/transactions/batch-transfer.json b/tests/fixtures/transactions/batch-transfer.json new file mode 100644 index 0000000..3fbe0c4 --- /dev/null +++ b/tests/fixtures/transactions/batch-transfer.json @@ -0,0 +1,17 @@ +{ + "data": { + "value": "0", + "senderPublicKey": "0243333347c8cbf4e3cbc7a96964181d02a2b0c854faa2fef86b4b8d92afcf473d", + "gasPrice": "5000000000", + "gasLimit": "21000", + "nonce": "1", + "data": "4885b254000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb22000000000000000000000000c3bbe9b1cee1ff85ad72b87414b0e9b7f2366763000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "to": "0x5a223F4434D5Bd8478100EEb3b0166a57A26350d", + "v": 1, + "r": "dc7cac17d9961b4730ec5426eecb94de93e6bf44a3c6d13b00413948646d18e5", + "s": "35220c92a43698524f9023727b68cd6f01753bf6d3128f0af4b78343ff6897e6", + "hash": "59c1dcace1b2573921909d9ad1ca475f49b890f2cb147030462a5b9076b6a5dd" + }, + "serialized": "f9018c0185012a05f200825208945a223f4434d5bd8478100eeb3b0166a57a26350d80b901244885b254000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb22000000000000000000000000c3bbe9b1cee1ff85ad72b87414b0e9b7f2366763000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40825c6ca0dc7cac17d9961b4730ec5426eecb94de93e6bf44a3c6d13b00413948646d18e5a035220c92a43698524f9023727b68cd6f01753bf6d3128f0af4b78343ff6897e6" +}