Compare commits
3 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
9808302ba8 | |
|
|
005f132758 | |
|
|
33718dcd81 |
709
README.md
709
README.md
|
|
@ -1,18 +1,26 @@
|
||||||
# PHP Contractless RPC
|
# PHP Contractless RPC
|
||||||
|
|
||||||
`php-contractless-rpc` connects PHP applications directly to a Contractless
|
`contractless-php-rpc` allows PHP applications to connect directly to a
|
||||||
node. It implements the authenticated binary RPC protocol without invoking
|
Contractless node through its authenticated binary RPC protocol. It does not
|
||||||
Contractless CLI programs.
|
invoke Contractless CLI programs and does not depend on centralized blockchain
|
||||||
|
APIs.
|
||||||
|
|
||||||
The package is intended for faucets, block explorers, wallets, application
|
The package can be used to build faucets, block explorers, wallets, application
|
||||||
backends, and other public Contractless integrations.
|
backends, storage applications, and other public Contractless integrations.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
The client includes public blockchain lookups, wallet and address lookups,
|
The library exposes application-safe public RPC commands, including:
|
||||||
mempool lookups, asset lookups, loan lookups, storage lookups, governance
|
|
||||||
lookups, wallet registration, signed transaction submission, and an RPC
|
- Network and blockchain information
|
||||||
interface restricted to application-safe commands.
|
- 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
|
||||||
|
|
||||||
Miner relay commands, network mapping changes, monitor-state commands, node
|
Miner relay commands, network mapping changes, monitor-state commands, node
|
||||||
setup synchronization, and server-owner IP controls are intentionally not
|
setup synchronization, and server-owner IP controls are intentionally not
|
||||||
|
|
@ -20,48 +28,32 @@ exposed.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- PHP 8.1 or newer, running as a 64-bit build
|
- PHP 8.1 or newer using a 64-bit build
|
||||||
- The `skein` module from `contractless-php-crypto`
|
- PHP GD
|
||||||
- The `oqsphp` module from `contractless-php-crypto`
|
- PHP OpenSSL
|
||||||
|
- The `skein` module from Contractless PHP Modules
|
||||||
|
- The `oqsphp` module from Contractless PHP Modules
|
||||||
|
|
||||||
Contractless uses the same Falcon key and signature sizes as
|
Contractless uses the same Falcon key and signature sizes as
|
||||||
`Falcon-padded-512`, but its FN-DSA signing mode requires a small message
|
`Falcon-padded-512`, but its FN-DSA signing mode requires a small message
|
||||||
adaptation. `NativeCrypto` performs that adaptation automatically.
|
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
|
## Installation
|
||||||
|
|
||||||
Install and enable `contractless-php-crypto` first. Its repository contains the
|
Install version `0.2` through Packagist:
|
||||||
native module build, installation, and compatibility-test instructions.
|
|
||||||
|
|
||||||
When this package is available through Packagist:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require contractless/php-contractless-rpc
|
composer require contractless/contractless-php-rpc:^0.2.0 -W
|
||||||
```
|
```
|
||||||
|
|
||||||
Until the package is published, add it as a Composer path repository:
|
Composer installs the package under `vendor/` and generates the required
|
||||||
|
autoload files.
|
||||||
|
|
||||||
```json
|
## Create A Client
|
||||||
{
|
|
||||||
"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
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -73,79 +65,633 @@ use Contractless\Rpc\Crypto\NativeCrypto;
|
||||||
use Contractless\Rpc\Transport\StreamTransport;
|
use Contractless\Rpc\Transport\StreamTransport;
|
||||||
use Contractless\Rpc\Wallet\Credentials;
|
use Contractless\Rpc\Wallet\Credentials;
|
||||||
|
|
||||||
$credentials = Credentials::fromHex(
|
$credentials = Credentials::fromWalletFile(
|
||||||
getenv('CONTRACTLESS_PUBLIC_KEY'),
|
'/private/path/contractless.wallet',
|
||||||
getenv('CONTRACTLESS_PRIVATE_KEY'),
|
'wallet decryption key',
|
||||||
);
|
);
|
||||||
|
|
||||||
$crypto = new NativeCrypto();
|
$crypto = new NativeCrypto();
|
||||||
|
|
||||||
$client = new Client(
|
$client = new Client(
|
||||||
new StreamTransport('127.0.0.1', 50050),
|
new StreamTransport(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 50050,
|
||||||
|
timeout: 10.0,
|
||||||
|
),
|
||||||
$crypto,
|
$crypto,
|
||||||
$credentials,
|
$credentials,
|
||||||
);
|
);
|
||||||
|
```
|
||||||
|
|
||||||
echo $client->totalBalance(
|
Use port `50050` for the default testnet RPC and `50055` for the default
|
||||||
'ab13318c26250b048db92920a80a86127c933b0c.cltc',
|
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,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
RPC replies are returned as their original bytes. Commands that return JSON can
|
Do not apply text conversion to raw blocks, headers, torrents, transactions, or
|
||||||
be decoded with `json_decode($reply, true, flags: JSON_THROW_ON_ERROR)`. Binary
|
binary integer replies.
|
||||||
block, torrent, header, and transaction replies remain available without a
|
|
||||||
lossy conversion.
|
|
||||||
|
|
||||||
## Transaction Submission
|
A coin balance is returned as an eight-byte little-endian unsigned integer:
|
||||||
|
|
||||||
`submitTransaction()` accepts the complete serialized signed transaction as a
|
```php
|
||||||
hexadecimal string:
|
$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:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$reply = $client->submitTransaction($signedTransactionHex);
|
$reply = $client->submitTransaction($signedTransactionHex);
|
||||||
```
|
```
|
||||||
|
|
||||||
The first transaction builder covers the transfer used by faucets and ordinary
|
Submit a `SignedTransaction` produced by one of the included builders:
|
||||||
currency, token, NFT, and RWA transfers:
|
|
||||||
|
```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
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Contractless\Rpc\Transaction\TransferBuilder;
|
use Contractless\Rpc\Transaction\TransferBuilder;
|
||||||
|
|
||||||
$transfer = (new TransferBuilder($crypto, $credentials))->create(
|
$transfer = (new TransferBuilder($crypto, $credentials))->create(
|
||||||
sender: 'sender-address.cltc',
|
sender: $sender,
|
||||||
receiver: 'receiver-address.cltc',
|
receiver: $receiver,
|
||||||
coin: 'CLTC',
|
coin: 'CLTC',
|
||||||
value: 2_500_000_000,
|
value: 2_500_000_000,
|
||||||
fee: 2_500,
|
fee: 25_000_000,
|
||||||
|
nftSeries: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
$reply = $client->submitSignedTransaction($transfer);
|
$reply = $client->submitSignedTransaction($transfer);
|
||||||
```
|
```
|
||||||
|
|
||||||
Transaction builders are a separate layer over the protocol client. This keeps
|
The same builder transfers base currency, tokens, NFTs, and RWAs. Set
|
||||||
transport and node access stable when new transaction types are introduced.
|
`nftSeries` to the applicable series number for an NFT/RWA transfer.
|
||||||
|
|
||||||
## Transaction Builders
|
## Token, NFT, RWA, Burn, And Vanity Builders
|
||||||
|
|
||||||
- `TransferBuilder`: currency, token, NFT, and RWA transfers
|
```php
|
||||||
- `AssetBuilder`: token creation, token issuance, NFT/RWA creation, burns, and vanity addresses
|
use Contractless\Rpc\Transaction\AssetBuilder;
|
||||||
- `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,
|
$assets = new AssetBuilder($crypto, $credentials);
|
||||||
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
|
$createToken = $assets->createToken(
|
||||||
Rust `u128` and `i128` ranges instead of silently losing precision through PHP
|
creator: $address,
|
||||||
integers or floating-point numbers.
|
ticker: 'TOKEN',
|
||||||
|
number: 1_000_000_000,
|
||||||
|
hardLimit: true,
|
||||||
|
fee: 100_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
## Wallet Material
|
$issueToken = $assets->issueToken(
|
||||||
|
creator: $address,
|
||||||
|
ticker: 'TOKEN',
|
||||||
|
number: 500_000_000,
|
||||||
|
fee: 100_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
The library accepts raw Falcon public and private key bytes. It does not read or
|
$createNft = $assets->createNft(
|
||||||
decrypt a Contractless private-key image. A server application should load keys
|
creator: $address,
|
||||||
from protected secrets and must never place them in a public web directory.
|
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.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
@ -154,17 +700,8 @@ composer install
|
||||||
composer test
|
composer test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Preparing The Composer Package
|
Validate package metadata before publishing a release:
|
||||||
|
|
||||||
Validate the package before publishing:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer validate --strict
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": ">=8.1",
|
||||||
|
"ext-gd": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
"ext-oqsphp": "*",
|
"ext-oqsphp": "*",
|
||||||
"ext-skein": "*"
|
"ext-skein": "*"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Contractless\Rpc\Wallet;
|
namespace Contractless\Rpc\Wallet;
|
||||||
|
|
||||||
|
use Contractless\Rpc\Crypto\NativeCrypto;
|
||||||
use Contractless\Rpc\Protocol\Binary;
|
use Contractless\Rpc\Protocol\Binary;
|
||||||
|
|
||||||
final class Credentials
|
final class Credentials
|
||||||
|
|
@ -23,4 +24,9 @@ final class Credentials
|
||||||
Binary::hex($privateKey, 1281, 'Falcon private key'),
|
Binary::hex($privateKey, 1281, 'Falcon private key'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function fromWalletFile(string $walletPath, string $walletKey): self
|
||||||
|
{
|
||||||
|
return WalletLoader::load($walletPath, $walletKey, new NativeCrypto());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue