Go to file
viraladmin 2644759ce9 Remove wallet custody from PHP RPC 2026-07-27 14:24:58 -06:00
examples added encrypted image decoding for php 2026-07-25 16:38:40 -06:00
src Remove wallet custody from PHP RPC 2026-07-27 14:24:58 -06:00
tests Remove wallet custody from PHP RPC 2026-07-27 14:24:58 -06:00
.gitignore Initial Contractless PHP RPC lib 2026-07-25 15:23:28 -06:00
README.md Remove wallet custody from PHP RPC 2026-07-27 14:24:58 -06:00
composer.json Remove wallet custody from PHP RPC 2026-07-27 14:24:58 -06:00
composer.lock Remove wallet custody from PHP RPC 2026-07-27 14:24:58 -06:00

README.md

PHP Contractless RPC

contractless-php-rpc allows PHP applications to connect directly to a Contractless node through its authenticated binary RPC protocol. It does not invoke Contractless CLI programs and does not depend on centralized blockchain APIs.

The package can be used to build faucets, block explorers, wallets, application backends, storage applications, and other public Contractless integrations.

Scope

The library exposes application-safe public RPC commands, including:

  • Network and blockchain information
  • Blocks, headers, and torrents
  • Confirmed transactions and mempool records
  • Wallet balances, registration, addresses, and history
  • Tokens, NFTs, RWAs, swaps, and loans
  • Marketing records
  • On-chain data storage
  • Governance proposals
  • Transaction creation and broadcasting with application-provided signatures

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 using a 64-bit build
  • The skein module from Contractless PHP Modules
  • The oqsphp module from Contractless PHP Modules

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 when verifying node responses. The application that owns a wallet remains responsible for signing.

Install and test the Contractless PHP Modules before installing this package:

https://contractless.dev/contractless/Contractless-PHP-Modules

Installation

Install version 1.0 through Packagist:

composer require contractless/contractless-php-rpc:^1.0.0 -W

Composer installs the package under vendor/ and generates the required autoload files.

Create A Client

<?php

require __DIR__ . '/vendor/autoload.php';

use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Protocol\HandshakeProof;
use Contractless\Rpc\Transport\StreamTransport;

$handshake = HandshakeProof::fromHex(
    $applicationPublicKeyHex,
    $applicationHandshakeSignatureHex,
);

$crypto = new NativeCrypto();

$client = new Client(
    new StreamTransport(
        host: '127.0.0.1',
        port: 50050,
        timeout: 10.0,
    ),
    $crypto,
    $handshake,
);

The handshake signature is the application's 666-byte Falcon signature over the 32-byte Skein-256 digest of aced. The RPC package does not create that signature and never receives a wallet file, decryption key, or private key.

Use port 50050 for the default testnet RPC and 50055 for the default mainnet RPC unless the node operator configured another port.

Every RPC call performs a signed client handshake. The wallet used for the handshake does not have to own an address being looked up.

RPC Endpoint Pool

Contractless client RPC is intentionally one request per connection. A node accepts the client connection, performs the authenticated handshake, returns one RPC reply, and closes the connection.

EndpointPoolTransport is therefore a pool of available endpoints, not a pool of persistent sockets. It randomly selects a new endpoint for every RPC call so applications can distribute their requests across several nodes:

use Contractless\Rpc\Transport\EndpointPoolTransport;
use Contractless\Rpc\Transport\StreamTransport;

$transport = new EndpointPoolTransport([
    'local-node' => new StreamTransport(
        host: '127.0.0.1',
        port: 50050,
        timeout: 10.0,
    ),
    'public-node-1' => new StreamTransport(
        host: 'node1.example.com',
        port: 50050,
        timeout: 10.0,
    ),
    'public-node-2' => new StreamTransport(
        host: 'node2.example.com',
        port: 50050,
        timeout: 10.0,
    ),
]);

$client = new Client(
    $transport,
    $crypto,
    $handshake,
);

For every Client call, the pool:

  1. Randomizes the configured endpoint order.
  2. Opens a one-shot RPC connection to the first selected endpoint.
  3. Returns its complete reply when successful.
  4. Tries the next randomized endpoint only after a TransportException.
  5. Throws one TransportException identifying every attempted endpoint when all transports fail.

Protocol errors and node validation replies do not trigger failover. Those responses may indicate that the request itself is invalid, so sending it to additional nodes would add unnecessary traffic.

The endpoint that most recently completed a request is available through:

$endpointId = $transport->lastSuccessfulEndpointId();

Linked operations that must return to one endpoint can retrieve its configured transport explicitly:

$selectedTransport = $transport->transportFor($endpointId);
$selectedClient = new Client(
    $selectedTransport,
    $crypto,
    $handshake,
);

This is needed for operations such as paid storage lookup, where the fee quote, payment address, and final lookup belong to the node that issued the quote.

RPC Replies

RPC methods return the node's original reply as a PHP string. Depending on the command, that string may contain text, JSON, or raw binary data.

Decode JSON replies when appropriate:

$data = json_decode(
    $client->networkInfo(),
    true,
    flags: JSON_THROW_ON_ERROR,
);

Do not apply text conversion to raw blocks, headers, torrents, transactions, or binary integer replies.

A coin balance is returned as an eight-byte little-endian unsigned integer:

$reply = $client->coinBalance('CLTC', $address);
$balanceAtomic = unpack('Pbalance', $reply)['balance'];
$balance = number_format($balanceAtomic / 100_000_000, 8, '.', '');

All transaction amounts and fees supplied to builders use atomic units. Contractless has 100,000,000 atomic units per CLC or CLTC.

Network And Chain RPC

$network = $client->networkInfo();
$height = $client->blockHeight();
$nodeTime = $client->nodeTime();
$difficulty = $client->difficulty();
$latestBlock = $client->latestBlock();
$largestFee = $client->largestTransactionFee();
$confirmedCount = $client->totalConfirmedTransactions();
  • networkInfo() returns the node's public network information.
  • blockHeight() returns the current saved chain height.
  • nodeTime() returns the node's current time.
  • difficulty() returns the current mining difficulty.
  • latestBlock() returns the latest saved block.
  • largestTransactionFee() returns the largest eligible mempool fee.
  • totalConfirmedTransactions() returns the confirmed transaction count.

Block, Header, And Torrent RPC

Hashes passed to these methods are 64-character hexadecimal hashes:

$block = $client->blockByHeight(1000);
$block = $client->blockByHash($blockHash);
$torrent = $client->torrentByHeight(1000);
$header = $client->headerByHeight(1000);
$header = $client->headerByHash($headerHash);
$blockHash = $client->blockHashAtHeight(1000);
$headers = $client->allHeaders();
  • blockByHeight() returns the raw saved block at a height.
  • blockByHash() returns the raw block matching a block hash.
  • torrentByHeight() returns the raw torrent metadata for a block.
  • headerByHeight() returns the raw block header at a height.
  • headerByHash() returns the raw header matching a header hash.
  • blockHashAtHeight() returns the hash recorded at a height.
  • allHeaders() returns the saved block-header history.

Transaction And Mempool RPC

$mempoolCount = $client->mempoolCount();
$mempoolTransaction = $client->mempoolTransactionBySignature(
    $signatureHex,
);
$addressMempool = $client->mempoolTransactionsByAddress($address);
$transaction = $client->transactionById($transactionId);
  • mempoolCount() returns the number of pending transactions.
  • mempoolTransactionBySignature() accepts a 1,332-character hexadecimal Falcon signature and returns the matching pending transaction.
  • mempoolTransactionsByAddress() returns pending transactions involving an address.
  • transactionById() accepts a 64-character hexadecimal transaction ID and returns the confirmed transaction and its recorded location.

Submit a complete serialized signed transaction represented as hexadecimal:

$reply = $client->submitTransaction($signedTransactionHex);

Submit a SignedTransaction produced by one of the included builders:

$reply = $client->submitSignedTransaction($signedTransaction);

Wallet And Address RPC

$coinBalance = $client->coinBalance('CLTC', $address);
$allBalances = $client->totalBalance($address);
$addressValid = $client->validateAddress($address);
$registration = $client->walletRegistrationStatus($address);
$history = $client->addressHistory($address, skip: 0, limit: 100);
$latest = $client->latestAddressTransactions($address, limit: 25);
$canonical = $client->vanityLookup('my-vanity-address.cltc');
$vanityOwner = $client->vanityOwner('my-vanity-address.cltc');
  • coinBalance() returns one confirmed asset balance as an eight-byte integer.
  • totalBalance() returns all confirmed balances owned by an address.
  • validateAddress() asks the node to validate an address.
  • walletRegistrationStatus() returns whether the wallet is registered.
  • addressHistory() returns a paginated confirmed transaction history.
  • latestAddressTransactions() returns the newest confirmed transactions.
  • vanityLookup() resolves a vanity address to its canonical wallet.
  • vanityOwner() returns the canonical owner of a vanity address.

Verify a signed message:

$result = $client->validateMessage(
    message: 'Message to verify',
    address: $address,
    signature: $signatureHex,
);

Wallet registration requires the application to provide the public key and registration signature:

$result = $client->registerWallet(
    $address,
    $publicKeyBytes,
    $signatureHex,
);

Token, NFT, RWA, Swap, And Loan RPC

$tokens = $client->tokenList();
$tokenCatalog = $client->tokenCatalog();
$token = $client->tokenDetails('TOKEN');

$nfts = $client->nftList();
$nft = $client->nftDetails('ART', series: 1);

$loan = $client->loanByHash($loanHash);
$addressContracts = $client->contractsByAddress($address);
$collateral = $client->collateralStatus($loanHash);
  • tokenList() returns the node's token list.
  • tokenCatalog() returns token catalog data.
  • tokenDetails() returns details for one token ticker.
  • nftList() returns the node's NFT and RWA list.
  • nftDetails() returns one NFT/RWA name and series record.
  • loanByHash() returns a loan using its contract hash.
  • contractsByAddress() returns loan contracts involving an address.
  • collateralStatus() returns the collateral state of a loan.

Marketing RPC

$records = $client->marketingCampaignHistory(
    advertiser: $address,
    campaign: 42,
    skip: 0,
    limit: 100,
);

marketingCampaignHistory() returns paginated marketing records for one advertiser and campaign number.

Data Storage RPC

Get the cost of returning one data key:

$quote = $client->storageLookupCost(
    storageKey: $storageKeyHash,
    dataKey: 'username',
    address: $address,
);

Get the cost of returning every key stored under the storage key and address:

$quote = $client->storageLookupCost(
    storageKey: $storageKeyHash,
    dataKey: 'all',
    address: $address,
);

Perform a paid lookup using a complete signed type-2 transfer represented as hexadecimal:

$data = $client->storageLookup(
    storageKey: $storageKeyHash,
    dataKey: 'all',
    address: $address,
    paymentTransactionHex: $paymentTransactionHex,
);

The payment must match the quote returned by the selected node. A node operator looking up data through their own node may receive a zero-cost quote.

Governance RPC

$proposal = $client->governanceProposal($proposalKeyHash);

governanceProposal() returns the recorded state of a Contractless proposal, including its voting and activation information.

Create And Broadcast A Transfer

Transaction builders receive an application-owned signature provider:

use Contractless\Rpc\Signing\CallbackSignatureProvider;

$signer = new CallbackSignatureProvider(
    function (string $digest) use ($applicationWallet): string {
        return $applicationWallet->signDigest($digest);
    },
);

The callback receives only the 32-byte transaction digest. How the application loads, decrypts, stores, or remotely accesses its signing key is outside this package. The callback must return a 666-byte raw Falcon signature.

use Contractless\Rpc\Transaction\TransferBuilder;

$transfer = (new TransferBuilder($crypto, $signer))->create(
    sender: $sender,
    receiver: $receiver,
    coin: 'CLTC',
    value: 2_500_000_000,
    fee: 25_000_000,
    nftSeries: 0,
);

$reply = $client->submitSignedTransaction($transfer);

The same builder transfers base currency, tokens, NFTs, and RWAs. Set nftSeries to the applicable series number for an NFT/RWA transfer.

Token, NFT, RWA, Burn, And Vanity Builders

use Contractless\Rpc\Transaction\AssetBuilder;

$assets = new AssetBuilder($crypto, $signer);

$createToken = $assets->createToken(
    creator: $address,
    ticker: 'TOKEN',
    number: 1_000_000_000,
    hardLimit: true,
    fee: 100_000_000,
);

$issueToken = $assets->issueToken(
    creator: $address,
    ticker: 'TOKEN',
    number: 500_000_000,
    fee: 100_000_000,
);

$createNft = $assets->createNft(
    creator: $address,
    series: false,
    fractionalOwnership: false,
    name: 'ART',
    ipfs: 'ipfs-content-identifier',
    count: 1,
    description: 'Description of the NFT or RWA',
    fee: 100_000_000,
);

$burn = $assets->burn(
    address: $address,
    coin: 'TOKEN',
    nftSeries: 0,
    value: 100_000_000,
    fee: 100_000_000,
);

$vanity = $assets->vanity(
    address: $address,
    vanityAddress: 'my-address.cltc',
    fee: 100_000_000,
);

$client->submitSignedTransaction($createToken);

Swap And Loan Builders

Swaps and loans require two wallet signatures. The first wallet creates and signs the transaction:

use Contractless\Rpc\Transaction\AgreementBuilder;

$agreements = new AgreementBuilder($crypto, $signer);

$swap = $agreements->swap(
    expiration: time() + 3600,
    ticker1: 'CLTC',
    series1: 0,
    value1: 100_000_000,
    ticker2: 'TOKEN',
    series2: 0,
    value2: 500_000_000,
    sender1: $firstAddress,
    sender2: $secondAddress,
    tip1: 1_000_000,
    tip2: 1_000_000,
    fee1: 2_500,
    fee2: 2_500,
);

$loan = $agreements->loan(
    loanCoin: 'CLTC',
    loanAmount: 1_000_000_000,
    lender: $lender,
    collateral: 'TOKEN',
    collateralAmount: 2_000_000_000,
    borrower: $borrower,
    paymentPeriod: 'm',
    paymentNumber: 12,
    paymentAmount: 100_000_000,
    gracePeriod: 2,
    maxLateValue: 300_000_000,
    fee: 100_000_000,
);

Export the partially signed transaction for the second party:

$exported = json_encode(
    $swap->export(),
    JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);

The second party imports and signs it using its own signature provider:

use Contractless\Rpc\Transaction\DualSignedTransaction;

$swap = DualSignedTransaction::import(
    json_decode($exported, true, flags: JSON_THROW_ON_ERROR),
);

$swap = $swap->signNext($crypto, $secondPartySigner);

if ($swap->isComplete()) {
    $client->submitSignedTransaction($swap->toSignedTransaction());
}

Loan Payment And Collateral Builders

use Contractless\Rpc\Transaction\MiscellaneousBuilder;

$miscellaneous = new MiscellaneousBuilder($crypto, $signer);

$payment = $miscellaneous->loanPayment(
    amount: 100_000_000,
    contractHash: $loanHash,
    address: $borrower,
    tip: 1_000_000,
    fee: 2_500,
);

$claim = $miscellaneous->collateralClaim(
    contractHash: $loanHash,
    address: $lender,
    fee: 100_000_000,
);

$client->submitSignedTransaction($payment);

Marketing Transaction Builder

$marketing = $miscellaneous->marketing(
    campaign: 42,
    adType: 'banner',
    keyword: 'blockchain',
    displayed: 'https://example.com/page',
    impressions: 1,
    clicks: 0,
    impressionValue: 100,
    clickValue: 500,
    advertiser: $address,
    fee: 100_000_000,
);

$client->submitSignedTransaction($marketing);

Data Storage Transaction Builders

use Contractless\Rpc\Transaction\StorageBuilder;

$storage = new StorageBuilder($crypto, $signer);

$createKey = $storage->createKey(
    address: $address,
    fee: 50_000_000_000,
);

$boolean = $storage->boolean(
    $storageKey, 'active', true, $address, 100_000_000,
);

$u8 = $storage->u8($storageKey, 'u8-key', 255, $address, 100_000_000);
$u16 = $storage->u16($storageKey, 'u16-key', 65535, $address, 100_000_000);
$u32 = $storage->u32($storageKey, 'u32-key', 100000, $address, 100_000_000);
$u64 = $storage->u64($storageKey, 'u64-key', 100000, $address, 100_000_000);
$u128 = $storage->u128(
    $storageKey,
    'u128-key',
    '340282366920938463463374607431768211455',
    $address,
    100_000_000,
);

$i8 = $storage->i8($storageKey, 'i8-key', -128, $address, 100_000_000);
$i16 = $storage->i16($storageKey, 'i16-key', -32768, $address, 100_000_000);
$i32 = $storage->i32($storageKey, 'i32-key', -100000, $address, 100_000_000);
$i64 = $storage->i64($storageKey, 'i64-key', -100000, $address, 100_000_000);
$i128 = $storage->i128(
    $storageKey,
    'i128-key',
    '-170141183460469231731687303715884105728',
    $address,
    100_000_000,
);

$string = $storage->string(
    storageKey: $storageKey,
    key: 'username',
    value: 'contractless-user',
    address: $address,
    previousHash: str_repeat('0', 64),
    fee: 100_000_000,
);

$delete = $storage->delete(
    storageKey: $storageKey,
    key: 'username',
    address: $address,
    fee: 100_000_000,
);

$client->submitSignedTransaction($string);

The u128() and i128() methods accept decimal strings so PHP does not lose precision. String values may contain up to 180 bytes per linked transaction. The first string uses 64 zeroes for previousHash; later chunks use the hash of the preceding string transaction.

Governance Transaction Builders

use Contractless\Rpc\Transaction\GovernanceBuilder;

$governance = new GovernanceBuilder($crypto, $signer);

$proposal = $governance->proposal(
    proposalHash: $proposalDocumentHash,
    address: $address,
    fee: 100_000_000,
);

$proposalVote = $governance->proposalVote(
    proposalKey: $proposalKey,
    address: $address,
    approve: true,
    fee: 100_000_000,
);

$activationVote = $governance->activationVote(
    proposalKey: $proposalKey,
    developmentHash: $implementationDocumentHash,
    developmentLocation: 'CLP-0001-IMPLEMENTATION.md',
    address: $address,
    approve: true,
    fee: 100_000_000,
);

$client->submitSignedTransaction($proposalVote);

The network still determines whether the submitting wallet is an eligible node for proposal and activation voting.

Signed Transaction Data

Every single-signature builder returns SignedTransaction:

$rawBytes = $transaction->bytes;
$hexadecimal = $transaction->toHex();
$signatureBytes = $transaction->signature;
$unsignedSigningJson = $transaction->unsignedJson;

Use submitSignedTransaction() to broadcast the exact bytes without converting them to hexadecimal first.

Wallet Custody

This package does not read Contractless wallet files, decode encrypted private key images, accept wallet decryption keys, or store private keys.

Applications are responsible for:

  • Loading or obtaining their wallet material
  • Protecting private keys and decryption keys
  • Creating the handshake signature
  • Creating transaction and registration signatures
  • Supplying only completed signatures and public data to this package

A faucet may keep its wallet loader in the faucet application. A browser wallet may sign locally in the browser. A hosted application may use a hardware or remote signer. All three can use the same RPC package because the connection layer does not control wallet custody.

Direct Public Command Calls

The named methods above are wrappers around call(). Applications may invoke an exposed public command directly when they need to construct its binary payload themselves:

use Contractless\Rpc\Protocol\Command;

$reply = $client->call(Command::BLOCK_HEIGHT);

With a payload:

use Contractless\Rpc\Protocol\Binary;

$reply = $client->call(
    Command::BLOCK_BY_HEIGHT,
    Binary::u32(1000),
);

call() rejects internal, administrative, mining, monitor, and unsupported command numbers. Prefer the named methods whenever one exists because they validate and encode the command payload correctly.

Error Handling

RPC transport, protocol, and validation failures throw exceptions:

try {
    $height = $client->blockHeight();
} catch (Throwable $error) {
    error_log($error->getMessage());
}

Do not expose internal node replies directly to public users.

Testing

composer install
composer test

Validate package metadata before publishing a release:

composer validate --strict