Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

6 changed files with 86 additions and 977 deletions

709
README.md
View File

@ -1,26 +1,18 @@
# 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.
`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 can be used to build faucets, block explorers, wallets, application
backends, storage applications, and other public Contractless integrations.
The package is intended for faucets, block explorers, wallets, application
backends, 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, signing, and broadcasting
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
@ -28,32 +20,48 @@ exposed.
## Requirements
- PHP 8.1 or newer using a 64-bit build
- PHP GD
- PHP OpenSSL
- The `skein` module from Contractless PHP Modules
- The `oqsphp` module from Contractless PHP Modules
- 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.
Install and test the Contractless PHP Modules before installing this package:
https://contractless.dev/contractless/Contractless-PHP-Modules
## Installation
Install version `0.2` through Packagist:
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/contractless-php-rpc:^0.2.0 -W
composer require contractless/php-contractless-rpc
```
Composer installs the package under `vendor/` and generates the required
autoload files.
Until the package is published, add it as a Composer path repository:
## Create A Client
```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
@ -65,633 +73,79 @@ use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Transport\StreamTransport;
use Contractless\Rpc\Wallet\Credentials;
$credentials = Credentials::fromWalletFile(
'/private/path/contractless.wallet',
'wallet decryption key',
$credentials = Credentials::fromHex(
getenv('CONTRACTLESS_PUBLIC_KEY'),
getenv('CONTRACTLESS_PRIVATE_KEY'),
);
$crypto = new NativeCrypto();
$client = new Client(
new StreamTransport(
host: '127.0.0.1',
port: 50050,
timeout: 10.0,
),
new StreamTransport('127.0.0.1', 50050),
$crypto,
$credentials,
);
```
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 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:
```php
$data = json_decode(
$client->networkInfo(),
true,
flags: JSON_THROW_ON_ERROR,
echo $client->totalBalance(
'ab13318c26250b048db92920a80a86127c933b0c.cltc',
);
```
Do not apply text conversion to raw blocks, headers, torrents, transactions, or
binary integer replies.
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.
A coin balance is returned as an eight-byte little-endian unsigned integer:
## Transaction Submission
```php
$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
```php
$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:
```php
$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
```php
$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:
`submitTransaction()` accepts the complete serialized signed transaction as a
hexadecimal string:
```php
$reply = $client->submitTransaction($signedTransactionHex);
```
Submit a `SignedTransaction` produced by one of the included builders:
```php
$reply = $client->submitSignedTransaction($signedTransaction);
```
## Wallet And Address RPC
```php
$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:
```php
$result = $client->validateMessage(
message: 'Message to verify',
address: $address,
signature: $signatureHex,
);
```
Register the wallet loaded into `$credentials`:
```php
$result = $client->registerOwnedWallet($address);
```
The lower-level registration method is available when an application has
already created the required signature:
```php
$result = $client->registerWallet($address, $signatureHex);
```
## Token, NFT, RWA, Swap, And Loan RPC
```php
$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
```php
$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:
```php
$quote = $client->storageLookupCost(
storageKey: $storageKeyHash,
dataKey: 'username',
address: $address,
);
```
Get the cost of returning every key stored under the storage key and address:
```php
$quote = $client->storageLookupCost(
storageKey: $storageKeyHash,
dataKey: 'all',
address: $address,
);
```
Perform a paid lookup using a complete signed type-2 transfer represented as
hexadecimal:
```php
$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
```php
$proposal = $client->governanceProposal($proposalKeyHash);
```
`governanceProposal()` returns the recorded state of a Contractless proposal,
including its voting and activation information.
## Create And Broadcast A Transfer
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,
receiver: $receiver,
sender: 'sender-address.cltc',
receiver: 'receiver-address.cltc',
coin: 'CLTC',
value: 2_500_000_000,
fee: 25_000_000,
nftSeries: 0,
fee: 2_500,
);
$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.
Transaction builders are a separate layer over the protocol client. This keeps
transport and node access stable when new transaction types are introduced.
## Token, NFT, RWA, Burn, And Vanity Builders
## Transaction Builders
```php
use Contractless\Rpc\Transaction\AssetBuilder;
- `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
$assets = new AssetBuilder($crypto, $credentials);
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.
$createToken = $assets->createToken(
creator: $address,
ticker: 'TOKEN',
number: 1_000_000_000,
hardLimit: true,
fee: 100_000_000,
);
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.
$issueToken = $assets->issueToken(
creator: $address,
ticker: 'TOKEN',
number: 500_000_000,
fee: 100_000_000,
);
## Wallet Material
$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:
```php
use Contractless\Rpc\Transaction\AgreementBuilder;
$agreements = new AgreementBuilder($crypto, $credentials);
$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:
```php
$exported = json_encode(
$swap->export(),
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
```
The second party imports and signs it using their own `$crypto` and
`$credentials`:
```php
use Contractless\Rpc\Transaction\DualSignedTransaction;
$swap = DualSignedTransaction::import(
json_decode($exported, true, flags: JSON_THROW_ON_ERROR),
);
$swap = $swap->signNext($crypto, $credentials);
if ($swap->isComplete()) {
$client->submitSignedTransaction($swap->toSignedTransaction());
}
```
## Loan Payment And Collateral Builders
```php
use Contractless\Rpc\Transaction\MiscellaneousBuilder;
$miscellaneous = new MiscellaneousBuilder($crypto, $credentials);
$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
```php
$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
```php
use Contractless\Rpc\Transaction\StorageBuilder;
$storage = new StorageBuilder($crypto, $credentials);
$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
```php
use Contractless\Rpc\Transaction\GovernanceBuilder;
$governance = new GovernanceBuilder($crypto, $credentials);
$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`:
```php
$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 Files
Load a normal Contractless wallet:
```php
$credentials = Credentials::fromWalletFile(
'/private/path/contractless.wallet',
'wallet decryption key',
);
```
The loader:
- Decodes every supported Contractless private-key image orientation
- Verifies the encrypted payload HMAC
- Decrypts the Falcon private key
- Proves that the public and private keys match
- Verifies the wallet's canonical short address
Wallet files and decryption keys must remain outside public web directories,
logs, repositories, and client-visible configuration.
Verify a wallet before using it:
```bash
php vendor/contractless/contractless-php-rpc/examples/load_wallet.php \
/private/path/contractless.wallet \
'wallet decryption key'
```
Applications that already manage protected raw keys may load hexadecimal
Falcon keys directly:
```php
$credentials = Credentials::fromHex($publicKeyHex, $privateKeyHex);
```
## 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:
```php
use Contractless\Rpc\Protocol\Command;
$reply = $client->call(Command::BLOCK_HEIGHT);
```
With a payload:
```php
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, validation, and wallet failures throw exceptions:
```php
try {
$height = $client->blockHeight();
} catch (Throwable $error) {
error_log($error->getMessage());
}
```
Do not expose wallet errors, filesystem paths, private material, or internal
node replies directly to public users.
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
@ -700,8 +154,17 @@ composer install
composer test
```
Validate package metadata before publishing a release:
## 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.

View File

@ -5,9 +5,7 @@
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-gd": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-oqsphp": "*",
"ext-skein": "*"
},

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
use Contractless\Rpc\Wallet\Credentials;
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
if (!is_file($autoload)) {
fwrite(STDERR, "Composer dependencies are not installed.\n");
exit(1);
}
require $autoload;
if ($argc !== 3) {
fwrite(STDERR, "Usage: php examples/load_wallet.php <wallet-file> <wallet-key>\n");
exit(1);
}
try {
$credentials = Credentials::fromWalletFile($argv[1], $argv[2]);
echo "Contractless wallet loaded and verified.\n";
echo 'Public key bytes: ' . strlen($credentials->publicKey) . "\n";
echo 'Private key bytes: ' . strlen($credentials->privateKey) . "\n";
} catch (Throwable $error) {
fwrite(STDERR, 'Wallet verification failed: ' . $error->getMessage() . "\n");
exit(1);
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Contractless\Rpc\Wallet;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Protocol\Binary;
final class Credentials
@ -24,9 +23,4 @@ final class Credentials
Binary::hex($privateKey, 1281, 'Falcon private key'),
);
}
public static function fromWalletFile(string $walletPath, string $walletKey): self
{
return WalletLoader::load($walletPath, $walletKey, new NativeCrypto());
}
}

View File

@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Wallet;
use Contractless\Rpc\Exception\ContractlessException;
final class EncryptedImageDecoder
{
private const IMAGE_WIDTH = 350;
private const LENGTH_PREFIX_BYTES = 4;
private const ANCHOR_ROWS = [0, 35, 70, 105, 140, 175, 210, 245, 280, 315, 349];
/** @var array<string, string>|null */
private static ?array $colorMap = null;
public static function extract(string $encodedImage): string
{
if (!extension_loaded('gd')) {
throw new ContractlessException('The GD PHP extension is required to read wallet images.');
}
$png = base64_decode($encodedImage, true);
if ($png === false) {
throw new ContractlessException('The wallet private-key image is not valid Base64.');
}
foreach (['h', 'h2', 'v', 'v2'] as $style) {
$image = @imagecreatefromstring($png);
if ($image === false) {
throw new ContractlessException('The wallet private-key image is not a valid PNG.');
}
$oriented = null;
try {
$oriented = self::orient($image, $style);
if ($oriented !== $image) {
imagedestroy($image);
$image = null;
}
$text = self::extractFromOrientedImage($oriented);
if ($text !== null) {
return $text;
}
} finally {
if ($oriented !== null) {
imagedestroy($oriented);
} elseif ($image !== null) {
imagedestroy($image);
}
}
}
throw new ContractlessException('The wallet private-key image could not be decoded.');
}
/** @param \GdImage $image */
private static function orient(object $image, string $style): object
{
if ($style === 'h') {
return $image;
}
if ($style === 'h2') {
imageflip($image, IMG_FLIP_VERTICAL);
return $image;
}
// PHP rotates counter-clockwise. These transformations mirror the
// inverse rotations used by encrypted_images during decoding.
$rotated = imagerotate($image, $style === 'v' ? 90 : -90, 0);
if ($rotated === false) {
throw new ContractlessException('The wallet image could not be rotated.');
}
if ($style === 'v2') {
imageflip($rotated, IMG_FLIP_VERTICAL);
}
return $rotated;
}
/** @param \GdImage $image */
private static function extractFromOrientedImage(object $image): ?string
{
if (imagesx($image) !== self::IMAGE_WIDTH) {
return null;
}
$characters = '';
$map = self::colorMap();
foreach (self::ANCHOR_ROWS as $row) {
for ($x = 0; $x < self::IMAGE_WIDTH; $x++) {
$index = imagecolorat($image, $x, $row);
if ($index === false) {
return null;
}
$rgba = imagecolorsforindex($image, $index);
$key = "{$rgba['red']},{$rgba['green']},{$rgba['blue']}";
if (!isset($map[$key])) {
return null;
}
$characters .= $map[$key];
}
}
$prefix = substr($characters, 0, self::LENGTH_PREFIX_BYTES);
if (strlen($prefix) !== self::LENGTH_PREFIX_BYTES || !ctype_digit($prefix)) {
return null;
}
$length = (int) $prefix;
$payload = substr($characters, self::LENGTH_PREFIX_BYTES, $length);
return strlen($payload) === $length ? $payload : null;
}
/** @return array<string, string> */
private static function colorMap(): array
{
if (self::$colorMap !== null) {
return self::$colorMap;
}
$base = [
'a' => [204, 180, 194], 'A' => [255, 255, 255],
'b' => [197, 186, 201], 'B' => [221, 206, 212],
'c' => [181, 185, 193], 'C' => [184, 201, 223],
'd' => [224, 218, 192], 'D' => [185, 191, 195],
'e' => [181, 197, 198], 'E' => [193, 206, 255],
'f' => [252, 193, 211], 'F' => [183, 192, 229],
'g' => [180, 191, 192], 'G' => [187, 219, 189],
'h' => [195, 187, 234], 'H' => [182, 216, 189],
'i' => [197, 183, 248], 'I' => [200, 182, 204],
'j' => [255, 235, 196], 'J' => [194, 186, 228],
'k' => [199, 238, 239], 'K' => [208, 247, 234],
'l' => [244, 214, 189], 'L' => [187, 243, 239],
'm' => [188, 231, 238], 'M' => [187, 197, 227],
'n' => [186, 240, 191], 'N' => [187, 198, 206],
'o' => [205, 193, 184], 'O' => [191, 187, 197],
'p' => [194, 200, 206], 'P' => [195, 183, 229],
'q' => [182, 219, 196], 'Q' => [238, 216, 184],
'r' => [199, 181, 208], 'R' => [239, 231, 198],
's' => [189, 188, 230], 'S' => [242, 192, 230],
't' => [199, 199, 199], 'T' => [188, 190, 230],
'u' => [230, 180, 253], 'U' => [241, 247, 247],
'v' => [242, 190, 199], 'V' => [230, 247, 234],
'w' => [197, 186, 249], 'W' => [194, 247, 249],
'x' => [242, 182, 246], 'X' => [188, 222, 193],
'y' => [188, 194, 183], 'Y' => [197, 195, 197],
'z' => [187, 249, 240], 'Z' => [233, 231, 242],
'0' => [195, 184, 218], '1' => [232, 180, 196],
'2' => [191, 193, 196], '3' => [185, 186, 186],
'4' => [191, 247, 180], '5' => [187, 199, 248],
'6' => [248, 198, 184], '7' => [243, 195, 184],
'8' => [232, 192, 208], '9' => [239, 197, 183],
'/' => [199, 187, 241], '+' => [195, 216, 223],
'=' => [193, 211, 184],
];
$map = [];
foreach ($base as $character => [$red, $green, $blue]) {
$average = ($red + $green + $blue) / 3;
$vivid = static function (int $value) use ($average): int {
$saturated = $average + (($value - $average) * 2.35);
$contrasted = (($saturated - 128) * 1.12) + 128 - 18;
return (int) round(max(0, min(255, $contrasted)));
};
$map[$vivid($red) . ',' . $vivid($green) . ',' . $vivid($blue)] = $character;
}
self::$colorMap = $map;
return $map;
}
}

View File

@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
namespace Contractless\Rpc\Wallet;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Exception\ContractlessException;
final class WalletLoader
{
public static function load(
string $walletPath,
string $walletKey,
CryptoInterface $crypto,
): Credentials {
if (!extension_loaded('openssl')) {
throw new ContractlessException('The OpenSSL PHP extension is required to decrypt wallets.');
}
if (!is_file($walletPath) || !is_readable($walletPath)) {
throw new ContractlessException('The Contractless wallet file is not readable.');
}
$contents = file_get_contents($walletPath);
if ($contents === false) {
throw new ContractlessException('The Contractless wallet file could not be read.');
}
try {
$wallet = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
throw new ContractlessException('The Contractless wallet file is not valid JSON.');
}
if (!is_array($wallet)) {
throw new ContractlessException('The Contractless wallet file has an invalid structure.');
}
$address = strtolower(trim((string) ($wallet['short_address'] ?? '')));
$publicKeyHex = trim((string) ($wallet['public_key'] ?? ''));
$encodedImage = trim((string) ($wallet['private_key'] ?? ''));
if (preg_match('/^[a-f0-9]{40}\.(clc|cltc)$/', $address, $match) !== 1) {
throw new ContractlessException('The wallet contains an invalid short address.');
}
if ($encodedImage === '') {
throw new ContractlessException('The wallet does not contain a private-key image.');
}
$publicKey = self::normalizePublicKey($publicKeyHex, $match[1]);
$encryptedPrivateKey = EncryptedImageDecoder::extract($encodedImage);
$privateKeyHex = self::decrypt($encryptedPrivateKey, $walletKey);
$credentials = Credentials::fromHex(bin2hex($publicKey), $privateKeyHex);
self::validatePublicKey($credentials->publicKey, $crypto);
self::validateKeypair($credentials, $crypto);
self::validateAddress($address, $credentials->publicKey, $crypto);
return $credentials;
}
private static function decrypt(string $encodedCiphertext, string $walletKey): string
{
$encrypted = base64_decode($encodedCiphertext, true);
if ($encrypted === false || strlen($encrypted) < 49) {
throw new ContractlessException('The encrypted wallet payload is invalid.');
}
$key = substr(str_pad($walletKey, 16, "\0"), 0, 16);
$iv = substr($encrypted, 0, 16);
$expectedHmac = substr($encrypted, 16, 32);
$ciphertext = substr($encrypted, 48);
$actualHmac = hash_hmac('sha256', $ciphertext, $key, true);
if (!hash_equals($expectedHmac, $actualHmac)) {
throw new ContractlessException('The wallet decryption key is incorrect.');
}
$privateKey = openssl_decrypt(
$ciphertext,
'aes-128-cbc',
$key,
OPENSSL_RAW_DATA,
$iv,
);
if ($privateKey === false || strlen($privateKey) !== 2562 || !ctype_xdigit($privateKey)) {
throw new ContractlessException('The decrypted Falcon private key is invalid.');
}
return strtolower($privateKey);
}
private static function normalizePublicKey(string $publicKeyHex, string $network): string
{
if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex)) {
throw new ContractlessException('The wallet contains an invalid Falcon public key.');
}
$publicKey = hex2bin($publicKeyHex);
if ($publicKey === false) {
throw new ContractlessException('The wallet public key could not be decoded.');
}
$networkByte = $network === 'clc' ? 1 : 2;
if (strlen($publicKey) === 898 && ord($publicKey[0]) === $networkByte) {
$publicKey = substr($publicKey, 1);
}
if (strlen($publicKey) !== 897) {
throw new ContractlessException('The wallet Falcon public key has an invalid length.');
}
return $publicKey;
}
private static function validatePublicKey(string $publicKey, CryptoInterface $crypto): void
{
if (ord($publicKey[0]) !== 9 || ord($crypto->skein256($publicKey)[0]) !== 239) {
throw new ContractlessException(
'The wallet public key does not satisfy the Contractless key rule.',
);
}
}
private static function validateKeypair(Credentials $credentials, CryptoInterface $crypto): void
{
$challenge = $crypto->skein256('contractless-wallet-keypair-check');
$signature = $crypto->sign(
$challenge,
$credentials->privateKey,
$credentials->publicKey,
);
if (!$crypto->verify($challenge, $signature, $credentials->publicKey)) {
throw new ContractlessException(
'The wallet public key does not match the decrypted private key.',
);
}
}
private static function validateAddress(
string $address,
string $publicKey,
CryptoInterface $crypto,
): void {
$suffix = str_ends_with($address, '.clc') ? 'clc' : 'cltc';
$skein = $crypto->skein256($publicKey);
$payload = hash('ripemd160', $skein);
$derived = $payload . '.' . $suffix;
if (!hash_equals($address, $derived)) {
throw new ContractlessException(
'The wallet short address does not match its Falcon public key.',
);
}
}
}