From 6bd3a853b3c17748f96c1080a27f5d9ba9d01594 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Sat, 25 Jul 2026 15:23:28 -0600 Subject: [PATCH] Initial Contractless PHP RPC lib --- .gitignore | 1 + README.md | 170 ++++++++++++ composer.json | 25 ++ src/Client.php | 323 ++++++++++++++++++++++ src/Crypto/CallbackCrypto.php | 41 +++ src/Crypto/CryptoInterface.php | 21 ++ src/Crypto/NativeCrypto.php | 77 ++++++ src/Exception/ContractlessException.php | 11 + src/Exception/ProtocolException.php | 9 + src/Exception/TransportException.php | 9 + src/Protocol/Address.php | 49 ++++ src/Protocol/Binary.php | 170 ++++++++++++ src/Protocol/Command.php | 57 ++++ src/Protocol/RequestBuilder.php | 47 ++++ src/Transaction/AgreementBuilder.php | 109 ++++++++ src/Transaction/AssetBuilder.php | 124 +++++++++ src/Transaction/DualSignedTransaction.php | 119 ++++++++ src/Transaction/GovernanceBuilder.php | 87 ++++++ src/Transaction/MiscellaneousBuilder.php | 102 +++++++ src/Transaction/SignedTransaction.php | 20 ++ src/Transaction/SignsTransactions.php | 29 ++ src/Transaction/StorageBuilder.php | 219 +++++++++++++++ src/Transaction/TransferBuilder.php | 63 +++++ src/Transport/StreamTransport.php | 114 ++++++++ src/Transport/TransportInterface.php | 17 ++ src/Wallet/Credentials.php | 26 ++ tests/run.php | 141 ++++++++++ 27 files changed, 2180 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Client.php create mode 100644 src/Crypto/CallbackCrypto.php create mode 100644 src/Crypto/CryptoInterface.php create mode 100644 src/Crypto/NativeCrypto.php create mode 100644 src/Exception/ContractlessException.php create mode 100644 src/Exception/ProtocolException.php create mode 100644 src/Exception/TransportException.php create mode 100644 src/Protocol/Address.php create mode 100644 src/Protocol/Binary.php create mode 100644 src/Protocol/Command.php create mode 100644 src/Protocol/RequestBuilder.php create mode 100644 src/Transaction/AgreementBuilder.php create mode 100644 src/Transaction/AssetBuilder.php create mode 100644 src/Transaction/DualSignedTransaction.php create mode 100644 src/Transaction/GovernanceBuilder.php create mode 100644 src/Transaction/MiscellaneousBuilder.php create mode 100644 src/Transaction/SignedTransaction.php create mode 100644 src/Transaction/SignsTransactions.php create mode 100644 src/Transaction/StorageBuilder.php create mode 100644 src/Transaction/TransferBuilder.php create mode 100644 src/Transport/StreamTransport.php create mode 100644 src/Transport/TransportInterface.php create mode 100644 src/Wallet/Credentials.php create mode 100644 tests/run.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..514a0b0 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# PHP Contractless RPC + +`php-contractless-rpc` connects PHP applications directly to a Contractless +node. It implements the authenticated binary RPC protocol without invoking +Contractless CLI programs. + +The package is intended for faucets, block explorers, wallets, application +backends, and other public Contractless integrations. + +## Scope + +The client includes public blockchain lookups, wallet and address lookups, +mempool lookups, asset lookups, loan lookups, storage lookups, governance +lookups, wallet registration, signed transaction submission, and an RPC +interface restricted to application-safe commands. + +Miner relay commands, network mapping changes, monitor-state commands, node +setup synchronization, and server-owner IP controls are intentionally not +exposed. + +## Requirements + +- PHP 8.1 or newer, running as a 64-bit build +- The `skein` module from `contractless-php-crypto` +- The `oqsphp` module from `contractless-php-crypto` + +Contractless uses the same Falcon key and signature sizes as +`Falcon-padded-512`, but its FN-DSA signing mode requires a small message +adaptation. `NativeCrypto` performs that adaptation automatically. + +## Installation + +Install and enable `contractless-php-crypto` first. Its repository contains the +native module build, installation, and compatibility-test instructions. + +When this package is available through Packagist: + +```bash +composer require contractless/php-contractless-rpc +``` + +Until the package is published, add it as a Composer path repository: + +```json +{ + "repositories": [ + { + "type": "path", + "url": "../php-contractless-rpc" + } + ], + "require": { + "contractless/php-contractless-rpc": "@dev" + } +} +``` + +Then install it: + +```bash +composer update contractless/php-contractless-rpc +``` + +## Basic Use + +```php +totalBalance( + 'ab13318c26250b048db92920a80a86127c933b0c.cltc', +); +``` + +RPC replies are returned as their original bytes. Commands that return JSON can +be decoded with `json_decode($reply, true, flags: JSON_THROW_ON_ERROR)`. Binary +block, torrent, header, and transaction replies remain available without a +lossy conversion. + +## Transaction Submission + +`submitTransaction()` accepts the complete serialized signed transaction as a +hexadecimal string: + +```php +$reply = $client->submitTransaction($signedTransactionHex); +``` + +The first transaction builder covers the transfer used by faucets and ordinary +currency, token, NFT, and RWA transfers: + +```php +use Contractless\Rpc\Transaction\TransferBuilder; + +$transfer = (new TransferBuilder($crypto, $credentials))->create( + sender: 'sender-address.cltc', + receiver: 'receiver-address.cltc', + coin: 'CLTC', + value: 2_500_000_000, + fee: 2_500, +); + +$reply = $client->submitSignedTransaction($transfer); +``` + +Transaction builders are a separate layer over the protocol client. This keeps +transport and node access stable when new transaction types are introduced. + +## Transaction Builders + +- `TransferBuilder`: currency, token, NFT, and RWA transfers +- `AssetBuilder`: token creation, token issuance, NFT/RWA creation, burns, and vanity addresses +- `AgreementBuilder`: two-party swaps and loan contracts +- `MiscellaneousBuilder`: marketing records, loan payments, and collateral claims +- `StorageBuilder`: storage keys, bool, signed and unsigned integers, strings, and deletion +- `GovernanceBuilder`: proposal keys, proposal votes, and activation votes + +Swaps and loans return `DualSignedTransaction`. The first signer exports it, +the second signer imports it and calls `signNext()`, and only a complete result +can be converted with `toSignedTransaction()` and broadcast. + +The 128-bit storage methods accept decimal strings. This preserves the complete +Rust `u128` and `i128` ranges instead of silently losing precision through PHP +integers or floating-point numbers. + +## Wallet Material + +The library accepts raw Falcon public and private key bytes. It does not read or +decrypt a Contractless private-key image. A server application should load keys +from protected secrets and must never place them in a public web directory. + +## Testing + +```bash +composer install +composer test +``` + +## Preparing The Composer Package + +Validate the package before publishing: + +```bash +composer validate --strict +composer install +composer test +``` + +Commit `composer.json`, `README.md`, `src/`, and `tests/` to the +`php-contractless-rpc` repository. Create a release tag such as `v0.1.0`, then +submit the repository URL to Packagist. Future version tags become installable +Composer releases. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..175cd34 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "contractless/php-contractless-rpc", + "description": "Native PHP client for the Contractless blockchain RPC protocol.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.1", + "ext-json": "*", + "ext-oqsphp": "*", + "ext-skein": "*" + }, + "autoload": { + "psr-4": { + "Contractless\\Rpc\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Contractless\\Rpc\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "php tests/run.php" + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..21ab625 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,323 @@ +buildHandshake(); + $request = RequestBuilder::request($command, $uid, $payload); + + return $this->transport->exchange( + $handshake, + $request, + $uid, + function (string $response): void { + $this->validateHandshake($response); + }, + ); + } + + public function networkInfo(): string { return $this->call(Command::NETWORK_INFO); } + public function blockHeight(): string { return $this->call(Command::BLOCK_HEIGHT); } + public function nodeTime(): string { return $this->call(Command::TIME); } + public function difficulty(): string { return $this->call(Command::DIFFICULTY); } + public function latestBlock(): string { return $this->call(Command::LATEST_BLOCK); } + public function largestTransactionFee(): string { return $this->call(Command::LARGEST_TX_FEE); } + public function mempoolCount(): string { return $this->call(Command::MEMPOOL_TX_COUNT); } + public function totalConfirmedTransactions(): string { return $this->call(Command::TOTAL_CONFIRMED_TX); } + public function allHeaders(): string { return $this->call(Command::ALL_HEADERS); } + public function tokenList(): string { return $this->call(Command::TOKEN_LIST); } + public function nftList(): string { return $this->call(Command::NFT_LIST); } + public function tokenCatalog(): string { return $this->call(Command::TOKEN_CATALOG); } + + public function blockByHeight(int $height): string + { + return $this->call(Command::BLOCK_BY_HEIGHT, Binary::u32($height)); + } + + public function blockByHash(string $hash): string + { + return $this->call(Command::BLOCK_BY_HASH, RequestBuilder::hash($hash, 'Block hash')); + } + + public function torrentByHeight(int $height): string + { + return $this->call(Command::TORRENT_BY_HEIGHT, Binary::u32($height)); + } + + public function headerByHeight(int $height): string + { + return $this->call(Command::HEADER_BY_HEIGHT, Binary::u32($height)); + } + + public function headerByHash(string $hash): string + { + return $this->call(Command::HEADER_BY_HASH, RequestBuilder::hash($hash, 'Header hash')); + } + + public function blockHashAtHeight(int $height): string + { + return $this->call(Command::BLOCK_HASH_AT_HEIGHT, Binary::u32($height)); + } + + public function submitTransaction(string $transactionHex): string + { + return $this->call(Command::SUBMIT_TRANSACTION, RequestBuilder::transaction($transactionHex)); + } + + public function submitSignedTransaction( + \Contractless\Rpc\Transaction\SignedTransaction $transaction, + ): string { + return $this->call(Command::SUBMIT_TRANSACTION, $transaction->bytes); + } + + public function mempoolTransactionBySignature(string $signature): string + { + return $this->call(Command::MEMPOOL_TX_BY_SIGNATURE, RequestBuilder::signature($signature)); + } + + public function mempoolTransactionsByAddress(string $address): string + { + return $this->call(Command::MEMPOOL_TX_BY_ADDRESS, Address::toBytes($address)); + } + + public function transactionById(string $txid): string + { + return $this->call(Command::TRANSACTION_BY_TXID, RequestBuilder::hash($txid, 'Transaction ID')); + } + + public function coinBalance(string $coin, string $address): string + { + return $this->call( + Command::ADDRESS_COIN_BALANCE, + Binary::fixedText($coin, 15, 'Coin name') . Address::toBytes($address), + ); + } + + public function totalBalance(string $address): string + { + return $this->call(Command::ADDRESS_TOTAL_BALANCE, Address::toBytes($address)); + } + + public function validateAddress(string $address): string + { + return $this->call(Command::VALIDATE_ADDRESS, Address::toBytes($address)); + } + + public function validateMessage(string $message, string $address, string $signature): string + { + if (strlen($message) > 0xffff) { + throw new ProtocolException('Signed message must be 65,535 bytes or less.'); + } + return $this->call( + Command::VALIDATE_MESSAGE, + Binary::u16(strlen($message)) + . $message + . Address::toBytes($address) + . RequestBuilder::signature($signature), + ); + } + + public function loanByHash(string $hash): string + { + return $this->call(Command::LOAN_CONTRACT, RequestBuilder::hash($hash, 'Loan hash')); + } + + public function tokenDetails(string $name): string + { + return $this->call(Command::TOKEN_DETAILS, Binary::fixedText($name, 15, 'Token name')); + } + + public function nftDetails(string $name, int $series): string + { + return $this->call( + Command::NFT_DETAILS, + Binary::fixedText($name, 15, 'NFT name') . Binary::u32($series), + ); + } + + public function contractsByAddress(string $address): string + { + return $this->call(Command::CONTRACT_BY_ADDRESS, Address::toBytes($address)); + } + + public function registerWallet(string $address, string $signature): string + { + return $this->call( + Command::REGISTER_WALLET, + Address::toBytes($address) + . $this->credentials->publicKey + . RequestBuilder::signature($signature), + ); + } + + public function registerOwnedWallet(string $address): string + { + $signedPayload = Binary::u8(Command::REGISTER_WALLET) + . Address::toBytes($address) + . $this->credentials->publicKey; + $signature = $this->crypto->sign( + $this->crypto->skein256($signedPayload), + $this->credentials->privateKey, + $this->credentials->publicKey, + ); + return $this->registerWallet($address, bin2hex($signature)); + } + + public function vanityLookup(string $address): string + { + return $this->call(Command::VANITY_LOOKUP, Address::toBytes($address)); + } + + public function walletRegistrationStatus(string $address): string + { + return $this->call(Command::WALLET_REGISTRATION_STATUS, Address::toBytes($address)); + } + + public function vanityOwner(string $address): string + { + return $this->call(Command::VANITY_OWNER_LOOKUP, Address::vanityToBytes($address)); + } + + public function addressHistory(string $address, int $skip, int $limit): string + { + return $this->call( + Command::ADDRESS_HISTORY, + Address::toBytes($address) . Binary::u32($skip) . Binary::u32($limit), + ); + } + + public function latestAddressTransactions(string $address, int $limit): string + { + return $this->call( + Command::LATEST_ADDRESS_TRANSACTIONS, + Address::toBytes($address) . Binary::u32($limit), + ); + } + + public function marketingCampaignHistory( + string $advertiser, + int $campaign, + int $skip, + int $limit, + ): string { + return $this->call( + Command::MARKETING_CAMPAIGN_HISTORY, + Address::toBytes($advertiser) + . Binary::u64($campaign) + . Binary::u32($skip) + . Binary::u32($limit), + ); + } + + public function collateralStatus(string $contractHash): string + { + return $this->call( + Command::COLLATERAL_STATUS, + RequestBuilder::hash($contractHash, 'Contract hash'), + ); + } + + public function storageLookupCost(string $storageKey, string $dataKey, string $address): string + { + return $this->call( + Command::STORAGE_LOOKUP_COST, + RequestBuilder::hash($storageKey, 'Storage key') + . RequestBuilder::storageSelector($dataKey) + . Address::toBytes($address), + ); + } + + public function storageLookup( + string $storageKey, + string $dataKey, + string $address, + string $paymentTransactionHex, + ): string { + $transaction = RequestBuilder::transaction($paymentTransactionHex); + if (ord($transaction[0]) !== 2) { + throw new ProtocolException('Storage lookup payment must be a type 2 transfer transaction.'); + } + return $this->call( + Command::STORAGE_LOOKUP, + RequestBuilder::hash($storageKey, 'Storage key') + . RequestBuilder::storageSelector($dataKey) + . Address::toBytes($address) + . $transaction, + ); + } + + public function governanceProposal(string $proposalKey): string + { + return $this->call( + Command::GOVERNANCE_PROPOSAL, + RequestBuilder::hash($proposalKey, 'Proposal key'), + ); + } + + private function buildHandshake(): string + { + $digest = $this->crypto->skein256('aced'); + $signature = $this->crypto->sign( + $digest, + $this->credentials->privateKey, + $this->credentials->publicKey, + ); + + return self::CLIENT_MARKER + . $signature + . $this->credentials->publicKey + . Binary::u32(time()) + . str_repeat("\0", 18); + } + + private function validateHandshake(string $response): void + { + Binary::length($response, 1565, 'Handshake response'); + $marker = substr($response, 0, 2); + $signature = substr($response, 2, 666); + $publicKey = substr($response, 668, 897); + if ($marker !== self::SERVER_MARKER) { + throw new ProtocolException('Handshake server marker is invalid.'); + } + + $digest = $this->crypto->skein256('ecaf'); + if (!$this->crypto->verify($digest, $signature, $publicKey)) { + throw new ProtocolException('Handshake server signature is invalid.'); + } + } +} diff --git a/src/Crypto/CallbackCrypto.php b/src/Crypto/CallbackCrypto.php new file mode 100644 index 0000000..385c95b --- /dev/null +++ b/src/Crypto/CallbackCrypto.php @@ -0,0 +1,41 @@ +skein)($message); + if (!is_string($digest) || strlen($digest) !== 32) { + throw new ProtocolException('The Skein callback must return exactly 32 raw bytes.'); + } + return $digest; + } + + public function sign(string $message, string $privateKey, string $publicKey): string + { + $signature = ($this->signer)($message, $privateKey, $publicKey); + if (!is_string($signature) || strlen($signature) !== 666) { + throw new ProtocolException('The signing callback must return exactly 666 raw bytes.'); + } + return $signature; + } + + public function verify(string $message, string $signature, string $publicKey): bool + { + return (bool) ($this->verifier)($message, $signature, $publicKey); + } +} diff --git a/src/Crypto/CryptoInterface.php b/src/Crypto/CryptoInterface.php new file mode 100644 index 0000000..1933ac1 --- /dev/null +++ b/src/Crypto/CryptoInterface.php @@ -0,0 +1,21 @@ +falcon = new \OQS_SIGNATURE(self::ALGORITHM); + } + + public function skein256(string $message): string + { + $digest = skein_hash($message, 256); + if (!is_string($digest) || strlen($digest) !== 32) { + throw new ContractlessException('Skein-256 hashing failed.'); + } + return $digest; + } + + public function sign(string $message, string $privateKey, string $publicKey): string + { + Binary::length($privateKey, 1281, 'Falcon private key'); + Binary::length($publicKey, 897, 'Falcon public key'); + $adapted = $this->adaptMessage($message, $publicKey); + $signature = ''; + $status = $this->falcon->sign($signature, $adapted, $privateKey); + if ($status !== 0 || strlen($signature) !== 666) { + throw new ContractlessException('Falcon-padded-512 signing failed.'); + } + return $signature; + } + + public function verify(string $message, string $signature, string $publicKey): bool + { + Binary::length($signature, 666, 'Falcon signature'); + Binary::length($publicKey, 897, 'Falcon public key'); + return $this->falcon->verify( + $this->adaptMessage($message, $publicKey), + $signature, + $publicKey, + ) === 0; + } + + private function adaptMessage(string $message, string $publicKey): string + { + $publicKeyHash = contractless_shake256($publicKey, 64); + if (!is_string($publicKeyHash) || strlen($publicKeyHash) !== 64) { + throw new ContractlessException('SHAKE256 public-key hashing failed.'); + } + + // fn-dsa uses DOMAIN_NONE and HASH_ID_RAW before the original message. + return $publicKeyHash . "\0\0" . $message; + } +} diff --git a/src/Exception/ContractlessException.php b/src/Exception/ContractlessException.php new file mode 100644 index 0000000..5595419 --- /dev/null +++ b/src/Exception/ContractlessException.php @@ -0,0 +1,11 @@ + 1, + 'cltc' => 2, + ]; + + public static function toBytes(string $address): string + { + $separator = strrpos($address, '.'); + if ($separator === false) { + throw new ProtocolException('A short address must include its network suffix.'); + } + + $payload = substr($address, 0, $separator); + $network = strtolower(substr($address, $separator + 1)); + $networkByte = self::NETWORK_BYTES[$network] ?? null; + if ($networkByte === null) { + throw new ProtocolException('Address network must be .clc or .cltc.'); + } + + return Binary::hex($payload, 20, 'Short address') . '.' . chr($networkByte); + } + + public static function vanityToBytes(string $address): string + { + $separator = strrpos($address, '.'); + if ($separator === false) { + throw new ProtocolException('A vanity address must include its network suffix.'); + } + + $payload = substr($address, 0, $separator); + $network = strtolower(substr($address, $separator + 1)); + $networkByte = self::NETWORK_BYTES[$network] ?? null; + if ($networkByte === null || strlen($payload) !== 20 || !ctype_alpha(ltrim($payload))) { + throw new ProtocolException('Invalid fixed-width vanity address.'); + } + + return strtolower($payload) . '.' . chr($networkByte); + } +} diff --git a/src/Protocol/Binary.php b/src/Protocol/Binary.php new file mode 100644 index 0000000..e533c87 --- /dev/null +++ b/src/Protocol/Binary.php @@ -0,0 +1,170 @@ +> 32); + } + + public static function i8(int $value): string + { + self::range($value, -128, 127, 'i8'); + return pack('c', $value); + } + + public static function i16(int $value): string + { + self::range($value, -32768, 32767, 'i16'); + return pack('v', $value & 0xffff); + } + + public static function i32(int $value): string + { + self::range($value, -2147483648, 2147483647, 'i32'); + return pack('V', $value & 0xffffffff); + } + + public static function i64(int $value): string + { + if (PHP_INT_SIZE < 8) { + throw new ProtocolException('i64 values require 64-bit PHP.'); + } + return pack('V2', $value & 0xffffffff, $value >> 32); + } + + public static function u128(string $value): string + { + return self::decimalInteger($value, false); + } + + public static function i128(string $value): string + { + return self::decimalInteger($value, true); + } + + public static function readU32(string $bytes): int + { + self::length($bytes, 4, 'u32'); + return unpack('Vvalue', $bytes)['value']; + } + + public static function hex(string $hex, int $bytes, string $field): string + { + if (strlen($hex) !== $bytes * 2 || !ctype_xdigit($hex)) { + throw new ProtocolException("$field must be exactly $bytes bytes of hexadecimal data."); + } + + $decoded = hex2bin($hex); + if ($decoded === false) { + throw new ProtocolException("$field is not valid hexadecimal data."); + } + return $decoded; + } + + public static function fixedText(string $value, int $bytes, string $field): string + { + if (strlen($value) > $bytes) { + throw new ProtocolException("$field must be $bytes bytes or less."); + } + return str_pad($value, $bytes, ' '); + } + + public static function length(string $value, int $bytes, string $field): void + { + if (strlen($value) !== $bytes) { + throw new ProtocolException("$field must be exactly $bytes bytes."); + } + } + + private static function range(int $value, int $minimum, int $maximum, string $type): void + { + if ($value < $minimum || $value > $maximum) { + throw new ProtocolException("$value is outside the $type range."); + } + } + + private static function decimalInteger(string $value, bool $signed): string + { + $value = trim($value); + $negative = str_starts_with($value, '-'); + $digits = $negative ? substr($value, 1) : $value; + if ($digits === '' || !ctype_digit($digits) || (!$signed && $negative)) { + throw new ProtocolException('Invalid 128-bit decimal integer.'); + } + $digits = ltrim($digits, '0'); + $digits = $digits === '' ? '0' : $digits; + + $maximum = $signed + ? ($negative ? '170141183460469231731687303715884105728' : '170141183460469231731687303715884105727') + : '340282366920938463463374607431768211455'; + if (strlen($digits) > strlen($maximum) + || (strlen($digits) === strlen($maximum) && strcmp($digits, $maximum) > 0) + ) { + throw new ProtocolException('128-bit decimal integer is outside its allowed range.'); + } + + $bytes = ''; + for ($index = 0; $index < 16; $index++) { + [$digits, $remainder] = self::divideDecimal($digits, 256); + $bytes .= chr($remainder); + } + if ($digits !== '0') { + throw new ProtocolException('128-bit decimal integer is too large.'); + } + + if ($negative) { + $carry = 1; + for ($index = 0; $index < 16; $index++) { + $next = ((~ord($bytes[$index])) & 0xff) + $carry; + $bytes[$index] = chr($next & 0xff); + $carry = $next > 0xff ? 1 : 0; + } + } + return $bytes; + } + + /** @return array{string, int} */ + private static function divideDecimal(string $digits, int $divisor): array + { + $quotient = ''; + $remainder = 0; + for ($index = 0, $length = strlen($digits); $index < $length; $index++) { + $number = ($remainder * 10) + (ord($digits[$index]) - 48); + $digit = intdiv($number, $divisor); + if ($quotient !== '' || $digit !== 0) { + $quotient .= (string) $digit; + } + $remainder = $number % $divisor; + } + return [$quotient === '' ? '0' : $quotient, $remainder]; + } +} diff --git a/src/Protocol/Command.php b/src/Protocol/Command.php new file mode 100644 index 0000000..e716afe --- /dev/null +++ b/src/Protocol/Command.php @@ -0,0 +1,57 @@ + 6, 'timestamp' => $timestamp, 'offer_expiration' => $expiration, + 'ticker1' => $ticker1, 'nft_series1' => $series1, 'value1' => $value1, + 'ticker2' => $ticker2, 'nft_series2' => $series2, 'value2' => $value2, + 'sender1' => $sender1, 'sender2' => $sender2, 'tip1' => $tip1, + 'tip2' => $tip2, 'txfee1' => $fee1, 'txfee2' => $fee2, + ]; + $json = $this->json($unsigned); + $hash = $this->crypto->skein256($json); + $bytes = Binary::u8(6) . Binary::u32($timestamp) . Binary::u32($expiration) + . $ticker1 . Binary::u32($series1) . Binary::u64($value1) + . $ticker2 . Binary::u32($series2) . Binary::u64($value2) + . Address::toBytes($sender1) . Address::toBytes($sender2) + . Binary::u64($tip1) . Binary::u64($tip2) + . Binary::u64($fee1) . Binary::u64($fee2); + return (new DualSignedTransaction($json, $bytes, $hash)) + ->signNext($this->crypto, $this->credentials); + } + + public function loan( + string $loanCoin, + int $loanAmount, + string $lender, + string $collateral, + int $collateralAmount, + string $borrower, + string $paymentPeriod, + int $paymentNumber, + int $paymentAmount, + int $gracePeriod, + int $maxLateValue, + int $fee, + ?int $timestamp = null, + ): DualSignedTransaction { + $timestamp ??= time(); + $loanCoin = Binary::fixedText($loanCoin, 15, 'Loan asset'); + $collateral = Binary::fixedText($collateral, 21, 'Collateral asset'); + if (strlen($paymentPeriod) !== 1) { + throw new ProtocolException('Payment period must be one byte: d, w, or m.'); + } + $unsigned = [ + 'txtype' => 7, 'timestamp' => $timestamp, 'loan_coin' => $loanCoin, + 'loan_amount' => $loanAmount, 'lender' => $lender, + 'collateral' => $collateral, 'collateral_amount' => $collateralAmount, + 'borrower' => $borrower, 'payment_period' => $paymentPeriod, + 'payment_number' => $paymentNumber, 'payment_amount' => $paymentAmount, + 'grace_period' => $gracePeriod, 'max_late_value' => $maxLateValue, + 'txfee' => $fee, + ]; + $json = $this->json($unsigned); + $hash = $this->crypto->skein256($json); + $bytes = Binary::u8(7) . Binary::u32($timestamp) . $loanCoin + . Binary::u64($loanAmount) . Address::toBytes($lender) + . $collateral . Binary::u64($collateralAmount) + . Address::toBytes($borrower) . $paymentPeriod + . Binary::u8($paymentNumber) . Binary::u64($paymentAmount) + . Binary::u8($gracePeriod) . Binary::u64($maxLateValue) + . Binary::u64($fee); + return (new DualSignedTransaction($json, $bytes, $hash, storesHash: true)) + ->signNext($this->crypto, $this->credentials); + } + + private function json(array $unsigned): string + { + return json_encode( + $unsigned, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ); + } +} diff --git a/src/Transaction/AssetBuilder.php b/src/Transaction/AssetBuilder.php new file mode 100644 index 0000000..7fb5ac4 --- /dev/null +++ b/src/Transaction/AssetBuilder.php @@ -0,0 +1,124 @@ +crypto = $crypto; + $this->credentials = $credentials; + } + + public function createToken( + string $creator, + string $ticker, + int $number, + bool $hardLimit, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $ticker = Binary::fixedText($ticker, 15, 'Token ticker'); + $unsigned = [ + 'txtype' => 3, 'time' => $timestamp, 'creator' => $creator, + 'ticker' => $ticker, 'number' => $number, + 'hard_limit' => $hardLimit ? 1 : 0, 'txfee' => $fee, + ]; + $binary = Binary::u8(3) . Binary::u32($timestamp) . Address::toBytes($creator) + . $ticker . Binary::u64($number) . Binary::u8($hardLimit ? 1 : 0) + . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function issueToken( + string $creator, + string $ticker, + int $number, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $ticker = Binary::fixedText($ticker, 15, 'Token ticker'); + $unsigned = [ + 'txtype' => 11, 'time' => $timestamp, 'creator' => $creator, + 'ticker' => $ticker, 'number' => $number, 'txfee' => $fee, + ]; + $binary = Binary::u8(11) . Binary::u32($timestamp) . Address::toBytes($creator) + . $ticker . Binary::u64($number) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function createNft( + string $creator, + bool $series, + bool $fractionalOwnership, + string $name, + string $ipfs, + int $count, + string $description, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $name = Binary::fixedText($name, 15, 'NFT name'); + $ipfs = Binary::fixedText($ipfs, 100, 'NFT IPFS location'); + $description = Binary::fixedText($description, 100, 'NFT description'); + $unsigned = [ + 'txtype' => 4, 'time' => $timestamp, 'creator' => $creator, + 'series' => $series ? 1 : 0, + 'ownership_type' => $fractionalOwnership ? 1 : 0, + 'nft_name' => $name, 'item_ipfs' => $ipfs, 'count' => $count, + 'desc' => $description, 'txfee' => $fee, + ]; + $binary = Binary::u8(4) . Binary::u32($timestamp) . Address::toBytes($creator) + . Binary::u8($series ? 1 : 0) . Binary::u8($fractionalOwnership ? 1 : 0) + . $name . $ipfs . Binary::u32($count) . $description . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function burn( + string $address, + string $coin, + int $nftSeries, + int $value, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $coin = Binary::fixedText($coin, 15, 'Asset name'); + $unsigned = [ + 'txtype' => 10, 'time' => $timestamp, 'address' => $address, + 'coin' => $coin, 'nft_series' => $nftSeries, 'value' => $value, + 'txfee' => $fee, + ]; + $binary = Binary::u8(10) . Binary::u32($timestamp) . Address::toBytes($address) + . $coin . Binary::u32($nftSeries) . Binary::u64($value) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function vanity( + string $address, + string $vanityAddress, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $unsigned = [ + 'txtype' => 12, 'timestamp' => $timestamp, 'address' => $address, + 'vanity_address' => $vanityAddress, 'txfee' => $fee, + ]; + $binary = Binary::u8(12) . Binary::u32($timestamp) . Address::toBytes($address) + . Address::vanityToBytes($vanityAddress) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } +} diff --git a/src/Transaction/DualSignedTransaction.php b/src/Transaction/DualSignedTransaction.php new file mode 100644 index 0000000..fb4d36f --- /dev/null +++ b/src/Transaction/DualSignedTransaction.php @@ -0,0 +1,119 @@ +skein256($this->unsignedJson); + if (!hash_equals($this->hash, $recalculatedHash)) { + throw new ProtocolException( + 'The exported transaction hash does not match its unsigned transaction.', + ); + } + $signature = $crypto->sign($this->hash, $credentials->privateKey, $credentials->publicKey); + if ($this->signature1 === '') { + return new self( + $this->unsignedJson, + $this->unsignedBytes, + $this->hash, + $signature, + '', + $this->storesHash, + ); + } + if ($this->signature2 === '') { + return new self( + $this->unsignedJson, + $this->unsignedBytes, + $this->hash, + $this->signature1, + $signature, + $this->storesHash, + ); + } + throw new ProtocolException('Both transaction signatures are already present.'); + } + + public function isComplete(): bool + { + return strlen($this->signature1) === 666 && strlen($this->signature2) === 666; + } + + public function toSignedTransaction(): SignedTransaction + { + if (!$this->isComplete()) { + throw new ProtocolException('Both signatures are required before broadcasting.'); + } + $bytes = $this->unsignedBytes + . ($this->storesHash ? $this->hash : '') + . $this->signature1 + . $this->signature2; + return new SignedTransaction($bytes, $this->signature1 . $this->signature2, $this->unsignedJson); + } + + public function export(): array + { + return [ + 'unsigned_json' => $this->unsignedJson, + 'unsigned_bytes' => bin2hex($this->unsignedBytes), + 'hash' => bin2hex($this->hash), + 'signature1' => bin2hex($this->signature1), + 'signature2' => bin2hex($this->signature2), + 'stores_hash' => $this->storesHash, + ]; + } + + public static function import(array $data): self + { + foreach (['unsigned_json', 'unsigned_bytes', 'hash', 'signature1', 'signature2'] as $field) { + if (!isset($data[$field]) || !is_string($data[$field])) { + throw new ProtocolException("Missing dual-signature field: $field."); + } + } + $transaction = new self( + $data['unsigned_json'], + self::decode($data['unsigned_bytes'], 'unsigned bytes'), + self::decode($data['hash'], 'transaction hash'), + self::decode($data['signature1'], 'signature1'), + self::decode($data['signature2'], 'signature2'), + (bool) ($data['stores_hash'] ?? false), + ); + if (strlen($transaction->hash) !== 32 + || !in_array(strlen($transaction->signature1), [0, 666], true) + || !in_array(strlen($transaction->signature2), [0, 666], true) + ) { + throw new ProtocolException('Imported dual-signature field has an invalid byte length.'); + } + return $transaction; + } + + private static function decode(string $hex, string $field): string + { + if ((strlen($hex) % 2) !== 0 || ($hex !== '' && !ctype_xdigit($hex))) { + throw new ProtocolException("Invalid hexadecimal $field."); + } + $decoded = hex2bin($hex); + if ($decoded === false) { + throw new ProtocolException("Invalid hexadecimal $field."); + } + return $decoded; + } +} diff --git a/src/Transaction/GovernanceBuilder.php b/src/Transaction/GovernanceBuilder.php new file mode 100644 index 0000000..12d3813 --- /dev/null +++ b/src/Transaction/GovernanceBuilder.php @@ -0,0 +1,87 @@ +crypto = $crypto; + $this->credentials = $credentials; + } + + public function proposal( + string $proposalHash, + string $address, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $unsigned = [ + 'txtype' => 200, 'time' => $timestamp, 'proposal_hash' => $proposalHash, + 'address' => $address, 'txfee' => $fee, + ]; + $binary = Binary::u8(200) . Binary::u32($timestamp) + . RequestBuilder::hash($proposalHash, 'Proposal document hash') + . Address::toBytes($address) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function proposalVote( + string $proposalKey, + string $address, + bool $approve, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $unsigned = [ + 'txtype' => 201, 'time' => $timestamp, 'proposal_key' => $proposalKey, + 'address' => $address, 'vote' => $approve ? 1 : 0, 'txfee' => $fee, + ]; + $binary = Binary::u8(201) . Binary::u32($timestamp) + . RequestBuilder::hash($proposalKey, 'Proposal key') + . Address::toBytes($address) . Binary::u8($approve ? 1 : 0) + . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function activationVote( + string $proposalKey, + string $developmentHash, + string $developmentLocation, + string $address, + bool $approve, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $developmentLocation = Binary::fixedText( + $developmentLocation, + 100, + 'Development location', + ); + $unsigned = [ + 'txtype' => 202, 'time' => $timestamp, 'proposal_key' => $proposalKey, + 'development_hash' => $developmentHash, + 'development_location' => $developmentLocation, + 'address' => $address, 'vote' => $approve ? 1 : 0, 'txfee' => $fee, + ]; + $binary = Binary::u8(202) . Binary::u32($timestamp) + . RequestBuilder::hash($proposalKey, 'Proposal key') + . RequestBuilder::hash($developmentHash, 'Development hash') + . $developmentLocation . Address::toBytes($address) + . Binary::u8($approve ? 1 : 0) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } +} diff --git a/src/Transaction/MiscellaneousBuilder.php b/src/Transaction/MiscellaneousBuilder.php new file mode 100644 index 0000000..584fc54 --- /dev/null +++ b/src/Transaction/MiscellaneousBuilder.php @@ -0,0 +1,102 @@ +crypto = $crypto; + $this->credentials = $credentials; + } + + public function marketing( + int $campaign, + string $adType, + string $keyword, + string $displayed, + int $impressions, + int $clicks, + int $impressionValue, + int $clickValue, + string $advertiser, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $adType = Binary::fixedText($adType, 6, 'Ad type'); + $keyword = Binary::fixedText($keyword, 40, 'Marketing keyword'); + $displayed = Binary::fixedText($displayed, 100, 'Display location'); + $unsigned = [ + 'txtype' => 5, 'time' => $timestamp, 'campaign' => $campaign, + 'ad_type' => $adType, 'keyword' => $keyword, 'displayed' => $displayed, + 'impression' => $impressions, 'click' => $clicks, + 'impression_value' => $impressionValue, 'click_value' => $clickValue, + 'advertiser' => $advertiser, 'txfee' => $fee, + ]; + $binary = Binary::u8(5) . Binary::u32($timestamp) . Binary::u64($campaign) + . $adType . $keyword . $displayed . Binary::u8($impressions) + . Binary::u8($clicks) . Binary::u16($impressionValue) + . Binary::u16($clickValue) . Address::toBytes($advertiser) + . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function collateralClaim( + string $contractHash, + string $address, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $unsigned = [ + 'txtype' => 9, 'time' => $timestamp, 'contract_hash' => $contractHash, + 'address' => $address, 'txfee' => $fee, + ]; + $binary = Binary::u8(9) . Binary::u32($timestamp) + . RequestBuilder::hash($contractHash, 'Loan contract hash') + . Address::toBytes($address) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function loanPayment( + int $amount, + string $contractHash, + string $address, + int $tip, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $unsigned = [ + 'txtype' => 8, 'timestamp' => $timestamp, 'payback_amount' => $amount, + 'contract_hash' => $contractHash, 'address' => $address, + 'tip' => $tip, 'txfee' => $fee, + ]; + $json = json_encode( + $unsigned, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ); + $hash = $this->crypto->skein256($json); + $signature = $this->crypto->sign( + $hash, + $this->credentials->privateKey, + $this->credentials->publicKey, + ); + $binary = Binary::u8(8) . Binary::u32($timestamp) . Binary::u64($amount) + . RequestBuilder::hash($contractHash, 'Loan contract hash') + . Address::toBytes($address) . Binary::u64($tip) . Binary::u64($fee) + . $hash . $signature; + return new SignedTransaction($binary, $signature, $json); + } +} diff --git a/src/Transaction/SignedTransaction.php b/src/Transaction/SignedTransaction.php new file mode 100644 index 0000000..4e287a7 --- /dev/null +++ b/src/Transaction/SignedTransaction.php @@ -0,0 +1,20 @@ +bytes); + } +} diff --git a/src/Transaction/SignsTransactions.php b/src/Transaction/SignsTransactions.php new file mode 100644 index 0000000..45983ac --- /dev/null +++ b/src/Transaction/SignsTransactions.php @@ -0,0 +1,29 @@ +crypto->skein256($json); + $signature = $this->crypto->sign( + $digest, + $this->credentials->privateKey, + $this->credentials->publicKey, + ); + return new SignedTransaction($binary . $signature, $signature, $json); + } +} diff --git a/src/Transaction/StorageBuilder.php b/src/Transaction/StorageBuilder.php new file mode 100644 index 0000000..5e596e6 --- /dev/null +++ b/src/Transaction/StorageBuilder.php @@ -0,0 +1,219 @@ +crypto = $crypto; + $this->credentials = $credentials; + } + + public function createKey( + string $address, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $unsigned = [ + 'txtype' => 100, 'time' => $timestamp, 'address' => $address, 'txfee' => $fee, + ]; + return $this->sign( + $unsigned, + Binary::u8(100) . Binary::u32($timestamp) + . Address::toBytes($address) . Binary::u64($fee), + ); + } + + public function boolean( + string $storageKey, + string $key, + bool $value, + string $address, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + return $this->value(101, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function u8(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(102, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function u16(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(103, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function u32(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(104, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function u64(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(105, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function i8(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(108, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function i16(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(109, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function i32(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(110, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function i64(string $storageKey, string $key, int $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value(111, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function u128(string $storageKey, string $key, string $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value128(106, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function i128(string $storageKey, string $key, string $value, string $address, int $fee, ?int $timestamp = null): SignedTransaction + { + return $this->value128(112, $storageKey, $key, $value, $address, $fee, $timestamp); + } + + public function string( + string $storageKey, + string $key, + string $value, + string $address, + string $previousHash, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $key = Binary::fixedText($key, 50, 'Storage data key'); + $value = Binary::fixedText($value, 180, 'Storage string value'); + $unsigned = [ + 'txtype' => 107, 'time' => $timestamp, 'storagekey' => $storageKey, + 'key' => $key, 'value' => $value, 'address' => $address, + 'previous' => $previousHash, 'txfee' => $fee, + ]; + $binary = Binary::u8(107) . Binary::u32($timestamp) + . RequestBuilder::hash($storageKey, 'Storage key') . $key . $value + . Address::toBytes($address) + . RequestBuilder::hash($previousHash, 'Previous string transaction hash') + . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + public function delete( + string $storageKey, + string $key, + string $address, + int $fee, + ?int $timestamp = null, + ): SignedTransaction { + $timestamp ??= time(); + $key = Binary::fixedText($key, 50, 'Storage data key'); + $unsigned = [ + 'txtype' => 113, 'time' => $timestamp, 'storagekey' => $storageKey, + 'key' => $key, 'address' => $address, 'txfee' => $fee, + ]; + $binary = Binary::u8(113) . Binary::u32($timestamp) + . RequestBuilder::hash($storageKey, 'Storage key') . $key + . Address::toBytes($address) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + private function value( + int $type, + string $storageKey, + string $key, + bool|int $value, + string $address, + int $fee, + ?int $timestamp, + ): SignedTransaction { + $timestamp ??= time(); + $key = Binary::fixedText($key, 50, 'Storage data key'); + $valueBytes = match ($type) { + 101 => Binary::u8($value === true ? 1 : 0), + 102 => Binary::u8($this->integer($value)), + 103 => Binary::u16($this->integer($value)), + 104 => Binary::u32($this->integer($value)), + 105 => Binary::u64($this->integer($value)), + 108 => Binary::i8($this->integer($value)), + 109 => Binary::i16($this->integer($value)), + 110 => Binary::i32($this->integer($value)), + 111 => Binary::i64($this->integer($value)), + default => throw new ProtocolException("Unsupported storage value type $type."), + }; + $unsigned = [ + 'txtype' => $type, 'time' => $timestamp, 'storagekey' => $storageKey, + 'key' => $key, 'value' => $value, 'address' => $address, 'txfee' => $fee, + ]; + $binary = Binary::u8($type) . Binary::u32($timestamp) + . RequestBuilder::hash($storageKey, 'Storage key') . $key . $valueBytes + . Address::toBytes($address) . Binary::u64($fee); + return $this->sign($unsigned, $binary); + } + + private function integer(bool|int $value): int + { + if (!is_int($value)) { + throw new ProtocolException('Numeric storage values must be integers.'); + } + return $value; + } + + private function value128( + int $type, + string $storageKey, + string $key, + string $value, + string $address, + int $fee, + ?int $timestamp, + ): SignedTransaction { + $timestamp ??= time(); + $key = Binary::fixedText($key, 50, 'Storage data key'); + $value = trim($value); + $valueBytes = $type === 106 ? Binary::u128($value) : Binary::i128($value); + + // serde_json writes Rust u128/i128 values as unquoted decimal numbers. + $json = '{"txtype":' . $type + . ',"time":' . $timestamp + . ',"storagekey":' . json_encode($storageKey, JSON_THROW_ON_ERROR) + . ',"key":' . json_encode($key, JSON_THROW_ON_ERROR) + . ',"value":' . $value + . ',"address":' . json_encode($address, JSON_THROW_ON_ERROR) + . ',"txfee":' . $fee . '}'; + $digest = $this->crypto->skein256($json); + $signature = $this->crypto->sign( + $digest, + $this->credentials->privateKey, + $this->credentials->publicKey, + ); + $binary = Binary::u8($type) . Binary::u32($timestamp) + . RequestBuilder::hash($storageKey, 'Storage key') . $key . $valueBytes + . Address::toBytes($address) . Binary::u64($fee); + return new SignedTransaction($binary . $signature, $signature, $json); + } +} diff --git a/src/Transaction/TransferBuilder.php b/src/Transaction/TransferBuilder.php new file mode 100644 index 0000000..f12d2a5 --- /dev/null +++ b/src/Transaction/TransferBuilder.php @@ -0,0 +1,63 @@ + 2, + 'time' => $timestamp, + 'value' => $value, + 'coin' => $coinBytes, + 'nft_series' => $nftSeries, + 'sender' => $sender, + 'receiver' => $receiver, + 'txfee' => $fee, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + $digest = $this->crypto->skein256($unsigned); + $signature = $this->crypto->sign( + $digest, + $this->credentials->privateKey, + $this->credentials->publicKey, + ); + + $bytes = Binary::u8(2) + . Binary::u32($timestamp) + . Binary::u64($value) + . $coinBytes + . Binary::u32($nftSeries) + . Address::toBytes($sender) + . Address::toBytes($receiver) + . Binary::u64($fee) + . $signature; + + return new SignedTransaction($bytes, $signature, $unsigned); + } +} diff --git a/src/Transport/StreamTransport.php b/src/Transport/StreamTransport.php new file mode 100644 index 0000000..4833b0e --- /dev/null +++ b/src/Transport/StreamTransport.php @@ -0,0 +1,114 @@ +host}:{$this->port}", + $errorCode, + $errorMessage, + $this->timeout, + STREAM_CLIENT_CONNECT, + ); + if ($stream === false) { + throw new TransportException("Unable to connect: $errorMessage ($errorCode)"); + } + + try { + stream_set_timeout( + $stream, + (int) $this->timeout, + (int) (($this->timeout - floor($this->timeout)) * 1_000_000), + ); + $this->writeAll($stream, $handshake); + $response = $this->readExact($stream, self::HANDSHAKE_RESPONSE_BYTES, true); + if (strlen($response) !== self::HANDSHAKE_RESPONSE_BYTES) { + $text = trim($response, "\0\r\n\t "); + throw new TransportException($text !== '' ? $text : 'Incomplete handshake response.'); + } + $validateHandshake($response); + + $this->writeAll($stream, $request); + $header = $this->readExact($stream, 8); + if (ord($header[0]) !== Command::REPLY) { + throw new ProtocolException('RPC response did not begin with the reply command.'); + } + if (substr($header, 1, 3) !== $uid) { + throw new ProtocolException('RPC response UID did not match the request UID.'); + } + + $length = Binary::readU32(substr($header, 4, 4)); + if ($length > self::MAX_REPLY_BYTES) { + throw new ProtocolException("RPC reply exceeds the 64 MiB protocol limit."); + } + return $this->readExact($stream, $length); + } finally { + fclose($stream); + } + } + + /** @param resource $stream */ + private function writeAll($stream, string $bytes): void + { + $offset = 0; + while ($offset < strlen($bytes)) { + $written = fwrite($stream, substr($bytes, $offset)); + if ($written === false || $written === 0) { + throw new TransportException('The RPC stream closed while writing.'); + } + $offset += $written; + } + fflush($stream); + } + + /** @param resource $stream */ + private function readExact($stream, int $length, bool $allowEarlyEof = false): string + { + $bytes = ''; + while (strlen($bytes) < $length) { + $chunk = fread($stream, $length - strlen($bytes)); + if ($chunk === false) { + throw new TransportException('Failed to read from the RPC stream.'); + } + if ($chunk === '') { + $metadata = stream_get_meta_data($stream); + if (($metadata['timed_out'] ?? false) === true) { + throw new TransportException('Timed out waiting for RPC data.'); + } + if (feof($stream) && $allowEarlyEof) { + break; + } + throw new TransportException('The RPC stream closed before the response completed.'); + } + $bytes .= $chunk; + } + return $bytes; + } +} diff --git a/src/Transport/TransportInterface.php b/src/Transport/TransportInterface.php new file mode 100644 index 0000000..55d0102 --- /dev/null +++ b/src/Transport/TransportInterface.php @@ -0,0 +1,17 @@ +handshake = $handshake; + $this->request = $request; + return 'ok'; + } +}; + +$crypto = new CallbackCrypto( + fn (string $message): string => str_repeat("\x11", 32), + fn (string $message, string $privateKey, string $publicKey): string => str_repeat("\x22", 666), + fn (string $message, string $signature, string $publicKey): bool => true, +); +$credentials = new Credentials(str_repeat("\x33", 897), str_repeat("\x44", 1281)); +$client = new Client($transport, $crypto, $credentials); + +expect($client->blockByHeight(42) === 'ok', 'Client did not return the RPC payload.'); +expect(strlen($transport->handshake) === 1587, 'Handshake request has the wrong size.'); +expect(substr($transport->handshake, 0, 2) === "\xac\xed", 'Handshake marker is wrong.'); +expect(ord($transport->request[0]) === Command::BLOCK_BY_HEIGHT, 'RPC command is wrong.'); +expect(unpack('Vheight', substr($transport->request, 4, 4))['height'] === 42, 'Height encoding is wrong.'); + +$client->storageLookupCost(str_repeat('01', 32), 'all', str_repeat('ab', 20) . '.cltc'); +expect(ord($transport->request[0]) === Command::STORAGE_LOOKUP_COST, 'Storage command is wrong.'); +expect(strlen($transport->request) === 4 + 32 + 50 + 22, 'Storage cost payload has the wrong size.'); + +$client->registerOwnedWallet(str_repeat('ab', 20) . '.cltc'); +expect(ord($transport->request[0]) === Command::REGISTER_WALLET, 'Wallet registration command is wrong.'); +expect(strlen($transport->request) === 4 + 22 + 897 + 666, 'Wallet registration payload has the wrong size.'); + +$transfer = (new TransferBuilder($crypto, $credentials))->create( + str_repeat('ab', 20) . '.cltc', + str_repeat('cd', 20) . '.cltc', + 'CLTC', + 2_500_000, + 2_500, + timestamp: 1_700_000_000, +); +expect(strlen($transfer->bytes) === 750, 'Transfer transaction has the wrong byte size.'); +expect( + $transfer->unsignedJson + === '{"txtype":2,"time":1700000000,"value":2500000,"coin":"CLTC ","nft_series":0,"sender":"' + . str_repeat('ab', 20) + . '.cltc","receiver":"' + . str_repeat('cd', 20) + . '.cltc","txfee":2500}', + 'Transfer signing JSON does not match Rust struct serialization.', +); + +$address = str_repeat('ab', 20) . '.cltc'; +$assets = new AssetBuilder($crypto, $credentials); +expect(strlen($assets->createToken($address, 'TEST', 1000, true, 100, 1)->bytes) === 725, 'Token size is wrong.'); +expect(strlen($assets->issueToken($address, 'TEST', 1000, 100, 1)->bytes) === 724, 'Token issue size is wrong.'); +expect(strlen($assets->createNft($address, false, false, 'ART', 'cid', 1, 'description', 100, 1)->bytes) === 922, 'NFT size is wrong.'); +expect(strlen($assets->burn($address, 'TEST', 0, 1, 100, 1)->bytes) === 728, 'Burn size is wrong.'); +expect(strlen($assets->vanity($address, str_repeat('a', 20) . '.cltc', 100, 1)->bytes) === 723, 'Vanity size is wrong.'); + +$governance = new GovernanceBuilder($crypto, $credentials); +expect(strlen($governance->proposal(str_repeat('01', 32), $address, 100, 1)->bytes) === 733, 'Proposal size is wrong.'); +expect(strlen($governance->proposalVote(str_repeat('01', 32), $address, true, 100, 1)->bytes) === 734, 'Proposal vote size is wrong.'); +expect(strlen($governance->activationVote(str_repeat('01', 32), str_repeat('02', 32), 'CLP-0001.md', $address, true, 100, 1)->bytes) === 866, 'Activation vote size is wrong.'); + +$storage = new StorageBuilder($crypto, $credentials); +expect(strlen($storage->createKey($address, 100, 1)->bytes) === 701, 'Storage key size is wrong.'); +expect(strlen($storage->boolean(str_repeat('01', 32), 'active', true, $address, 100, 1)->bytes) === 784, 'Storage bool size is wrong.'); +expect(strlen($storage->u32(str_repeat('01', 32), 'count', 42, $address, 100, 1)->bytes) === 787, 'Storage u32 size is wrong.'); +expect(strlen($storage->i64(str_repeat('01', 32), 'balance', -42, $address, 100, 1)->bytes) === 791, 'Storage i64 size is wrong.'); +expect(strlen($storage->u128(str_repeat('01', 32), 'large', '340282366920938463463374607431768211455', $address, 100, 1)->bytes) === 799, 'Storage u128 size is wrong.'); +expect(strlen($storage->i128(str_repeat('01', 32), 'negative', '-170141183460469231731687303715884105728', $address, 100, 1)->bytes) === 799, 'Storage i128 size is wrong.'); +expect(bin2hex(substr($storage->u128(str_repeat('01', 32), 'large', '340282366920938463463374607431768211455', $address, 100, 1)->bytes, 87, 16)) === str_repeat('ff', 16), 'u128 maximum encoding is wrong.'); +expect(strlen($storage->string(str_repeat('01', 32), 'bio', 'hello', $address, str_repeat('0', 64), 100, 1)->bytes) === 995, 'Storage string size is wrong.'); +expect(strlen($storage->delete(str_repeat('01', 32), 'bio', $address, 100, 1)->bytes) === 783, 'Storage delete size is wrong.'); + +$misc = new MiscellaneousBuilder($crypto, $credentials); +expect(strlen($misc->marketing(1, 'banner', 'keyword', 'website', 1, 1, 100, 100, $address, 100, 1)->bytes) === 861, 'Marketing size is wrong.'); +expect(strlen($misc->collateralClaim(str_repeat('01', 32), $address, 100, 1)->bytes) === 733, 'Collateral claim size is wrong.'); +expect(strlen($misc->loanPayment(1000, str_repeat('01', 32), $address, 100, 100, 1)->bytes) === 781, 'Loan payment size is wrong.'); + +$agreements = new AgreementBuilder($crypto, $credentials); +$swap = $agreements->swap(1000, 'CLTC', 0, 100, 'TEST', 0, 200, $address, str_repeat('cd', 20) . '.cltc', 1, 2, 3, 4, 1); +$swap = $swap->signNext($crypto, $credentials); +expect(strlen($swap->toSignedTransaction()->bytes) === 1471, 'Swap size is wrong.'); +expect(DualSignedTransaction::import($swap->export())->isComplete(), 'Swap export/import lost signatures.'); + +$loan = $agreements->loan('CLTC', 1000, $address, 'TEST', 2000, str_repeat('cd', 20) . '.cltc', 'm', 12, 100, 2, 300, 100, 1); +$loan = $loan->signNext($crypto, $credentials); +expect(strlen($loan->toSignedTransaction()->bytes) === 1492, 'Loan size is wrong.'); + +echo "All protocol tests passed.\n";