2026-07-25 21:23:28 +00:00
< ? 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 ;
2026-07-27 16:37:30 +00:00
use Contractless\Rpc\Exception\ProtocolException ;
use Contractless\Rpc\Exception\TransportException ;
2026-07-25 21:23:28 +00:00
use Contractless\Rpc\Protocol\Address ;
use Contractless\Rpc\Protocol\Command ;
2026-07-27 16:37:30 +00:00
use Contractless\Rpc\Transport\EndpointPoolTransport ;
2026-07-25 21:23:28 +00:00
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 );
}
}
2026-07-27 16:37:30 +00:00
final class TestTransport implements TransportInterface
{
public int $calls = 0 ;
public function __construct (
private readonly ? string $reply = null ,
private readonly ? Throwable $failure = null ,
) {
}
public function exchange (
string $handshake ,
string $request ,
string $uid ,
Closure $validateHandshake ,
) : string {
$this -> calls ++ ;
if ( $this -> failure !== null ) {
throw $this -> failure ;
}
return $this -> reply ? ? '' ;
}
}
2026-07-25 21:23:28 +00:00
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.' );
2026-07-27 16:37:30 +00:00
$failedEndpoint = new TestTransport (
failure : new TransportException ( 'Connection refused.' ),
);
$workingEndpoint = new TestTransport ( reply : 'pool reply' );
$endpointPool = new EndpointPoolTransport (
[
'offline-node' => $failedEndpoint ,
'working-node' => $workingEndpoint ,
],
randomize : false ,
);
$pooledClient = new Client ( $endpointPool , $crypto , $credentials );
expect (
$pooledClient -> blockHeight () === 'pool reply' ,
'Endpoint pool did not return the successful fallback reply.' ,
);
expect ( $failedEndpoint -> calls === 1 , 'Endpoint pool did not try the first endpoint.' );
expect ( $workingEndpoint -> calls === 1 , 'Endpoint pool did not try the fallback endpoint.' );
expect (
$endpointPool -> lastSuccessfulEndpointId () === 'working-node' ,
'Endpoint pool did not record the endpoint that answered.' ,
);
expect (
$endpointPool -> endpointIds () === [ 'offline-node' , 'working-node' ],
'Endpoint pool did not preserve its endpoint identifiers.' ,
);
expect (
$endpointPool -> transportFor ( 'working-node' ) === $workingEndpoint ,
'Endpoint pool could not return a specifically requested transport.' ,
);
$protocolFailure = new TestTransport (
failure : new ProtocolException ( 'Invalid RPC reply.' ),
);
$unusedEndpoint = new TestTransport ( reply : 'must not run' );
$protocolPool = new EndpointPoolTransport (
[
'invalid-node' => $protocolFailure ,
'unused-node' => $unusedEndpoint ,
],
randomize : false ,
);
$protocolClient = new Client ( $protocolPool , $crypto , $credentials );
$protocolRejected = false ;
try {
$protocolClient -> blockHeight ();
} catch ( ProtocolException ) {
$protocolRejected = true ;
}
expect ( $protocolRejected , 'Endpoint pool swallowed a protocol error.' );
expect (
$unusedEndpoint -> calls === 0 ,
'Endpoint pool retried a request after a protocol error.' ,
);
$allFailedPool = new EndpointPoolTransport (
[
'offline-one' => new TestTransport (
failure : new TransportException ( 'First endpoint failed.' ),
),
'offline-two' => new TestTransport (
failure : new TransportException ( 'Second endpoint failed.' ),
),
],
randomize : false ,
);
$allFailedClient = new Client ( $allFailedPool , $crypto , $credentials );
$allFailedMessage = '' ;
try {
$allFailedClient -> blockHeight ();
} catch ( TransportException $error ) {
$allFailedMessage = $error -> getMessage ();
}
expect (
str_contains ( $allFailedMessage , 'offline-one' )
&& str_contains ( $allFailedMessage , 'offline-two' ),
'Endpoint pool failure did not identify every failed endpoint.' ,
);
$emptyPoolRejected = false ;
try {
new EndpointPoolTransport ([]);
} catch ( InvalidArgumentException ) {
$emptyPoolRejected = true ;
}
expect ( $emptyPoolRejected , 'Endpoint pool accepted an empty endpoint list.' );
2026-07-25 21:23:28 +00:00
echo " All protocol tests passed. \n " ;