Initial Contractless PHP RPC lib

This commit is contained in:
viraladmin 2026-07-25 15:23:28 -06:00
commit 6bd3a853b3
27 changed files with 2180 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/vendor/

170
README.md Normal file
View File

@ -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
<?php
require __DIR__ . '/vendor/autoload.php';
use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Transport\StreamTransport;
use Contractless\Rpc\Wallet\Credentials;
$credentials = Credentials::fromHex(
getenv('CONTRACTLESS_PUBLIC_KEY'),
getenv('CONTRACTLESS_PRIVATE_KEY'),
);
$crypto = new NativeCrypto();
$client = new Client(
new StreamTransport('127.0.0.1', 50050),
$crypto,
$credentials,
);
echo $client->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.

25
composer.json Normal file
View File

@ -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"
}
}

323
src/Client.php Normal file
View File

@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Exception\ProtocolException;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Protocol\Command;
use Contractless\Rpc\Protocol\RequestBuilder;
use Contractless\Rpc\Transport\TransportInterface;
use Contractless\Rpc\Wallet\Credentials;
final class Client
{
private const CLIENT_MARKER = "\xac\xed";
private const SERVER_MARKER = "\xec\xaf";
private const PUBLIC_COMMANDS = [
1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
22, 23, 24, 25, 31, 32, 33, 35, 36, 37, 38, 40, 42, 43, 44, 45, 46,
47, 48, 49, 52, 53, 57,
];
public function __construct(
private readonly TransportInterface $transport,
private readonly CryptoInterface $crypto,
private readonly Credentials $credentials,
) {
}
public function call(int $command, string $payload = ''): string
{
if (!in_array($command, self::PUBLIC_COMMANDS, true)) {
throw new ProtocolException(
"RPC command $command is internal, administrative, or unsupported.",
);
}
$uid = random_bytes(3);
$handshake = $this->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.');
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Crypto;
use Closure;
use Contractless\Rpc\Exception\ProtocolException;
final class CallbackCrypto implements CryptoInterface
{
public function __construct(
private readonly Closure $skein,
private readonly Closure $signer,
private readonly Closure $verifier,
) {
}
public function skein256(string $message): string
{
$digest = ($this->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);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Crypto;
interface CryptoInterface
{
/** Return the raw 32-byte Skein-256 digest. */
public function skein256(string $message): string;
/** Sign raw Contractless message bytes and return a 666-byte signature. */
public function sign(string $message, string $privateKey, string $publicKey): string;
/** Verify raw Contractless message bytes against a 666-byte signature. */
public function verify(
string $message,
string $signature,
string $publicKey,
): bool;
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Crypto;
use Contractless\Rpc\Exception\ContractlessException;
use Contractless\Rpc\Protocol\Binary;
final class NativeCrypto implements CryptoInterface
{
private const ALGORITHM = 'Falcon-padded-512';
/** @var \OQS_SIGNATURE */
private readonly object $falcon;
public function __construct()
{
if (!function_exists('skein_hash')) {
throw new ContractlessException('The PHP Skein extension is not loaded.');
}
if (!function_exists('contractless_shake256')) {
throw new ContractlessException(
'The Contractless liboqs-php SHAKE256 helper is not loaded.',
);
}
if (!class_exists('OQS_SIGNATURE')) {
throw new ContractlessException('The liboqs-php extension is not loaded.');
}
$this->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;
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Exception;
use RuntimeException;
class ContractlessException extends RuntimeException
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Exception;
final class ProtocolException extends ContractlessException
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Exception;
final class TransportException extends ContractlessException
{
}

49
src/Protocol/Address.php Normal file
View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Protocol;
use Contractless\Rpc\Exception\ProtocolException;
final class Address
{
private const NETWORK_BYTES = [
'clc' => 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);
}
}

170
src/Protocol/Binary.php Normal file
View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Protocol;
use Contractless\Rpc\Exception\ProtocolException;
final class Binary
{
public static function u8(int $value): string
{
self::range($value, 0, 0xff, 'u8');
return pack('C', $value);
}
public static function u16(int $value): string
{
self::range($value, 0, 0xffff, 'u16');
return pack('v', $value);
}
public static function u32(int $value): string
{
self::range($value, 0, 0xffffffff, 'u32');
return pack('V', $value);
}
public static function u64(int $value): string
{
if (PHP_INT_SIZE < 8 || $value < 0) {
throw new ProtocolException('u64 values require 64-bit PHP and a non-negative integer.');
}
return pack('V2', $value & 0xffffffff, $value >> 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];
}
}

57
src/Protocol/Command.php Normal file
View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Protocol;
final class Command
{
public const NETWORK_INFO = 1;
public const BLOCK_HEIGHT = 2;
public const TIME = 4;
public const DIFFICULTY = 5;
public const VALIDATE_TORRENT = 6;
public const BLOCK_PIECE = 7;
public const SUBMIT_TRANSACTION = 8;
public const BLOCK_BY_HEIGHT = 9;
public const BLOCK_BY_HASH = 10;
public const LATEST_BLOCK = 11;
public const TORRENT_BY_HEIGHT = 12;
public const LARGEST_TX_FEE = 13;
public const MEMPOOL_TX_BY_SIGNATURE = 14;
public const MEMPOOL_TX_COUNT = 15;
public const MEMPOOL_TX_BY_ADDRESS = 16;
public const TRANSACTION_BY_TXID = 17;
public const TOTAL_CONFIRMED_TX = 18;
public const HEADER_BY_HEIGHT = 19;
public const HEADER_BY_HASH = 20;
public const ALL_HEADERS = 21;
public const ADDRESS_COIN_BALANCE = 22;
public const ADDRESS_TOTAL_BALANCE = 23;
public const VALIDATE_ADDRESS = 24;
public const VALIDATE_MESSAGE = 25;
public const TOKEN_LIST = 31;
public const NFT_LIST = 32;
public const LOAN_CONTRACT = 33;
public const TOKEN_DETAILS = 35;
public const NFT_DETAILS = 36;
public const CONTRACT_BY_ADDRESS = 37;
public const REGISTER_WALLET = 38;
public const VANITY_LOOKUP = 40;
public const BLOCK_HASH_AT_HEIGHT = 42;
public const WALLET_REGISTRATION_STATUS = 43;
public const ADDRESS_HISTORY = 44;
public const VANITY_OWNER_LOOKUP = 45;
public const LATEST_ADDRESS_TRANSACTIONS = 46;
public const MARKETING_CAMPAIGN_HISTORY = 47;
public const TOKEN_CATALOG = 48;
public const COLLATERAL_STATUS = 49;
public const STORAGE_LOOKUP_COST = 52;
public const STORAGE_LOOKUP = 53;
public const GOVERNANCE_PROPOSAL = 57;
public const REPLY = 255;
private function __construct()
{
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Protocol;
use Contractless\Rpc\Exception\ProtocolException;
final class RequestBuilder
{
public static function request(int $command, string $uid, string $payload = ''): string
{
Binary::length($uid, 3, 'RPC UID');
return Binary::u8($command) . $uid . $payload;
}
public static function hash(string $hex, string $field = 'Hash'): string
{
return Binary::hex($hex, 32, $field);
}
public static function signature(string $hex): string
{
return Binary::hex($hex, 666, 'Signature');
}
public static function storageSelector(string $key): string
{
$key = trim($key);
if ($key === '') {
throw new ProtocolException('The storage data key cannot be empty.');
}
return Binary::fixedText(strcasecmp($key, 'all') === 0 ? 'all' : $key, 50, 'Storage data key');
}
public static function transaction(string $hex): string
{
if ($hex === '' || (strlen($hex) % 2) !== 0 || !ctype_xdigit($hex)) {
throw new ProtocolException('Transaction must be non-empty hexadecimal data.');
}
$bytes = hex2bin($hex);
if ($bytes === false) {
throw new ProtocolException('Transaction is not valid hexadecimal data.');
}
return $bytes;
}
}

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Exception\ProtocolException;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Wallet\Credentials;
final class AgreementBuilder
{
public function __construct(
private readonly CryptoInterface $crypto,
private readonly Credentials $credentials,
) {
}
public function swap(
int $expiration,
string $ticker1,
int $series1,
int $value1,
string $ticker2,
int $series2,
int $value2,
string $sender1,
string $sender2,
int $tip1,
int $tip2,
int $fee1,
int $fee2,
?int $timestamp = null,
): DualSignedTransaction {
$timestamp ??= time();
$ticker1 = Binary::fixedText($ticker1, 15, 'First swap asset');
$ticker2 = Binary::fixedText($ticker2, 15, 'Second swap asset');
$unsigned = [
'txtype' => 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,
);
}
}

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Wallet\Credentials;
final class AssetBuilder
{
use SignsTransactions;
public function __construct(CryptoInterface $crypto, Credentials $credentials)
{
$this->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);
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Exception\ProtocolException;
use Contractless\Rpc\Wallet\Credentials;
final class DualSignedTransaction
{
public function __construct(
public readonly string $unsignedJson,
public readonly string $unsignedBytes,
public readonly string $hash,
public readonly string $signature1 = '',
public readonly string $signature2 = '',
private readonly bool $storesHash = false,
) {
}
public function signNext(CryptoInterface $crypto, Credentials $credentials): self
{
$recalculatedHash = $crypto->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;
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Protocol\RequestBuilder;
use Contractless\Rpc\Wallet\Credentials;
final class GovernanceBuilder
{
use SignsTransactions;
public function __construct(CryptoInterface $crypto, Credentials $credentials)
{
$this->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);
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Protocol\RequestBuilder;
use Contractless\Rpc\Wallet\Credentials;
final class MiscellaneousBuilder
{
use SignsTransactions;
public function __construct(CryptoInterface $crypto, Credentials $credentials)
{
$this->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);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
final class SignedTransaction
{
public function __construct(
public readonly string $bytes,
public readonly string $signature,
public readonly string $unsignedJson,
) {
}
public function toHex(): string
{
return bin2hex($this->bytes);
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Wallet\Credentials;
trait SignsTransactions
{
private CryptoInterface $crypto;
private Credentials $credentials;
private function sign(array $unsigned, string $binary): SignedTransaction
{
$json = json_encode(
$unsigned,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
);
$digest = $this->crypto->skein256($json);
$signature = $this->crypto->sign(
$digest,
$this->credentials->privateKey,
$this->credentials->publicKey,
);
return new SignedTransaction($binary . $signature, $signature, $json);
}
}

View File

@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Exception\ProtocolException;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Protocol\RequestBuilder;
use Contractless\Rpc\Wallet\Credentials;
final class StorageBuilder
{
use SignsTransactions;
public function __construct(CryptoInterface $crypto, Credentials $credentials)
{
$this->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);
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transaction;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Wallet\Credentials;
final class TransferBuilder
{
public function __construct(
private readonly CryptoInterface $crypto,
private readonly Credentials $credentials,
) {
}
public function create(
string $sender,
string $receiver,
string $coin,
int $value,
int $fee,
int $nftSeries = 0,
?int $timestamp = null,
): SignedTransaction {
$timestamp ??= time();
$coinBytes = Binary::fixedText($coin, 15, 'Coin name');
// The field order must match Rust's UnsignedTransferTransaction exactly.
$unsigned = json_encode([
'txtype' => 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);
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transport;
use Closure;
use Contractless\Rpc\Exception\ProtocolException;
use Contractless\Rpc\Exception\TransportException;
use Contractless\Rpc\Protocol\Binary;
use Contractless\Rpc\Protocol\Command;
final class StreamTransport implements TransportInterface
{
private const HANDSHAKE_RESPONSE_BYTES = 1565;
private const MAX_REPLY_BYTES = 67_108_864;
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly float $timeout = 10.0,
) {
}
public function exchange(
string $handshake,
string $request,
string $uid,
Closure $validateHandshake,
): string {
$errorCode = 0;
$errorMessage = '';
$stream = @stream_socket_client(
"tcp://{$this->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;
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Transport;
use Closure;
interface TransportInterface
{
public function exchange(
string $handshake,
string $request,
string $uid,
Closure $validateHandshake,
): string;
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Wallet;
use Contractless\Rpc\Protocol\Binary;
final class Credentials
{
public function __construct(
public readonly string $publicKey,
public readonly string $privateKey,
) {
Binary::length($publicKey, 897, 'Falcon public key');
Binary::length($privateKey, 1281, 'Falcon private key');
}
public static function fromHex(string $publicKey, string $privateKey): self
{
return new self(
Binary::hex($publicKey, 897, 'Falcon public key'),
Binary::hex($privateKey, 1281, 'Falcon private key'),
);
}
}

141
tests/run.php Normal file
View File

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Contractless\\Rpc\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$path = dirname(__DIR__) . '/src/'
. str_replace('\\', '/', substr($class, strlen($prefix)))
. '.php';
if (is_file($path)) {
require $path;
}
});
use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\CallbackCrypto;
use Contractless\Rpc\Protocol\Address;
use Contractless\Rpc\Protocol\Command;
use Contractless\Rpc\Transport\TransportInterface;
use Contractless\Rpc\Transaction\TransferBuilder;
use Contractless\Rpc\Transaction\AssetBuilder;
use Contractless\Rpc\Transaction\GovernanceBuilder;
use Contractless\Rpc\Transaction\StorageBuilder;
use Contractless\Rpc\Transaction\MiscellaneousBuilder;
use Contractless\Rpc\Transaction\AgreementBuilder;
use Contractless\Rpc\Transaction\DualSignedTransaction;
use Contractless\Rpc\Wallet\Credentials;
function expect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
expect(
Address::toBytes(str_repeat('ab', 20) . '.cltc')
=== str_repeat("\xab", 20) . ".\x02",
'Short-address encoding differs from the Rust wallet format.',
);
$transport = new class implements TransportInterface {
public string $handshake = '';
public string $request = '';
public function exchange(
string $handshake,
string $request,
string $uid,
Closure $validateHandshake,
): string {
$this->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";