Initial Contractless PHP RPC lib
This commit is contained in:
commit
6bd3a853b3
|
|
@ -0,0 +1 @@
|
|||
/vendor/
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Rpc\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ContractlessException extends RuntimeException
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Rpc\Exception;
|
||||
|
||||
final class ProtocolException extends ContractlessException
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Rpc\Exception;
|
||||
|
||||
final class TransportException extends ContractlessException
|
||||
{
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
Loading…
Reference in New Issue