708 lines
18 KiB
Markdown
708 lines
18 KiB
Markdown
# 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, signing, and broadcasting
|
|
|
|
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
|
|
- PHP GD
|
|
- 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
|
|
`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:
|
|
|
|
```bash
|
|
composer require contractless/contractless-php-rpc:^0.2.0 -W
|
|
```
|
|
|
|
Composer installs the package under `vendor/` and generates the required
|
|
autoload files.
|
|
|
|
## Create A Client
|
|
|
|
```php
|
|
<?php
|
|
|
|
require __DIR__ . '/vendor/autoload.php';
|
|
|
|
use Contractless\Rpc\Client;
|
|
use Contractless\Rpc\Crypto\NativeCrypto;
|
|
use Contractless\Rpc\Transport\StreamTransport;
|
|
use Contractless\Rpc\Wallet\Credentials;
|
|
|
|
$credentials = Credentials::fromWalletFile(
|
|
'/private/path/contractless.wallet',
|
|
'wallet decryption key',
|
|
);
|
|
|
|
$crypto = new NativeCrypto();
|
|
|
|
$client = new Client(
|
|
new StreamTransport(
|
|
host: '127.0.0.1',
|
|
port: 50050,
|
|
timeout: 10.0,
|
|
),
|
|
$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,
|
|
);
|
|
```
|
|
|
|
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:
|
|
|
|
```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:
|
|
|
|
```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
|
|
|
|
```php
|
|
use Contractless\Rpc\Transaction\TransferBuilder;
|
|
|
|
$transfer = (new TransferBuilder($crypto, $credentials))->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
|
|
|
|
```php
|
|
use Contractless\Rpc\Transaction\AssetBuilder;
|
|
|
|
$assets = new AssetBuilder($crypto, $credentials);
|
|
|
|
$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:
|
|
|
|
```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
|
|
|
|
```bash
|
|
composer install
|
|
composer test
|
|
```
|
|
|
|
Validate package metadata before publishing a release:
|
|
|
|
```bash
|
|
composer validate --strict
|
|
```
|