Initial Contractless PHP API

This commit is contained in:
viraladmin 2026-07-27 15:21:26 -06:00
commit c5c467c814
34 changed files with 3535 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/api.env
/vendor/
/storage/security-state.json

432
README.md Normal file
View File

@ -0,0 +1,432 @@
# Contractless PHP API
HTTP API foundation for applications that communicate with Contractless nodes
through `contractless/contractless-php-rpc`.
## Install
```bash
composer install
cp api.env.example api.env
```
Set one or more RPC nodes in `api.env`. The API has no wallet file,
decryption key, public key, or private key of its own. The environment file
must not be placed inside `public/`.
Point the web server document root to the `public` directory. During local
development, PHP's built-in server can be started from the project root:
```bash
php -S 127.0.0.1:8080 -t public public/index.php
```
The version root is available at `GET /api/v1`.
## Wallet Authentication
Except for `GET /api/v1/live` and CORS `OPTIONS` requests, callers provide the
wallet identity used for the node RPC handshake:
```text
X-Contractless-Address: 40-character-address.cltc
X-Contractless-Public-Key: Falcon-public-key-hex
X-Contractless-Signature: 1332-character-signature-hex
```
`X-Contractless-Public-Key` may contain either the raw 897-byte Falcon public
key or the 898-byte wallet representation with its leading network byte.
`X-Contractless-Signature` is the caller's Falcon signature of the 32-byte
Skein-256 digest of the exact ASCII text `aced`. The browser wallet or
application creates this proof locally. The API never receives the private key
and never signs on behalf of the user.
Before opening an RPC connection, the API verifies:
- The address, public key, and signature have the required sizes and formats
- The public key satisfies the Contractless public-key rule
- The wallet address is derived from the supplied public key
- The supplied signature validates against the Contractless handshake digest
The verified public key and signature are then passed to the PHP RPC library
as a `HandshakeProof`. Every HTTP request creates its RPC client from that
caller's proof. The API operator does not maintain a registered hot wallet.
This handshake signature identifies the caller to a Contractless node. It does
not authorize spending. Transactions must still be created and signed locally
by the user's wallet and submitted as complete signed transaction bytes.
Because the handshake proof can be reused by anyone who sees it, production
APIs must use HTTPS. Replaying it can identify a caller for public lookups, but
cannot produce valid wallet transactions without the user's private key.
For example:
```bash
curl https://api.example.com/api/v1/height \
-H "X-Contractless-Address: 0123456789abcdef0123456789abcdef01234567.cltc" \
-H "X-Contractless-Public-Key: $CONTRACTLESS_PUBLIC_KEY_HEX" \
-H "X-Contractless-Signature: $CONTRACTLESS_HANDSHAKE_SIGNATURE_HEX"
```
A browser wallet uses the same headers:
```javascript
const response = await fetch(`${apiEndpoint}/api/v1/height`, {
headers: {
"X-Contractless-Address": wallet.address,
"X-Contractless-Public-Key": wallet.publicKeyHex,
"X-Contractless-Signature": wallet.handshakeSignatureHex,
},
});
const result = await response.json();
```
The wallet can generate the handshake proof when it is unlocked and reuse it
for later API calls. It does not send its private key or wallet decryption key.
## Initial Endpoints
All responses use JSON. Successful responses contain `success: true` and a
`data` object. Errors contain `success: false` and a sanitized `error` message.
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1` | API version information |
| `GET` | `/api/v1/live` | Confirm the PHP API configuration and process are reachable |
| `GET` | `/api/v1/ready` | Confirm an RPC node is reachable and show endpoint health |
| `GET` | `/api/v1/health` | Confirm RPC availability and return chain height |
| `GET` | `/api/v1/network` | Return decoded network information |
| `GET` | `/api/v1/height` | Return the current block height |
| `GET` | `/api/v1/time` | Return the node's Unix timestamp |
| `GET` | `/api/v1/addresses/validate?address=...` | Validate a registered canonical address |
| `GET` | `/api/v1/addresses/vanity/resolve?address=...` | Resolve a vanity address to its owner |
| `GET` | `/api/v1/addresses/registration?address=...` | Check wallet registration |
| `GET` | `/api/v1/balances/base?coin=CLTC&address=...` | Return one base-coin balance |
| `GET` | `/api/v1/balances?address=...` | Return every balance owned by an address |
| `GET` | `/api/v1/transactions/lookup?txid=...` | Return a confirmed transaction |
| `GET` | `/api/v1/addresses/history?address=...&skip=0&limit=100` | Return paginated history |
| `POST` | `/api/v1/messages/verify` | Verify a signed message |
| `POST` | `/api/v1/transactions/broadcast` | Broadcast a complete signed transaction |
Message verification accepts:
```json
{
"message": "Exact message bytes",
"address": "40-character-address.cltc",
"signature": "1332-character-signature-hex"
}
```
Transaction broadcasting accepts:
```json
{
"transaction_hex": "complete-signed-transaction-hex"
}
```
The API never creates or signs a transaction for the caller. It only forwards
the complete signed transaction through `Client::submitTransaction()`.
## Reliability
Each RPC request still opens one connection, makes one request, receives one
reply, and closes the connection. The API does not create persistent node
connections.
The endpoint pool retries another configured node only when a connection,
timeout, read, or write failure occurs. A protocol error or rejected response
is returned as a failure instead of being hidden by another node.
Endpoint health survives separate PHP requests in the locked API state file:
```ini
CONTRACTLESS_RPC_TIMEOUT=10
RPC_FAILURE_THRESHOLD=2
RPC_COOLDOWN_SECONDS=30
```
After the configured number of consecutive transport failures, that endpoint
is skipped for the cooldown period. A successful request clears its failure
count. If every endpoint is cooling down or unreachable, the API returns
`503 Service Unavailable`.
Use `GET /api/v1/live` for a process liveness check. It does not require wallet
headers or contact a Contractless node. Use `GET /api/v1/ready` with wallet
headers when a deployment check must confirm that at least one RPC node can
answer.
Malformed upstream protocol replies return `502 Bad Gateway`. Internal API
failures return a sanitized `500` response. Every response includes an
`X-Request-ID` header. Server error logs contain that identifier and the error
class, but never request bodies, wallet authentication headers, private keys,
or raw node responses.
Paid storage lookups remain pinned to the endpoint that generated their quote.
They are never moved to another endpoint because payment addresses and lookup
prices can differ between node operators.
Rate limits, HMAC nonces, and endpoint health share the same file-backed state
store. Every read-modify-write operation holds an exclusive file lock, so
concurrent PHP workers cannot overwrite each other's updates.
## API Security
General HTTP security controls run before caller wallet authentication or RPC
client construction. This prevents rejected or rate-limited requests from
triggering Falcon verification.
### HTTPS
`API_REQUIRE_HTTPS=true` rejects non-HTTPS production requests. For direct TLS,
no proxy configuration is needed.
If HTTPS terminates at a reverse proxy, add only IP addresses or CIDR ranges
belonging to that proxy:
```ini
API_TRUSTED_PROXIES=127.0.0.1,10.20.0.0/16
```
`X-Forwarded-For` and `X-Forwarded-Proto` are ignored unless the immediate
connection came from a configured trusted proxy. Never add arbitrary public
networks merely to make forwarded headers work.
For local development without HTTPS:
```ini
APP_ENV=development
API_REQUIRE_HTTPS=false
```
### CORS
List the exact websites or browser extensions permitted to call the API:
```ini
API_CORS_ORIGINS=https://wallet.example.com,chrome-extension://extension-id
```
Use `API_CORS_ORIGINS=*` only when intentionally operating a public API for
every website. Requests without an `Origin` header, including normal
server-to-server calls, are unaffected.
### Request Limits
```ini
API_MAX_BODY_BYTES=2000000
API_RATE_WINDOW_SECONDS=60
API_PUBLIC_REQUESTS_PER_WINDOW=120
API_MESSAGE_VERIFY_REQUESTS_PER_WINDOW=30
API_BROADCAST_REQUESTS_PER_WINDOW=10
```
Public callers are limited by resolved client IP. Message verification and
transaction broadcasting have additional route-specific limits. Rate-limit
responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
`X-RateLimit-Reset`, and, when blocked, `Retry-After`.
The default security state is stored in `storage/security-state.json`. Its
directory must be writable by the PHP process and must remain outside the web
document root.
All `POST`, `PUT`, and `PATCH` requests require
`Content-Type: application/json`. Oversized bodies are rejected before parsing.
The API does not write request bodies to its application logs.
### Transaction Broadcasting
Before contacting a node, the broadcast route decodes the first transaction
byte and checks the exact serialized length expected by Contractless.
Unsupported types, malformed lengths, genesis transactions, and node-generated
reward transactions are rejected by the API.
### Optional API Keys
API keys provide identified clients with their own quota:
```ini
API_KEYS=example-service=replace-with-at-least-32-random-characters:300
```
The client sends the ID and secret together:
```text
X-API-Key: example-service.replace-with-at-least-32-random-characters
```
Multiple entries are comma-separated. API keys require HTTPS but do not provide
request signing or replay protection.
### Optional HMAC Authentication
HMAC authentication is intended for trusted server-to-server applications:
```ini
API_HMAC_KEYS=backend=replace-with-at-least-32-random-characters:600
API_HMAC_CLOCK_SKEW_SECONDS=300
```
The caller sends:
```text
X-API-Key: backend
X-Timestamp: current Unix timestamp
X-Nonce: unique random value containing 16 to 128 letters, numbers, _ or -
X-Signature: lowercase hexadecimal HMAC-SHA256 signature
```
The signed canonical string is:
```text
HTTP_METHOD
/request/path
raw=query&string
unix_timestamp
nonce
sha256_hex_of_exact_request_body
```
The signature is:
```php
$bodyHash = hash('sha256', $exactRequestBody);
$canonical = implode("\n", [
strtoupper($method),
$path,
$rawQueryString,
(string) $timestamp,
$nonce,
$bodyHash,
]);
$signature = hash_hmac('sha256', $canonical, $hmacSecret);
```
Timestamps outside the configured clock window are rejected. Each valid nonce
is recorded until its timestamp can no longer be accepted, preventing replay.
The browser wallet uses public rate-limited access. It must never contain an
API-key secret or HMAC secret.
## Remaining RPC Endpoints
### Chain, Blocks, Headers, And Torrents
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/chain/difficulty` | Current height and next-block difficulty |
| `GET` | `/api/v1/chain/largest-transaction-fee` | Largest eligible mempool fee |
| `GET` | `/api/v1/chain/transaction-counts` | Confirmed totals by transaction type |
| `GET` | `/api/v1/blocks/latest` | Latest raw block |
| `GET` | `/api/v1/blocks/by-height?height=...` | Raw block at a height |
| `GET` | `/api/v1/blocks/by-hash?hash=...` | Raw block matching a hash |
| `GET` | `/api/v1/blocks/hash?height=...` | Block hash at a height |
| `GET` | `/api/v1/headers/by-height?height=...` | Raw header at a height |
| `GET` | `/api/v1/headers/by-hash?hash=...` | Raw header matching a hash |
| `GET` | `/api/v1/headers/all` | Complete raw header history |
| `GET` | `/api/v1/torrents/by-height?height=...` | Raw torrent metadata |
Raw blocks, headers, and torrents are returned as `hex` plus their original
byte count. The API does not alter the blockchain artifact.
Large routes have a separate quota:
```ini
API_EXPENSIVE_REQUESTS_PER_WINDOW=5
```
Operators can disable costly raw routes independently:
```ini
API_ENABLE_RAW_BLOCKS=true
API_ENABLE_TORRENTS=true
API_ENABLE_ALL_HEADERS=false
```
Complete header-history downloads are disabled by default.
### Mempool
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/mempool/count` | Number of pending transactions |
| `GET` | `/api/v1/mempool/by-signature?signature=...` | Pending transaction by signature |
| `GET` | `/api/v1/mempool/by-address?address=...` | Pending transactions involving an address |
Pending transactions are returned as their exact signed transaction hex,
original byte count, and transaction type.
### Tokens, NFTs, And RWAs
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/tokens` | Token list and origin transaction IDs |
| `GET` | `/api/v1/tokens/catalog` | Token catalog |
| `GET` | `/api/v1/tokens/details?name=...` | Details for one token |
| `GET` | `/api/v1/nfts` | NFT/RWA list, ownership type, and supply |
| `GET` | `/api/v1/nfts/details?name=...&series=...` | Details for one NFT/RWA item |
### Loans And Marketing
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/loans/by-hash?hash=...` | Loan by contract hash |
| `GET` | `/api/v1/loans/by-address?address=...` | Loans involving an address |
| `GET` | `/api/v1/loans/collateral?hash=...` | Loan collateral status |
| `GET` | `/api/v1/marketing/history?advertiser=...&campaign=...&skip=0&limit=100` | Campaign records |
### Additional Address Routes
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/addresses/latest?address=...&limit=25` | Newest confirmed and pending activity |
| `GET` | `/api/v1/addresses/vanity?address=...` | Vanity address registered to a canonical wallet |
### Storage
Get a storage lookup quote:
```text
GET /api/v1/storage/cost?storage_key=...&data_key=all&address=...
```
Along with the byte count, price, and payment address, the quote returns
`rpc_endpoint`. This opaque configured endpoint ID must be submitted with the
paid lookup:
```json
{
"storage_key": "64-character-storage-key-hash",
"data_key": "all",
"address": "40-character-address.cltc",
"rpc_endpoint": "node-1",
"payment_transaction_hex": "complete-signed-type-2-transfer"
}
```
Send that JSON to:
```text
POST /api/v1/storage/lookup
```
The API uses the same node that issued the quote. It intentionally does not
fail over this request because another node may use a different payment
address or lookup price.
### Governance
```text
GET /api/v1/governance/proposals?proposal_key=...
```
This returns the node's proposal, vote, implementation, and activation state as
JSON.
The public API does not currently expose wallet-registration transaction
creation. A browser wallet must create and sign registration data locally
before an appropriate broadcast path can submit it. The API never registers a
wallet using credentials owned by the API operator.

45
api.env.example Normal file
View File

@ -0,0 +1,45 @@
# Copy this file to api.env and keep api.env outside the public directory.
APP_ENV=production
# Give every endpoint a unique name. Separate endpoints with commas.
CONTRACTLESS_RPC_NODES=node-1=127.0.0.1:50050,node-2=192.0.2.10:50050
CONTRACTLESS_RPC_TIMEOUT=10
# An endpoint enters a temporary cooldown after this many consecutive
# connection or transport failures. Protocol and validation failures do not
# count because retrying them against another node could conceal bad data.
RPC_FAILURE_THRESHOLD=2
RPC_COOLDOWN_SECONDS=30
# Production requests must use HTTPS. When TLS terminates at a reverse proxy,
# list only that proxy's IP or CIDR below so X-Forwarded-* can be trusted.
API_REQUIRE_HTTPS=true
API_TRUSTED_PROXIES=
# Comma-separated exact browser origins. Use * only for a deliberately public
# API. Browser-extension origins are supported.
API_CORS_ORIGINS=https://wallet.example.com,chrome-extension://extension-id
# This directory must be writable by PHP and must remain outside public/.
API_SECURITY_STATE_PATH=storage/security-state.json
API_MAX_BODY_BYTES=2000000
# Public browser-wallet limits use the caller's resolved IP address.
API_RATE_WINDOW_SECONDS=60
API_PUBLIC_REQUESTS_PER_WINDOW=120
API_MESSAGE_VERIFY_REQUESTS_PER_WINDOW=30
API_BROADCAST_REQUESTS_PER_WINDOW=10
API_EXPENSIVE_REQUESTS_PER_WINDOW=5
# Large raw-response routes can be disabled independently.
API_ENABLE_RAW_BLOCKS=true
API_ENABLE_TORRENTS=true
API_ENABLE_ALL_HEADERS=false
# Optional identified clients use client-id=secret:requests-per-window.
# Secrets must contain at least 32 characters. Leave these settings empty when
# not needed. Never embed either kind of secret in a browser wallet.
API_KEYS=
API_HMAC_KEYS=
API_HMAC_CLOCK_SKEW_SECONDS=300

16
composer.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "contractless/contractless-php-api",
"description": "HTTP API for the Contractless blockchain RPC network.",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-json": "*",
"contractless/contractless-php-rpc": "^1.0.0"
},
"autoload": {
"psr-4": {
"Contractless\\Api\\": "src/"
}
}
}

49
composer.lock generated Normal file
View File

@ -0,0 +1,49 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "483ea3aaa2a4cdad5b7954f193cb8003",
"packages": [
{
"name": "contractless/contractless-php-rpc",
"version": "v1.0.0",
"source": {
"type": "git",
"url": "https://contractless.dev/contractless/Contractless-PHP-RPC.git",
"reference": "2644759ce958a39627e04522ed6e4a6947654534"
},
"require": {
"ext-json": "*",
"ext-oqsphp": "*",
"ext-skein": "*",
"php": ">=8.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Contractless\\Rpc\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Native PHP client for the Contractless blockchain RPC protocol.",
"time": "2026-07-27T20:24:58+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.1",
"ext-json": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

216
public/index.php Normal file
View File

@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
use Contractless\Api\ApplicationFactory;
use Contractless\Api\Http\ExceptionResponder;
use Contractless\Api\Http\JsonResponse;
use Contractless\Api\Http\HttpException;
use Contractless\Api\Http\Request;
use Contractless\Api\Http\RequestContext;
use Contractless\Api\Http\Router;
use Contractless\Api\Rpc\RpcReplyDecoder;
use Contractless\Api\Rpc\RequestCredentials;
use Contractless\Api\Routes\RemainingRoutes;
use Contractless\Api\Security\SecurityMiddleware;
use Contractless\Api\Security\TransactionPolicy;
use Contractless\Rpc\Crypto\NativeCrypto;
$projectRoot = dirname(__DIR__);
$autoload = $projectRoot . '/vendor/autoload.php';
if (!is_file($autoload)) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo '{"success":false,"error":"API dependencies are not installed."}';
exit;
}
require $autoload;
RequestContext::initialize();
try {
$config = ApplicationFactory::configuration($projectRoot);
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$path = is_string($path) ? '/' . trim($path, '/') : '/';
if ($path === '/') {
$path = '/';
}
// General HTTP security runs before wallet authentication or RPC creation.
(new SecurityMiddleware($config))->handle($method, $path);
if ($method === 'GET' && $path === '/api/v1/live') {
JsonResponse::success(['alive' => true]);
}
$requestCredentials = RequestCredentials::fromHeaders(new NativeCrypto());
$application = ApplicationFactory::createFromConfig(
$config,
$requestCredentials->handshakeProof,
);
$router = new Router();
$router->get('/api/v1', static function () use ($application): never {
JsonResponse::success([
'name' => 'Contractless PHP API',
'version' => 'v1',
'rpc_endpoints' => count($application->transport->endpointIds()),
]);
});
$router->get('/api/v1/health', static function () use ($application): never {
$height = RpcReplyDecoder::unsigned32(
$application->client->blockHeight(),
'block height',
);
JsonResponse::success(['online' => true, 'height' => $height]);
});
$router->get('/api/v1/ready', static function () use ($application): never {
$height = RpcReplyDecoder::unsigned32(
$application->client->blockHeight(),
'block height',
);
JsonResponse::success([
'ready' => true,
'height' => $height,
'rpc_endpoints' => $application->endpointHealth->status(
$application->transport->endpointIds(),
),
]);
});
$router->get('/api/v1/network', static function () use ($application): never {
JsonResponse::success(
RpcReplyDecoder::networkInfo($application->client->networkInfo()),
);
});
$router->get('/api/v1/height', static function () use ($application): never {
JsonResponse::success([
'height' => RpcReplyDecoder::unsigned32(
$application->client->blockHeight(),
'block height',
),
]);
});
$router->get('/api/v1/time', static function () use ($application): never {
JsonResponse::success([
'timestamp' => RpcReplyDecoder::unsigned32(
$application->client->nodeTime(),
'node time',
),
]);
});
$router->get('/api/v1/addresses/validate', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'valid' => RpcReplyDecoder::validStatus(
$application->client->validateAddress($address),
),
]);
});
$router->get('/api/v1/addresses/vanity/resolve', static function () use ($application): never {
$address = strtolower(Request::queryString('address', 25));
if (preg_match('/^([a-z]{1,20})\.(clc|cltc)$/', $address, $matches) !== 1) {
throw new HttpException(422, 'Enter a valid Contractless vanity address.');
}
$fixedWidthAddress = str_pad($matches[1], 20, ' ', STR_PAD_LEFT)
. '.'
. $matches[2];
JsonResponse::success([
'vanity_address' => $address,
'owner_address' => RpcReplyDecoder::optionalText(
$application->client->vanityOwner($fixedWidthAddress),
),
]);
});
$router->get('/api/v1/addresses/registration', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'registered' => RpcReplyDecoder::registrationStatus(
$application->client->walletRegistrationStatus($address),
),
]);
});
$router->get('/api/v1/balances/base', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
$coin = strtoupper(Request::queryString('coin', 15));
$expectedCoin = str_ends_with($address, '.cltc') ? 'CLTC' : 'CLC';
if ($coin !== $expectedCoin) {
throw new HttpException(
422,
"Base coin must be $expectedCoin for this address.",
);
}
JsonResponse::success([
'address' => $address,
'coin' => $coin,
'balance' => RpcReplyDecoder::balance(
$application->client->coinBalance($coin, $address),
),
]);
});
$router->get('/api/v1/balances', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'balances' => RpcReplyDecoder::balances(
$application->client->totalBalance($address),
),
]);
});
$router->get('/api/v1/transactions/lookup', static function () use ($application): never {
$txid = Request::hash(Request::queryString('txid', 64), 'txid');
JsonResponse::success([
'txid' => $txid,
] + RpcReplyDecoder::transaction(
$application->client->transactionById($txid),
));
});
$router->get('/api/v1/addresses/history', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
$skip = Request::queryInt('skip', 0, 0, 4_294_967_295);
$limit = Request::queryInt('limit', 100, 1, 1_000);
JsonResponse::success([
'address' => $address,
'skip' => $skip,
'limit' => $limit,
'transactions' => RpcReplyDecoder::addressHistory(
$application->client->addressHistory($address, $skip, $limit),
),
]);
});
$router->post('/api/v1/messages/verify', static function () use ($application): never {
$body = Request::json();
// Message bytes must remain exactly as signed, including surrounding whitespace.
$message = Request::bodyString($body, 'message', 65_535, trim: false);
$address = Request::canonicalAddress(
Request::bodyString($body, 'address', 45),
);
$signature = Request::signature(
Request::bodyString($body, 'signature', 1_332),
);
JsonResponse::success([
'valid' => RpcReplyDecoder::validStatus(
$application->client->validateMessage($message, $address, $signature),
),
]);
});
$router->post('/api/v1/transactions/broadcast', static function () use ($application): never {
$body = Request::json();
$transaction = Request::transactionHex(
Request::bodyString($body, 'transaction_hex', 1_900_000),
);
$transactionType = TransactionPolicy::validate($transaction);
JsonResponse::success(
['transaction_type' => $transactionType] + RpcReplyDecoder::broadcast(
$application->client->submitTransaction($transaction),
),
);
});
RemainingRoutes::register($router, $application);
$router->dispatch($method, $path);
} catch (Throwable $error) {
ExceptionResponder::respond($error);
}

32
src/Application.php Normal file
View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Contractless\Api;
use Contractless\Api\Config\ApiConfig;
use Contractless\Api\Rpc\EndpointHealthTracker;
use Contractless\Rpc\Client;
use Contractless\Rpc\Transport\EndpointPoolTransport;
use InvalidArgumentException;
final class Application
{
public function __construct(
public readonly ApiConfig $config,
public readonly EndpointPoolTransport $transport,
public readonly Client $client,
public readonly EndpointHealthTracker $endpointHealth,
/** @var array<string, Client> */
private readonly array $endpointClients,
) {
}
public function clientForEndpoint(string $endpointId): Client
{
if (!isset($this->endpointClients[$endpointId])) {
throw new InvalidArgumentException('Unknown RPC endpoint.');
}
return $this->endpointClients[$endpointId];
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Contractless\Api;
use Contractless\Api\Config\ApiConfig;
use Contractless\Api\Config\Environment;
use Contractless\Api\Rpc\EndpointHealthTracker;
use Contractless\Api\Rpc\TrackedEndpointTransport;
use Contractless\Api\Security\SecurityStateStore;
use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Protocol\HandshakeProof;
use Contractless\Rpc\Transport\EndpointPoolTransport;
use Contractless\Rpc\Transport\StreamTransport;
final class ApplicationFactory
{
public static function configuration(string $projectRoot): ApiConfig
{
return ApiConfig::fromEnvironment(
Environment::load($projectRoot . DIRECTORY_SEPARATOR . 'api.env'),
);
}
public static function create(
string $projectRoot,
HandshakeProof $handshakeProof,
): Application
{
return self::createFromConfig(
self::configuration($projectRoot),
$handshakeProof,
);
}
public static function createFromConfig(
ApiConfig $config,
HandshakeProof $handshakeProof,
): Application
{
$crypto = new NativeCrypto();
$endpointHealth = new EndpointHealthTracker(
$config->reliability,
new SecurityStateStore($config->security->statePath),
);
$transports = [];
$endpointClients = [];
foreach ($config->rpcNodes as $name => $node) {
$transport = new StreamTransport(
host: $node['host'],
port: $node['port'],
timeout: $config->rpcTimeout,
);
$transports[$name] = $transport;
$endpointClients[$name] = new Client(
new TrackedEndpointTransport($name, $transport, $endpointHealth),
$crypto,
$handshakeProof,
);
}
$pool = new EndpointPoolTransport($transports, true, $endpointHealth);
return new Application(
$config,
$pool,
new Client($pool, $crypto, $handshakeProof),
$endpointHealth,
$endpointClients,
);
}
}

89
src/Config/ApiConfig.php Normal file
View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Config;
use RuntimeException;
final class ApiConfig
{
/**
* @param array<string, array{host: string, port: int}> $rpcNodes
*/
private function __construct(
public readonly string $environment,
public readonly array $rpcNodes,
public readonly float $rpcTimeout,
public readonly SecurityConfig $security,
public readonly ReliabilityConfig $reliability,
) {
}
public static function fromEnvironment(Environment $environment): self
{
return new self(
$environment->string('APP_ENV', 'production'),
self::parseNodes($environment->required('CONTRACTLESS_RPC_NODES')),
$environment->float('CONTRACTLESS_RPC_TIMEOUT', 10.0),
SecurityConfig::fromEnvironment($environment, dirname(__DIR__, 2)),
ReliabilityConfig::fromEnvironment($environment),
);
}
/**
* @return array<string, array{host: string, port: int}>
*/
private static function parseNodes(string $nodes): array
{
$parsed = [];
foreach (explode(',', $nodes) as $entry) {
$entry = trim($entry);
if ($entry === '') {
continue;
}
$separator = strpos($entry, '=');
if ($separator === false) {
throw new RuntimeException(
'Every RPC node must use the format name=host:port.',
);
}
$name = trim(substr($entry, 0, $separator));
$endpoint = trim(substr($entry, $separator + 1));
$lastColon = strrpos($endpoint, ':');
if ($name === '' || $lastColon === false) {
throw new RuntimeException(
'Every RPC node must use the format name=host:port.',
);
}
$host = trim(substr($endpoint, 0, $lastColon));
$portText = trim(substr($endpoint, $lastColon + 1));
if (
preg_match('/^[A-Za-z0-9.-]+$/', $host) !== 1
|| filter_var($portText, FILTER_VALIDATE_INT) === false
) {
throw new RuntimeException("RPC node $name has an invalid host or port.");
}
$port = (int) $portText;
if ($port < 1 || $port > 65535) {
throw new RuntimeException("RPC node $name has an invalid port.");
}
if (isset($parsed[$name])) {
throw new RuntimeException("RPC node name $name is duplicated.");
}
$parsed[$name] = ['host' => $host, 'port' => $port];
}
if ($parsed === []) {
throw new RuntimeException('At least one Contractless RPC node is required.');
}
return $parsed;
}
}

100
src/Config/Environment.php Normal file
View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Config;
use RuntimeException;
final class Environment
{
/** @var array<string, string> */
private array $values;
private function __construct(array $values)
{
$this->values = $values;
}
public static function load(string $path): self
{
if (!is_file($path) || !is_readable($path)) {
throw new RuntimeException('The API environment file is not readable.');
}
$values = parse_ini_file($path, false, INI_SCANNER_RAW);
if ($values === false) {
throw new RuntimeException('The API environment file could not be parsed.');
}
$normalized = [];
foreach ($values as $name => $value) {
if (!is_string($value)) {
throw new RuntimeException("API setting $name must be a string.");
}
$normalized[$name] = trim($value);
}
return new self($normalized);
}
public function required(string $name): string
{
$value = $this->values[$name] ?? '';
if ($value === '') {
throw new RuntimeException("Required API setting $name is missing.");
}
return $value;
}
public function string(string $name, string $default): string
{
return $this->values[$name] ?? $default;
}
public function float(string $name, float $default): float
{
$value = $this->values[$name] ?? '';
if ($value === '') {
return $default;
}
if (!is_numeric($value) || (float) $value <= 0) {
throw new RuntimeException("API setting $name must be greater than zero.");
}
return (float) $value;
}
public function int(string $name, int $default, int $minimum, int $maximum): int
{
$value = $this->values[$name] ?? '';
if ($value === '') {
return $default;
}
if (filter_var($value, FILTER_VALIDATE_INT) === false) {
throw new RuntimeException("API setting $name must be an integer.");
}
$integer = (int) $value;
if ($integer < $minimum || $integer > $maximum) {
throw new RuntimeException(
"API setting $name must be between $minimum and $maximum.",
);
}
return $integer;
}
public function bool(string $name, bool $default): bool
{
$value = strtolower($this->values[$name] ?? '');
if ($value === '') {
return $default;
}
return match ($value) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => throw new RuntimeException(
"API setting $name must be true or false.",
),
};
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Config;
final class ReliabilityConfig
{
private function __construct(
public readonly int $failureThreshold,
public readonly int $cooldownSeconds,
) {
}
public static function fromEnvironment(Environment $environment): self
{
return new self(
$environment->int('RPC_FAILURE_THRESHOLD', 2, 1, 100),
$environment->int('RPC_COOLDOWN_SECONDS', 30, 1, 3_600),
);
}
}

View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Config;
use RuntimeException;
final class SecurityConfig
{
/**
* @param list<string> $trustedProxies
* @param list<string> $corsOrigins
* @param array<string, array{secret: string, quota: int}> $apiKeys
* @param array<string, array{secret: string, quota: int}> $hmacKeys
*/
private function __construct(
public readonly bool $requireHttps,
public readonly array $trustedProxies,
public readonly array $corsOrigins,
public readonly string $statePath,
public readonly int $maximumBodyBytes,
public readonly int $rateWindowSeconds,
public readonly int $publicQuota,
public readonly int $messageVerificationQuota,
public readonly int $broadcastQuota,
public readonly int $expensiveRouteQuota,
public readonly bool $enableRawBlocks,
public readonly bool $enableTorrents,
public readonly bool $enableAllHeaders,
public readonly array $apiKeys,
public readonly array $hmacKeys,
public readonly int $hmacClockSkewSeconds,
) {
}
public static function fromEnvironment(
Environment $environment,
string $projectRoot,
): self {
return new self(
$environment->bool('API_REQUIRE_HTTPS', true),
self::proxies($environment->string('API_TRUSTED_PROXIES', '')),
self::origins($environment->string('API_CORS_ORIGINS', '')),
self::statePath(
$environment->string(
'API_SECURITY_STATE_PATH',
$projectRoot . DIRECTORY_SEPARATOR . 'storage'
. DIRECTORY_SEPARATOR . 'security-state.json',
),
$projectRoot,
),
$environment->int('API_MAX_BODY_BYTES', 2_000_000, 1_024, 67_108_864),
$environment->int('API_RATE_WINDOW_SECONDS', 60, 1, 86_400),
$environment->int('API_PUBLIC_REQUESTS_PER_WINDOW', 120, 1, 1_000_000),
$environment->int('API_MESSAGE_VERIFY_REQUESTS_PER_WINDOW', 30, 1, 1_000_000),
$environment->int('API_BROADCAST_REQUESTS_PER_WINDOW', 10, 1, 1_000_000),
$environment->int('API_EXPENSIVE_REQUESTS_PER_WINDOW', 5, 1, 1_000_000),
$environment->bool('API_ENABLE_RAW_BLOCKS', true),
$environment->bool('API_ENABLE_TORRENTS', true),
$environment->bool('API_ENABLE_ALL_HEADERS', false),
self::credentials($environment->string('API_KEYS', ''), 'API_KEYS'),
self::credentials($environment->string('API_HMAC_KEYS', ''), 'API_HMAC_KEYS'),
$environment->int('API_HMAC_CLOCK_SKEW_SECONDS', 300, 30, 3_600),
);
}
/** @return list<string> */
private static function list(string $value): array
{
if (trim($value) === '') {
return [];
}
return array_values(array_filter(
array_map('trim', explode(',', $value)),
static fn(string $entry): bool => $entry !== '',
));
}
/** @return list<string> */
private static function origins(string $value): array
{
$origins = self::list($value);
foreach ($origins as $origin) {
if ($origin === '*') {
continue;
}
if (
!str_starts_with($origin, 'https://')
&& !str_starts_with($origin, 'http://')
&& !str_starts_with($origin, 'chrome-extension://')
&& !str_starts_with($origin, 'moz-extension://')
) {
throw new RuntimeException('API_CORS_ORIGINS contains an invalid origin.');
}
if (
strlen($origin) > 2_048
|| preg_match('/[\x00-\x20\x7f]/', $origin) === 1
) {
throw new RuntimeException('API_CORS_ORIGINS contains an invalid origin.');
}
}
return array_values(array_unique($origins));
}
/** @return list<string> */
private static function proxies(string $value): array
{
$proxies = self::list($value);
foreach ($proxies as $proxy) {
[$address, $prefix] = array_pad(explode('/', $proxy, 2), 2, null);
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
throw new RuntimeException('API_TRUSTED_PROXIES contains an invalid address.');
}
if ($prefix === null) {
continue;
}
$maximum = str_contains($address, ':') ? 128 : 32;
if (
filter_var($prefix, FILTER_VALIDATE_INT) === false
|| (int) $prefix < 0
|| (int) $prefix > $maximum
) {
throw new RuntimeException('API_TRUSTED_PROXIES contains an invalid CIDR.');
}
}
return array_values(array_unique($proxies));
}
/**
* Format: client-id=secret:requests-per-window
*
* @return array<string, array{secret: string, quota: int}>
*/
private static function credentials(string $value, string $setting): array
{
if (trim($value) === '') {
return [];
}
$credentials = [];
foreach (explode(',', $value) as $entry) {
$entry = trim($entry);
$equals = strpos($entry, '=');
$colon = strrpos($entry, ':');
if ($equals === false || $colon === false || $colon <= $equals) {
throw new RuntimeException(
"$setting must use client-id=secret:quota entries.",
);
}
$id = trim(substr($entry, 0, $equals));
$secret = substr($entry, $equals + 1, $colon - $equals - 1);
$quotaText = trim(substr($entry, $colon + 1));
if (
preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id) !== 1
|| strlen($secret) < 32
|| filter_var($quotaText, FILTER_VALIDATE_INT) === false
|| (int) $quotaText < 1
) {
throw new RuntimeException("$setting contains an invalid client entry.");
}
if (isset($credentials[$id])) {
throw new RuntimeException("$setting contains a duplicate client ID.");
}
$credentials[$id] = ['secret' => $secret, 'quota' => (int) $quotaText];
}
return $credentials;
}
private static function statePath(string $path, string $projectRoot): string
{
$path = trim($path);
if ($path === '') {
throw new RuntimeException('API_SECURITY_STATE_PATH cannot be empty.');
}
if (!self::isAbsolutePath($path)) {
$path = $projectRoot . DIRECTORY_SEPARATOR . $path;
}
return $path;
}
private static function isAbsolutePath(string $path): bool
{
return str_starts_with($path, '/')
|| preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
use Contractless\Api\Logging\SafeLogger;
use Contractless\Api\Rpc\UpstreamResponseException;
use Contractless\Api\Rpc\UpstreamRejectedException;
use Contractless\Rpc\Exception\ProtocolException;
use Contractless\Rpc\Exception\TransportException;
use Throwable;
final class ExceptionResponder
{
public static function respond(Throwable $error): never
{
if ($error instanceof HttpException) {
JsonResponse::error($error->getMessage(), $error->status);
}
SafeLogger::exception($error, RequestContext::requestId());
if ($error instanceof UpstreamRejectedException) {
JsonResponse::error('The Contractless node rejected the request.', 422);
}
if ($error instanceof TransportException) {
header('Retry-After: 5');
JsonResponse::error(
'No Contractless RPC node is currently available.',
503,
);
}
if (
$error instanceof ProtocolException
|| $error instanceof UpstreamResponseException
) {
JsonResponse::error(
'A Contractless RPC node returned an invalid response.',
502,
);
}
JsonResponse::error('The API could not process the request.', 500);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
use RuntimeException;
final class HttpException extends RuntimeException
{
public function __construct(
public readonly int $status,
string $message,
) {
parent::__construct($message);
}
}

35
src/Http/JsonResponse.php Normal file
View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
final class JsonResponse
{
/** @param array<string, mixed> $data */
public static function success(array $data = [], int $status = 200): never
{
self::send(['success' => true, 'data' => $data], $status);
}
public static function error(string $message, int $status): never
{
self::send(['success' => false, 'error' => $message], $status);
}
/** @param array<string, mixed> $payload */
private static function send(array $payload, int $status): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$json = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);
echo $json;
exit;
}
}

189
src/Http/Request.php Normal file
View File

@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
final class Request
{
private static int $maximumBodyBytes = 2_000_000;
private static ?string $rawBody = null;
public static function setMaximumBodyBytes(int $bytes): void
{
self::$maximumBodyBytes = $bytes;
}
public static function rawBody(): string
{
if (self::$rawBody !== null) {
return self::$rawBody;
}
$raw = file_get_contents('php://input');
if ($raw === false) {
throw new HttpException(400, 'The request body could not be read.');
}
if (strlen($raw) > self::$maximumBodyBytes) {
throw new HttpException(413, 'The request body is too large.');
}
self::$rawBody = $raw;
return $raw;
}
public static function queryString(string $name, int $maxBytes = 4096): string
{
$value = $_GET[$name] ?? null;
if (!is_string($value)) {
throw new HttpException(422, "Query parameter $name is required.");
}
return self::validatedString($value, $name, $maxBytes);
}
public static function queryInt(
string $name,
int $default,
int $minimum,
int $maximum,
): int {
$value = $_GET[$name] ?? null;
if ($value === null || $value === '') {
return $default;
}
if (
!is_string($value)
|| filter_var($value, FILTER_VALIDATE_INT) === false
) {
throw new HttpException(422, "Query parameter $name must be an integer.");
}
$integer = (int) $value;
if ($integer < $minimum || $integer > $maximum) {
throw new HttpException(
422,
"Query parameter $name must be between $minimum and $maximum.",
);
}
return $integer;
}
public static function requiredQueryInt(
string $name,
int $minimum,
int $maximum,
): int {
$value = $_GET[$name] ?? null;
if (!is_string($value) || $value === '') {
throw new HttpException(422, "Query parameter $name is required.");
}
if (filter_var($value, FILTER_VALIDATE_INT) === false) {
throw new HttpException(422, "Query parameter $name must be an integer.");
}
$integer = (int) $value;
if ($integer < $minimum || $integer > $maximum) {
throw new HttpException(
422,
"Query parameter $name must be between $minimum and $maximum.",
);
}
return $integer;
}
public static function canonicalAddress(string $address): string
{
$address = strtolower(trim($address));
if (preg_match('/^[a-f0-9]{40}\.(clc|cltc)$/', $address) !== 1) {
throw new HttpException(422, 'Enter a valid Contractless wallet address.');
}
return $address;
}
public static function hash(string $hash, string $name): string
{
$hash = strtolower(trim($hash));
if (preg_match('/^[a-f0-9]{64}$/', $hash) !== 1) {
throw new HttpException(422, "Field $name must be a 64-character hash.");
}
return $hash;
}
public static function signature(string $signature): string
{
$signature = strtolower(trim($signature));
if (preg_match('/^[a-f0-9]{1332}$/', $signature) !== 1) {
throw new HttpException(
422,
'The signature must contain 1,332 hexadecimal characters.',
);
}
return $signature;
}
public static function transactionHex(string $transaction): string
{
$transaction = strtolower(trim($transaction));
if (
$transaction === ''
|| (strlen($transaction) % 2) !== 0
|| !ctype_xdigit($transaction)
) {
throw new HttpException(
422,
'The transaction must be complete hexadecimal data.',
);
}
return $transaction;
}
/** @return array<string, mixed> */
public static function json(): array
{
$contentType = strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0]));
if ($contentType !== 'application/json') {
throw new HttpException(415, 'Content-Type must be application/json.');
}
$raw = self::rawBody();
try {
$body = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new HttpException(400, 'The request body is not valid JSON.');
}
if (!is_array($body)) {
throw new HttpException(400, 'The request body must be a JSON object.');
}
return $body;
}
/** @param array<string, mixed> $body */
public static function bodyString(
array $body,
string $name,
int $maxBytes = 4096,
bool $trim = true,
): string {
$value = $body[$name] ?? null;
if (!is_string($value)) {
throw new HttpException(422, "JSON field $name is required.");
}
return self::validatedString($value, $name, $maxBytes, $trim);
}
private static function validatedString(
string $value,
string $name,
int $maxBytes,
bool $trim = true,
): string {
if ($trim) {
$value = trim($value);
}
if ($value === '') {
throw new HttpException(422, "Field $name cannot be empty.");
}
if (strlen($value) > $maxBytes) {
throw new HttpException(422, "Field $name is too long.");
}
return $value;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
final class RequestContext
{
private static string $requestId = '';
public static function initialize(): string
{
$incoming = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''));
self::$requestId = preg_match('/^[A-Za-z0-9_-]{8,64}$/', $incoming) === 1
? $incoming
: bin2hex(random_bytes(12));
header('X-Request-ID: ' . self::$requestId);
return self::$requestId;
}
public static function requestId(): string
{
return self::$requestId !== '' ? self::$requestId : 'unavailable';
}
}

41
src/Http/Router.php Normal file
View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
use Closure;
final class Router
{
/** @var array<string, array<string, Closure(): never>> */
private array $routes = [];
public function get(string $path, Closure $handler): void
{
$this->routes[$path]['GET'] = $handler;
}
public function post(string $path, Closure $handler): void
{
$this->routes[$path]['POST'] = $handler;
}
public function dispatch(string $method, string $path): never
{
$path = '/' . trim($path, '/');
if ($path === '/') {
$path = '/';
}
if (!isset($this->routes[$path])) {
JsonResponse::error('Route not found.', 404);
}
if (!isset($this->routes[$path][$method])) {
header('Allow: ' . implode(', ', array_keys($this->routes[$path])));
JsonResponse::error('Method not allowed.', 405);
}
($this->routes[$path][$method])();
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Logging;
use Throwable;
final class SafeLogger
{
public static function exception(Throwable $error, string $requestId): void
{
$event = [
'event' => 'request_failed',
'request_id' => $requestId,
'error_type' => $error::class,
];
error_log(
'[contractless-api] '
. json_encode($event, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
);
}
}

View File

@ -0,0 +1,426 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Routes;
use Contractless\Api\Application;
use Contractless\Api\Http\HttpException;
use Contractless\Api\Http\JsonResponse;
use Contractless\Api\Http\Request;
use Contractless\Api\Http\Router;
use Contractless\Api\Rpc\RpcReplyDecoder;
use Contractless\Api\Security\TransactionPolicy;
final class RemainingRoutes
{
public static function register(Router $router, Application $application): void
{
self::chain($router, $application);
self::blocks($router, $application);
self::mempool($router, $application);
self::assets($router, $application);
self::loans($router, $application);
self::marketing($router, $application);
self::storage($router, $application);
self::governance($router, $application);
self::additionalAddressRoutes($router, $application);
}
private static function chain(Router $router, Application $application): void
{
$router->get('/api/v1/chain/difficulty', static function () use ($application): never {
JsonResponse::success(
RpcReplyDecoder::difficulty($application->client->difficulty()),
);
});
$router->get(
'/api/v1/chain/largest-transaction-fee',
static function () use ($application): never {
$atomic = RpcReplyDecoder::largestFee(
$application->client->largestTransactionFee(),
);
JsonResponse::success([
'fee_atomic' => $atomic,
'fee' => self::decimal($atomic),
]);
},
);
$router->get(
'/api/v1/chain/transaction-counts',
static function () use ($application): never {
JsonResponse::success([
'counts' => RpcReplyDecoder::transactionCounts(
$application->client->totalConfirmedTransactions(),
),
]);
},
);
}
private static function blocks(Router $router, Application $application): void
{
$router->get('/api/v1/blocks/latest', static function () use ($application): never {
self::requireEnabled(
$application->config->security->enableRawBlocks,
'Raw block downloads',
);
JsonResponse::success(
RpcReplyDecoder::raw($application->client->latestBlock(), 'block'),
);
});
$router->get('/api/v1/blocks/by-height', static function () use ($application): never {
self::requireEnabled(
$application->config->security->enableRawBlocks,
'Raw block downloads',
);
$height = Request::requiredQueryInt('height', 0, 4_294_967_295);
JsonResponse::success(
['height' => $height]
+ RpcReplyDecoder::raw(
$application->client->blockByHeight($height),
'block',
),
);
});
$router->get('/api/v1/blocks/by-hash', static function () use ($application): never {
self::requireEnabled(
$application->config->security->enableRawBlocks,
'Raw block downloads',
);
$hash = Request::hash(Request::queryString('hash', 64), 'hash');
JsonResponse::success(
['hash' => $hash]
+ RpcReplyDecoder::raw(
$application->client->blockByHash($hash),
'block',
),
);
});
$router->get('/api/v1/blocks/hash', static function () use ($application): never {
$height = Request::requiredQueryInt('height', 0, 4_294_967_295);
JsonResponse::success([
'height' => $height,
'hash' => RpcReplyDecoder::blockHash(
$application->client->blockHashAtHeight($height),
),
]);
});
$router->get('/api/v1/headers/by-height', static function () use ($application): never {
$height = Request::requiredQueryInt('height', 0, 4_294_967_295);
JsonResponse::success(
['height' => $height]
+ RpcReplyDecoder::raw(
$application->client->headerByHeight($height),
'block header',
),
);
});
$router->get('/api/v1/headers/by-hash', static function () use ($application): never {
$hash = Request::hash(Request::queryString('hash', 64), 'hash');
JsonResponse::success(
['hash' => $hash]
+ RpcReplyDecoder::raw(
$application->client->headerByHash($hash),
'block header',
),
);
});
$router->get('/api/v1/headers/all', static function () use ($application): never {
self::requireEnabled(
$application->config->security->enableAllHeaders,
'All-header downloads',
);
JsonResponse::success(
RpcReplyDecoder::raw(
$application->client->allHeaders(),
'block-header history',
),
);
});
$router->get('/api/v1/torrents/by-height', static function () use ($application): never {
self::requireEnabled(
$application->config->security->enableTorrents,
'Torrent downloads',
);
$height = Request::requiredQueryInt('height', 0, 4_294_967_295);
JsonResponse::success(
['height' => $height]
+ RpcReplyDecoder::raw(
$application->client->torrentByHeight($height),
'torrent',
),
);
});
}
private static function mempool(Router $router, Application $application): void
{
$router->get('/api/v1/mempool/count', static function () use ($application): never {
JsonResponse::success([
'count' => RpcReplyDecoder::unsigned32(
$application->client->mempoolCount(),
'mempool count',
),
]);
});
$router->get(
'/api/v1/mempool/by-signature',
static function () use ($application): never {
$signature = Request::signature(
Request::queryString('signature', 1_332),
);
JsonResponse::success(
RpcReplyDecoder::mempoolTransaction(
$application->client->mempoolTransactionBySignature($signature),
),
);
},
);
$router->get('/api/v1/mempool/by-address', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'transactions' => RpcReplyDecoder::mempoolTransactions(
$application->client->mempoolTransactionsByAddress($address),
),
]);
});
}
private static function assets(Router $router, Application $application): void
{
$router->get('/api/v1/tokens', static function () use ($application): never {
JsonResponse::success([
'tokens' => RpcReplyDecoder::tokenList($application->client->tokenList()),
]);
});
$router->get('/api/v1/tokens/catalog', static function () use ($application): never {
JsonResponse::success(
RpcReplyDecoder::jsonTextOrRaw(
$application->client->tokenCatalog(),
'token catalog',
),
);
});
$router->get('/api/v1/tokens/details', static function () use ($application): never {
$name = self::assetName(Request::queryString('name', 15));
JsonResponse::success(
['name' => $name]
+ RpcReplyDecoder::jsonTextOrRaw(
$application->client->tokenDetails($name),
'token details',
),
);
});
$router->get('/api/v1/nfts', static function () use ($application): never {
JsonResponse::success([
'nfts' => RpcReplyDecoder::nftList($application->client->nftList()),
]);
});
$router->get('/api/v1/nfts/details', static function () use ($application): never {
$name = self::assetName(Request::queryString('name', 15));
$series = Request::queryInt('series', 0, 0, 4_294_967_295);
JsonResponse::success(
['name' => $name, 'series' => $series]
+ RpcReplyDecoder::jsonTextOrRaw(
$application->client->nftDetails($name, $series),
'NFT details',
),
);
});
}
private static function loans(Router $router, Application $application): void
{
$router->get('/api/v1/loans/by-hash', static function () use ($application): never {
$hash = Request::hash(Request::queryString('hash', 64), 'hash');
JsonResponse::success(
['hash' => $hash]
+ RpcReplyDecoder::jsonTextOrRaw(
$application->client->loanByHash($hash),
'loan',
),
);
});
$router->get('/api/v1/loans/by-address', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success(
['address' => $address]
+ RpcReplyDecoder::jsonTextOrRaw(
$application->client->contractsByAddress($address),
'loans',
),
);
});
$router->get('/api/v1/loans/collateral', static function () use ($application): never {
$hash = Request::hash(Request::queryString('hash', 64), 'hash');
JsonResponse::success(
['hash' => $hash]
+ RpcReplyDecoder::jsonTextOrRaw(
$application->client->collateralStatus($hash),
'collateral status',
),
);
});
}
private static function marketing(Router $router, Application $application): void
{
$router->get('/api/v1/marketing/history', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('advertiser', 45));
$campaign = Request::requiredQueryInt('campaign', 0, PHP_INT_MAX);
$skip = Request::queryInt('skip', 0, 0, 4_294_967_295);
$limit = Request::queryInt('limit', 100, 1, 1_000);
JsonResponse::success(
[
'advertiser' => $address,
'campaign' => $campaign,
'skip' => $skip,
'limit' => $limit,
] + RpcReplyDecoder::jsonTextOrRaw(
$application->client->marketingCampaignHistory(
$address,
$campaign,
$skip,
$limit,
),
'marketing history',
),
);
});
}
private static function storage(Router $router, Application $application): void
{
$router->get('/api/v1/storage/cost', static function () use ($application): never {
$storageKey = Request::hash(
Request::queryString('storage_key', 64),
'storage_key',
);
$dataKey = self::dataKey(Request::queryString('data_key', 50));
$address = Request::canonicalAddress(Request::queryString('address', 45));
$quote = RpcReplyDecoder::storageCost(
$application->client->storageLookupCost(
$storageKey,
$dataKey,
$address,
),
);
$endpoint = $application->transport->lastSuccessfulEndpointId();
if ($endpoint === null) {
throw new \RuntimeException('The storage quote endpoint was not recorded.');
}
JsonResponse::success($quote + ['rpc_endpoint' => $endpoint]);
});
$router->post('/api/v1/storage/lookup', static function () use ($application): never {
$body = Request::json();
$storageKey = Request::hash(
Request::bodyString($body, 'storage_key', 64),
'storage_key',
);
$dataKey = self::dataKey(Request::bodyString($body, 'data_key', 50));
$address = Request::canonicalAddress(
Request::bodyString($body, 'address', 45),
);
$endpoint = Request::bodyString($body, 'rpc_endpoint', 64);
if (!in_array($endpoint, $application->transport->endpointIds(), true)) {
throw new HttpException(422, 'The storage quote endpoint is invalid.');
}
$payment = Request::transactionHex(
Request::bodyString($body, 'payment_transaction_hex', 3_000),
);
if (TransactionPolicy::validate($payment) !== 2) {
throw new HttpException(
422,
'Storage lookup payment must be a type 2 transfer transaction.',
);
}
JsonResponse::success(
RpcReplyDecoder::jsonObject(
$application->clientForEndpoint($endpoint)->storageLookup(
$storageKey,
$dataKey,
$address,
$payment,
),
),
);
});
}
private static function governance(Router $router, Application $application): void
{
$router->get('/api/v1/governance/proposals', static function () use ($application): never {
$key = Request::hash(
Request::queryString('proposal_key', 64),
'proposal_key',
);
JsonResponse::success(
RpcReplyDecoder::jsonObject(
$application->client->governanceProposal($key),
),
);
});
}
private static function additionalAddressRoutes(
Router $router,
Application $application,
): void {
$router->get('/api/v1/addresses/latest', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
$limit = Request::queryInt('limit', 25, 1, 1_000);
JsonResponse::success([
'address' => $address,
'limit' => $limit,
'transactions' => RpcReplyDecoder::addressHistory(
$application->client->latestAddressTransactions($address, $limit),
),
]);
});
$router->get('/api/v1/addresses/vanity', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'vanity_address' => RpcReplyDecoder::optionalText(
$application->client->vanityLookup($address),
),
]);
});
}
private static function requireEnabled(bool $enabled, string $feature): void
{
if (!$enabled) {
throw new HttpException(403, "$feature are disabled by this API operator.");
}
}
private static function assetName(string $name): string
{
$name = trim($name);
if ($name === '' || strlen($name) > 15) {
throw new HttpException(422, 'Asset names must contain 1 to 15 bytes.');
}
return $name;
}
private static function dataKey(string $key): string
{
$key = trim($key);
if ($key === '' || strlen($key) > 50) {
throw new HttpException(422, 'Storage data keys must contain 1 to 50 bytes.');
}
return strcasecmp($key, 'all') === 0 ? 'all' : $key;
}
private static function decimal(int $atomic): string
{
return intdiv($atomic, 100_000_000)
. '.'
. str_pad((string) ($atomic % 100_000_000), 8, '0', STR_PAD_LEFT);
}
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Rpc;
use Contractless\Api\Config\ReliabilityConfig;
use Contractless\Api\Security\SecurityStateStore;
use Contractless\Rpc\Exception\TransportException;
use Contractless\Rpc\Transport\EndpointHealthTrackerInterface;
final class EndpointHealthTracker implements EndpointHealthTrackerInterface
{
public function __construct(
private readonly ReliabilityConfig $config,
private readonly SecurityStateStore $store,
) {
}
public function eligibleEndpointIds(array $endpointIds): array
{
$now = time();
return $this->store->read(
static function (array $state) use ($endpointIds, $now): array {
$eligible = [];
foreach ($endpointIds as $endpointId) {
$record = $state['endpoints'][$endpointId] ?? [];
$cooldownUntil = is_array($record)
? (int) ($record['cooldown_until'] ?? 0)
: 0;
if ($cooldownUntil <= $now) {
$eligible[] = $endpointId;
}
}
return $eligible;
},
);
}
public function recordSuccess(string $endpointId): void
{
$now = time();
$this->store->mutate(
static function (array &$state) use ($endpointId, $now): void {
$record = $state['endpoints'][$endpointId] ?? [];
$record = is_array($record) ? $record : [];
$state['endpoints'][$endpointId] = [
'consecutive_failures' => 0,
'cooldown_until' => 0,
'last_success' => $now,
'last_failure' => (int) ($record['last_failure'] ?? 0),
];
},
);
}
public function recordFailure(
string $endpointId,
TransportException $error,
): void {
$now = time();
$threshold = $this->config->failureThreshold;
$cooldown = $this->config->cooldownSeconds;
$this->store->mutate(
static function (array &$state) use (
$endpointId,
$now,
$threshold,
$cooldown,
): void {
$record = $state['endpoints'][$endpointId] ?? [];
$record = is_array($record) ? $record : [];
$failures = (int) ($record['consecutive_failures'] ?? 0) + 1;
$state['endpoints'][$endpointId] = [
'consecutive_failures' => $failures,
'cooldown_until' => $failures >= $threshold
? $now + $cooldown
: 0,
'last_success' => (int) ($record['last_success'] ?? 0),
'last_failure' => $now,
];
},
);
}
/** @param list<string> $endpointIds */
public function status(array $endpointIds): array
{
$now = time();
return $this->store->read(
static function (array $state) use ($endpointIds, $now): array {
$status = [];
foreach ($endpointIds as $endpointId) {
$record = $state['endpoints'][$endpointId] ?? [];
$record = is_array($record) ? $record : [];
$cooldownUntil = (int) ($record['cooldown_until'] ?? 0);
$status[$endpointId] = [
'available' => $cooldownUntil <= $now,
'consecutive_failures' => (int) (
$record['consecutive_failures'] ?? 0
),
'cooldown_until' => $cooldownUntil,
'last_success' => (int) ($record['last_success'] ?? 0),
'last_failure' => (int) ($record['last_failure'] ?? 0),
];
}
return $status;
},
);
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Rpc;
use Contractless\Api\Http\HttpException;
use Contractless\Rpc\Crypto\CryptoInterface;
use Contractless\Rpc\Protocol\HandshakeProof;
final class RequestCredentials
{
private function __construct(
public readonly string $address,
public readonly HandshakeProof $handshakeProof,
) {
}
public static function fromHeaders(CryptoInterface $crypto): self
{
$address = strtolower(trim(
(string) ($_SERVER['HTTP_X_CONTRACTLESS_ADDRESS'] ?? ''),
));
$publicKeyHex = strtolower(trim(
(string) ($_SERVER['HTTP_X_CONTRACTLESS_PUBLIC_KEY'] ?? ''),
));
$signatureHex = strtolower(trim(
(string) ($_SERVER['HTTP_X_CONTRACTLESS_SIGNATURE'] ?? ''),
));
if ($address === '' || $publicKeyHex === '' || $signatureHex === '') {
throw new HttpException(
401,
'Contractless wallet authentication is required.',
);
}
if (preg_match('/^[a-f0-9]{40}\.(clc|cltc)$/', $address, $match) !== 1) {
throw new HttpException(401, 'The Contractless wallet address is invalid.');
}
if (!ctype_xdigit($publicKeyHex) || !ctype_xdigit($signatureHex)) {
throw new HttpException(401, 'Contractless wallet authentication is invalid.');
}
$publicKey = hex2bin($publicKeyHex);
$signature = hex2bin($signatureHex);
if ($publicKey === false || $signature === false) {
throw new HttpException(401, 'Contractless wallet authentication is invalid.');
}
// Wallet files may include a leading network byte. Nodes require the
// raw 897-byte Falcon key in their client handshake.
$networkByte = $match[1] === 'clc' ? 1 : 2;
if (strlen($publicKey) === 898 && ord($publicKey[0]) === $networkByte) {
$publicKey = substr($publicKey, 1);
}
if (strlen($publicKey) !== 897 || strlen($signature) !== 666) {
throw new HttpException(401, 'Contractless wallet authentication is invalid.');
}
if (ord($publicKey[0]) !== 9 || ord($crypto->skein256($publicKey)[0]) !== 239) {
throw new HttpException(401, 'The Contractless public key is invalid.');
}
$derivedAddress = hash('ripemd160', $crypto->skein256($publicKey))
. '.'
. $match[1];
if (!hash_equals($address, $derivedAddress)) {
throw new HttpException(
401,
'The Contractless public key does not belong to this wallet address.',
);
}
$digest = $crypto->skein256('aced');
if (!$crypto->verify($digest, $signature, $publicKey)) {
throw new HttpException(
401,
'The Contractless wallet signature is invalid.',
);
}
return new self(
$address,
new HandshakeProof($publicKey, $signature),
);
}
}

518
src/Rpc/RpcReplyDecoder.php Normal file
View File

@ -0,0 +1,518 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Rpc;
use Contractless\Api\Security\TransactionPolicy;
use Contractless\Api\Rpc\UpstreamResponseException as RuntimeException;
final class RpcReplyDecoder
{
private const ATOMIC_UNITS = 100_000_000;
/** @return array<string, int|string> */
public static function networkInfo(string $reply): array
{
self::notNodeError($reply);
if (strlen($reply) <= 40) {
throw new RuntimeException('The node returned invalid network information.');
}
$prefixLength = strlen($reply) - 40;
$offset = 0;
$version = ord($reply[$offset++]);
$network = trim(substr($reply, $offset, 7));
$offset += 7;
$time = self::u32($reply, $offset);
$walletPrefix = trim(substr($reply, $offset, $prefixLength));
$offset += $prefixLength;
$height = self::u32($reply, $offset);
$difficulty = self::u64($reply, $offset);
$confirmed = self::u32($reply, $offset);
$mempool = self::u32($reply, $offset);
$largestFee = self::u64($reply, $offset);
if ($offset !== strlen($reply)) {
throw new RuntimeException('The node returned invalid network information.');
}
return [
'version' => $version,
'network' => $network,
'time' => $time,
'wallet_prefix' => $walletPrefix,
'height' => $height,
'next_block_difficulty' => $difficulty,
'total_block_transactions' => $confirmed,
'total_mempool_transactions' => $mempool,
'largest_tx_fee_atomic' => $largestFee,
'largest_tx_fee' => self::decimal($largestFee),
];
}
public static function unsigned32(string $reply, string $field): int
{
self::notNodeError($reply);
if (strlen($reply) !== 4) {
throw new RuntimeException("The node returned an invalid $field.");
}
$offset = 0;
return self::u32($reply, $offset);
}
public static function unsigned64(string $reply, string $field): int
{
self::notNodeError($reply);
if (strlen($reply) !== 8) {
throw new RuntimeException("The node returned an invalid $field.");
}
$offset = 0;
return self::u64($reply, $offset);
}
public static function largestFee(string $reply): int
{
self::notNodeError($reply);
if ($reply === "\0\0\0\0") {
return 0;
}
return self::unsigned64($reply, 'largest transaction fee');
}
/** @return array{height: int, difficulty: int} */
public static function difficulty(string $reply): array
{
self::notNodeError($reply);
if (strlen($reply) !== 12) {
throw new RuntimeException('The node returned invalid difficulty data.');
}
$offset = 0;
return [
'height' => self::u32($reply, $offset),
'difficulty' => self::u64($reply, $offset),
];
}
/** @return array{bytes: int, hex: string} */
public static function raw(string $reply, string $field): array
{
self::notNodeError($reply);
if ($reply === '') {
throw new RuntimeException("The node returned an empty $field.");
}
return ['bytes' => strlen($reply), 'hex' => bin2hex($reply)];
}
public static function blockHash(string $reply): string
{
self::notNodeError($reply);
if (strlen($reply) !== 32) {
throw new RuntimeException('The node returned an invalid block hash.');
}
return bin2hex($reply);
}
/**
* @return list<array{transaction_type: int, total: int, non_zero: int}>
*/
public static function transactionCounts(string $reply): array
{
self::notNodeError($reply);
if ((strlen($reply) % 17) !== 0) {
throw new RuntimeException('The node returned invalid transaction counts.');
}
$records = [];
for ($offset = 0; $offset < strlen($reply); $offset += 17) {
$row = $offset + 1;
$records[] = [
'transaction_type' => ord($reply[$offset]),
'total' => self::u64($reply, $row),
'non_zero' => self::u64($reply, $row),
];
}
return $records;
}
/**
* @return array{
* found: bool,
* transaction_type?: int,
* bytes?: int,
* transaction_hex?: string
* }
*/
public static function mempoolTransaction(string $reply): array
{
self::notNodeError($reply);
if ($reply === '') {
return ['found' => false];
}
return [
'found' => true,
'transaction_type' => ord($reply[0]),
'bytes' => strlen($reply),
'transaction_hex' => bin2hex($reply),
];
}
/**
* @return list<array{transaction_type: int, bytes: int, transaction_hex: string}>
*/
public static function mempoolTransactions(string $reply): array
{
self::notNodeError($reply);
$offset = 0;
$transactions = [];
while ($offset < strlen($reply)) {
$type = ord($reply[$offset]);
$length = TransactionPolicy::expectedLength($type);
if ($length === null || $length > strlen($reply) - $offset) {
throw new RuntimeException('The node returned invalid mempool transactions.');
}
$transaction = substr($reply, $offset, $length);
$offset += $length;
$transactions[] = [
'transaction_type' => $type,
'bytes' => $length,
'transaction_hex' => bin2hex($transaction),
];
}
return $transactions;
}
/** @return list<array{token: string, origin_txid: string}> */
public static function tokenList(string $reply): array
{
self::notNodeError($reply);
if ((strlen($reply) % 79) !== 0) {
throw new RuntimeException('The node returned an invalid token list.');
}
$tokens = [];
for ($offset = 0; $offset < strlen($reply); $offset += 79) {
$tokens[] = [
'token' => rtrim(substr($reply, $offset, 15), "\0 "),
'origin_txid' => trim(substr($reply, $offset + 15, 64)),
];
}
return $tokens;
}
/**
* @return list<array{
* origin_txid: string,
* name: string,
* series: int,
* ownership_type: int,
* supply_atomic: int,
* supply: string
* }>
*/
public static function nftList(string $reply): array
{
self::notNodeError($reply);
if ((strlen($reply) % 60) !== 0) {
throw new RuntimeException('The node returned an invalid NFT list.');
}
$nfts = [];
for ($offset = 0; $offset < strlen($reply); $offset += 60) {
$row = $offset + 47;
$series = self::u32($reply, $row);
$ownershipType = ord($reply[$row++]);
$supply = self::u64($reply, $row);
$nfts[] = [
'origin_txid' => bin2hex(substr($reply, $offset, 32)),
'name' => rtrim(substr($reply, $offset + 32, 15), "\0 "),
'series' => $series,
'ownership_type' => $ownershipType,
'supply_atomic' => $supply,
'supply' => self::decimal($supply),
];
}
return $nfts;
}
/**
* @return array{
* total_bytes: int,
* cost_per_byte_atomic: int,
* cost_per_byte: string,
* total_cost_atomic: int,
* total_cost: string,
* payment_address: string
* }
*/
public static function storageCost(string $reply): array
{
self::notNodeError($reply);
if (strlen($reply) !== 42) {
throw new RuntimeException('The node returned an invalid storage quote.');
}
$offset = 0;
$totalBytes = self::u32($reply, $offset);
$costPerByte = self::u64($reply, $offset);
$totalCost = self::u64($reply, $offset);
return [
'total_bytes' => $totalBytes,
'cost_per_byte_atomic' => $costPerByte,
'cost_per_byte' => self::decimal($costPerByte),
'total_cost_atomic' => $totalCost,
'total_cost' => self::decimal($totalCost),
'payment_address' => self::walletAddress(substr($reply, $offset, 22)),
];
}
/** @return array<string, mixed> */
public static function jsonObject(string $reply): array
{
self::notNodeError($reply);
try {
$value = json_decode($reply, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new RuntimeException('The node returned invalid JSON data.');
}
if (!is_array($value)) {
throw new RuntimeException('The node returned an invalid JSON object.');
}
if (isset($value['error'])) {
throw new UpstreamRejectedException(
'The Contractless node rejected the request.',
);
}
return $value;
}
/** @return array<string, mixed> */
public static function jsonTextOrRaw(string $reply, string $field): array
{
self::notNodeError($reply);
try {
$value = json_decode($reply, true, flags: JSON_THROW_ON_ERROR);
if (is_array($value)) {
if (isset($value['error'])) {
throw new UpstreamRejectedException(
'The Contractless node rejected the request.',
);
}
return $value;
}
} catch (\JsonException) {
// Continue to text or binary handling.
}
if (
preg_match('//u', $reply) === 1
&& preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $reply) !== 1
) {
return ['text' => trim($reply)];
}
return self::raw($reply, $field);
}
/** @return array{atomic: int, decimal: string} */
public static function balance(string $reply): array
{
self::notNodeError($reply);
if (strlen($reply) !== 8) {
throw new RuntimeException('The node returned an invalid balance.');
}
$offset = 0;
$atomic = self::u64($reply, $offset);
return ['atomic' => $atomic, 'decimal' => self::decimal($atomic)];
}
/**
* @return list<array{asset: string, nft_series: int, balance_atomic: int, balance: string}>
*/
public static function balances(string $reply): array
{
self::notNodeError($reply);
if ((strlen($reply) % 27) !== 0) {
throw new RuntimeException('The node returned an invalid balance list.');
}
$balances = [];
for ($offset = 0; $offset < strlen($reply); $offset += 27) {
$rowOffset = $offset + 15;
$series = self::u32($reply, $rowOffset);
$atomic = self::u64($reply, $rowOffset);
$balances[] = [
'asset' => rtrim(substr($reply, $offset, 15), "\0 "),
'nft_series' => $series,
'balance_atomic' => $atomic,
'balance' => self::decimal($atomic),
];
}
return $balances;
}
public static function validStatus(string $reply): bool
{
self::notNodeError($reply);
$status = strtolower(trim($reply));
if ($status !== 'valid' && $status !== 'invalid') {
throw new RuntimeException('The node returned an invalid validation status.');
}
return $status === 'valid';
}
public static function registrationStatus(string $reply): bool
{
self::notNodeError($reply);
$status = trim($reply);
if ($status !== '0' && $status !== '1') {
throw new RuntimeException('The node returned an invalid registration status.');
}
return $status === '1';
}
public static function optionalText(string $reply): ?string
{
self::notNodeError($reply);
$value = trim($reply);
return $value === '' ? null : $value;
}
/** @return array{block_height: int, transaction_hex: string} */
public static function transaction(string $reply): array
{
self::notNodeError($reply);
if (strlen($reply) <= 4) {
throw new RuntimeException('The node returned an invalid transaction.');
}
$offset = 0;
$height = self::u32($reply, $offset);
return [
'block_height' => $height,
'transaction_hex' => bin2hex(substr($reply, $offset)),
];
}
/**
* @return list<array{
* txid: string,
* block_height: int,
* transaction_hex: string,
* miner_earnings: int
* }>
*/
public static function addressHistory(string $reply): array
{
self::notNodeError($reply);
if (strlen($reply) < 4) {
throw new RuntimeException('The node returned invalid address history.');
}
$offset = 0;
$count = self::u32($reply, $offset);
$records = [];
for ($index = 0; $index < $count; $index++) {
if (strlen($reply) - $offset < 44) {
throw new RuntimeException('The node returned truncated address history.');
}
$txid = bin2hex(substr($reply, $offset, 32));
$offset += 32;
$height = self::u32($reply, $offset);
$transactionLength = self::u32($reply, $offset);
if ($transactionLength > strlen($reply) - $offset - 4) {
throw new RuntimeException('The node returned invalid address history.');
}
$transaction = substr($reply, $offset, $transactionLength);
$offset += $transactionLength;
$minerEarnings = self::u32($reply, $offset);
$records[] = [
'txid' => $txid,
'block_height' => $height,
'transaction_hex' => bin2hex($transaction),
'miner_earnings' => $minerEarnings,
];
}
if ($offset !== strlen($reply)) {
throw new RuntimeException('The node returned malformed address history.');
}
return $records;
}
/** @return array{accepted: bool, already_in_mempool: bool, message: string} */
public static function broadcast(string $reply): array
{
self::notNodeError($reply);
$message = trim($reply);
if ($message === 'successful_broadcast: true') {
return [
'accepted' => true,
'already_in_mempool' => false,
'message' => $message,
];
}
if ($message === 'successful_broadcast: false already_in_mempool') {
return [
'accepted' => false,
'already_in_mempool' => true,
'message' => $message,
];
}
throw new RuntimeException('The node returned an invalid broadcast response.');
}
public static function notNodeError(string $reply): void
{
if (preg_match('/^error:/i', ltrim($reply)) === 1) {
throw new UpstreamRejectedException(
'The Contractless node rejected the request.',
);
}
}
private static function u32(string $bytes, int &$offset): int
{
if (strlen($bytes) - $offset < 4) {
throw new RuntimeException('The node returned truncated binary data.');
}
$decoded = unpack('Vvalue', substr($bytes, $offset, 4));
$offset += 4;
return (int) $decoded['value'];
}
private static function u64(string $bytes, int &$offset): int
{
if (strlen($bytes) - $offset < 8) {
throw new RuntimeException('The node returned truncated binary data.');
}
$decoded = unpack('Pvalue', substr($bytes, $offset, 8));
$offset += 8;
if (!isset($decoded['value']) || !is_int($decoded['value'])) {
throw new RuntimeException('The node returned an unsupported integer value.');
}
return $decoded['value'];
}
private static function decimal(int $atomic): string
{
return intdiv($atomic, self::ATOMIC_UNITS)
. '.'
. str_pad(
(string) ($atomic % self::ATOMIC_UNITS),
8,
'0',
STR_PAD_LEFT,
);
}
private static function walletAddress(string $bytes): string
{
if (strlen($bytes) !== 22 || $bytes[20] !== '.') {
throw new RuntimeException('The node returned an invalid wallet address.');
}
$suffix = match (ord($bytes[21])) {
1 => 'clc',
2 => 'cltc',
default => throw new RuntimeException(
'The node returned an invalid wallet network.',
),
};
return bin2hex(substr($bytes, 0, 20)) . '.' . $suffix;
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Rpc;
use Closure;
use Contractless\Rpc\Exception\TransportException;
use Contractless\Rpc\Transport\TransportInterface;
final class TrackedEndpointTransport implements TransportInterface
{
public function __construct(
private readonly string $endpointId,
private readonly TransportInterface $transport,
private readonly EndpointHealthTracker $health,
) {
}
public function exchange(
string $handshake,
string $request,
string $uid,
Closure $validateHandshake,
): string {
try {
$reply = $this->transport->exchange(
$handshake,
$request,
$uid,
$validateHandshake,
);
$this->health->recordSuccess($this->endpointId);
return $reply;
} catch (TransportException $error) {
$this->health->recordFailure($this->endpointId, $error);
throw $error;
}
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Rpc;
use RuntimeException;
final class UpstreamRejectedException extends RuntimeException
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Rpc;
use RuntimeException;
final class UpstreamResponseException extends RuntimeException
{
}

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
use Contractless\Api\Config\SecurityConfig;
use Contractless\Api\Http\HttpException;
use Contractless\Api\Http\Request;
final class Authenticator
{
public function __construct(
private readonly SecurityConfig $config,
private readonly SecurityStateStore $store,
) {
}
public function authenticate(
string $method,
string $path,
string $clientIp,
): RequestIdentity {
$header = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? ''));
if ($header === '') {
return new RequestIdentity(
'public:' . hash('sha256', $clientIp),
$this->config->publicQuota,
'public',
);
}
if (str_contains($header, '.')) {
[$id, $presentedSecret] = explode('.', $header, 2);
$credential = $this->config->apiKeys[$id] ?? null;
if (
$credential === null
|| !hash_equals($credential['secret'], $presentedSecret)
) {
throw new HttpException(401, 'Invalid API key.');
}
return new RequestIdentity(
'api-key:' . hash('sha256', $id),
$credential['quota'],
'api-key',
);
}
$credential = $this->config->hmacKeys[$header] ?? null;
if ($credential === null) {
throw new HttpException(401, 'Invalid API key.');
}
$this->verifyHmac($header, $credential['secret'], $method, $path);
return new RequestIdentity(
'hmac:' . hash('sha256', $header),
$credential['quota'],
'hmac',
);
}
private function verifyHmac(
string $id,
string $secret,
string $method,
string $path,
): void {
$timestampText = trim((string) ($_SERVER['HTTP_X_TIMESTAMP'] ?? ''));
$nonce = trim((string) ($_SERVER['HTTP_X_NONCE'] ?? ''));
$signature = strtolower(trim((string) ($_SERVER['HTTP_X_SIGNATURE'] ?? '')));
if (
filter_var($timestampText, FILTER_VALIDATE_INT) === false
|| preg_match('/^[A-Za-z0-9_-]{16,128}$/', $nonce) !== 1
|| preg_match('/^[a-f0-9]{64}$/', $signature) !== 1
) {
throw new HttpException(401, 'Invalid HMAC authentication headers.');
}
$timestamp = (int) $timestampText;
$now = time();
if (abs($now - $timestamp) > $this->config->hmacClockSkewSeconds) {
throw new HttpException(401, 'The HMAC timestamp is outside the allowed window.');
}
$query = (string) ($_SERVER['QUERY_STRING'] ?? '');
$bodyHash = hash('sha256', Request::rawBody());
$canonical = implode("\n", [
strtoupper($method),
$path,
$query,
$timestampText,
$nonce,
$bodyHash,
]);
$expected = hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, $signature)) {
throw new HttpException(401, 'The HMAC signature is invalid.');
}
$nonceKey = hash('sha256', $id . "\0" . $nonce);
$expiresAt = $timestamp + $this->config->hmacClockSkewSeconds;
$replayed = $this->store->mutate(
static function (array &$state) use ($nonceKey, $expiresAt, $now): bool {
foreach ($state['nonces'] as $key => $expiry) {
if (!is_int($expiry) || $expiry < $now) {
unset($state['nonces'][$key]);
}
}
if (isset($state['nonces'][$nonceKey])) {
return true;
}
$state['nonces'][$nonceKey] = $expiresAt;
return false;
},
);
if ($replayed) {
throw new HttpException(409, 'The HMAC nonce has already been used.');
}
}
}

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
use Contractless\Api\Http\HttpException;
final class ClientAddress
{
/** @param list<string> $trustedProxies */
public static function resolve(array $trustedProxies): string
{
$remote = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if (filter_var($remote, FILTER_VALIDATE_IP) === false) {
throw new HttpException(400, 'The client IP address could not be determined.');
}
if (!self::matchesAny($remote, $trustedProxies)) {
return $remote;
}
$forwarded = trim((string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($forwarded === '') {
return $remote;
}
$chain = array_map('trim', explode(',', $forwarded));
foreach ($chain as $address) {
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
throw new HttpException(400, 'The forwarded client IP address is invalid.');
}
}
$chain[] = $remote;
for ($index = count($chain) - 1; $index >= 0; $index--) {
if (!self::matchesAny($chain[$index], $trustedProxies)) {
return $chain[$index];
}
}
return $chain[0];
}
/** @param list<string> $trustedProxies */
public static function immediatePeerIsTrusted(array $trustedProxies): bool
{
$remote = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
return filter_var($remote, FILTER_VALIDATE_IP) !== false
&& self::matchesAny($remote, $trustedProxies);
}
/** @param list<string> $ranges */
private static function matchesAny(string $address, array $ranges): bool
{
foreach ($ranges as $range) {
if (self::matches($address, $range)) {
return true;
}
}
return false;
}
private static function matches(string $address, string $range): bool
{
if (!str_contains($range, '/')) {
return hash_equals(strtolower($range), strtolower($address));
}
[$network, $prefixText] = explode('/', $range, 2);
$addressBytes = inet_pton($address);
$networkBytes = inet_pton($network);
if (
$addressBytes === false
|| $networkBytes === false
|| strlen($addressBytes) !== strlen($networkBytes)
|| filter_var($prefixText, FILTER_VALIDATE_INT) === false
) {
return false;
}
$prefix = (int) $prefixText;
$maximum = strlen($addressBytes) * 8;
if ($prefix < 0 || $prefix > $maximum) {
return false;
}
$wholeBytes = intdiv($prefix, 8);
$remainingBits = $prefix % 8;
if (
$wholeBytes > 0
&& substr($addressBytes, 0, $wholeBytes) !== substr($networkBytes, 0, $wholeBytes)
) {
return false;
}
if ($remainingBits === 0) {
return true;
}
$mask = (0xff << (8 - $remainingBits)) & 0xff;
return (ord($addressBytes[$wholeBytes]) & $mask)
=== (ord($networkBytes[$wholeBytes]) & $mask);
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
use Contractless\Api\Config\SecurityConfig;
use Contractless\Api\Http\HttpException;
final class RateLimiter
{
public function __construct(
private readonly SecurityConfig $config,
private readonly SecurityStateStore $store,
) {
}
public function enforce(RequestIdentity $identity, string $path): void
{
$limits = [
['scope' => 'global', 'limit' => $identity->quota],
];
if ($path === '/api/v1/messages/verify') {
$limits[] = [
'scope' => 'message-verify',
'limit' => $this->config->messageVerificationQuota,
];
}
if ($path === '/api/v1/transactions/broadcast') {
$limits[] = [
'scope' => 'broadcast',
'limit' => $this->config->broadcastQuota,
];
}
if (
str_starts_with($path, '/api/v1/blocks/')
|| str_starts_with($path, '/api/v1/torrents/')
|| $path === '/api/v1/headers/all'
) {
$limits[] = [
'scope' => 'expensive',
'limit' => $this->config->expensiveRouteQuota,
];
}
$now = time();
$window = intdiv($now, $this->config->rateWindowSeconds);
$result = $this->store->mutate(
function (array &$state) use ($limits, $identity, $window): array {
foreach ($state['rates'] as $key => $entry) {
if (
!is_array($entry)
|| (int) ($entry['window'] ?? -1) < $window - 1
) {
unset($state['rates'][$key]);
}
}
$minimumRemaining = PHP_INT_MAX;
$minimumLimit = PHP_INT_MAX;
foreach ($limits as $limit) {
$key = hash(
'sha256',
$limit['scope'] . "\0" . $identity->rateKey,
);
$entry = $state['rates'][$key] ?? null;
$count = is_array($entry) && ($entry['window'] ?? null) === $window
? (int) ($entry['count'] ?? 0) + 1
: 1;
$state['rates'][$key] = [
'window' => $window,
'count' => $count,
];
$remaining = max(0, $limit['limit'] - $count);
$minimumRemaining = min($minimumRemaining, $remaining);
$minimumLimit = min($minimumLimit, $limit['limit']);
if ($count > $limit['limit']) {
return [
'allowed' => false,
'limit' => $limit['limit'],
'remaining' => 0,
];
}
}
return [
'allowed' => true,
'limit' => $minimumLimit,
'remaining' => $minimumRemaining,
];
},
);
$reset = (($window + 1) * $this->config->rateWindowSeconds) - $now;
header('X-RateLimit-Limit: ' . $result['limit']);
header('X-RateLimit-Remaining: ' . $result['remaining']);
header('X-RateLimit-Reset: ' . $reset);
if (!$result['allowed']) {
header('Retry-After: ' . max(1, $reset));
throw new HttpException(429, 'Too many requests.');
}
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
final class RequestIdentity
{
public function __construct(
public readonly string $rateKey,
public readonly int $quota,
public readonly string $authentication,
) {
}
}

View File

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
use Contractless\Api\Config\ApiConfig;
use Contractless\Api\Http\HttpException;
use Contractless\Api\Http\Request;
final class SecurityMiddleware
{
private Authenticator $authenticator;
private RateLimiter $rateLimiter;
public function __construct(private readonly ApiConfig $config)
{
$store = new SecurityStateStore($config->security->statePath);
$this->authenticator = new Authenticator($config->security, $store);
$this->rateLimiter = new RateLimiter($config->security, $store);
}
public function handle(string $method, string $path): void
{
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
$this->applyCors();
$this->enforceHttps();
$this->enforceEnabledRoutes($path);
if ($method === 'OPTIONS') {
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header(
'Access-Control-Allow-Headers: Content-Type, X-API-Key, '
. 'X-Timestamp, X-Nonce, X-Signature, '
. 'X-Contractless-Address, X-Contractless-Public-Key, '
. 'X-Contractless-Signature',
);
header('Access-Control-Max-Age: 600');
http_response_code(204);
exit;
}
Request::setMaximumBodyBytes($this->config->security->maximumBodyBytes);
$this->enforceRequestBody($method);
$clientIp = ClientAddress::resolve($this->config->security->trustedProxies);
$identity = $this->authenticator->authenticate($method, $path, $clientIp);
$this->rateLimiter->enforce($identity, $path);
}
private function applyCors(): void
{
$origin = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($origin === '') {
return;
}
$allowed = $this->config->security->corsOrigins;
if (in_array('*', $allowed, true)) {
header('Access-Control-Allow-Origin: *');
return;
}
if (!in_array($origin, $allowed, true)) {
throw new HttpException(403, 'This request origin is not allowed.');
}
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
private function enforceHttps(): void
{
if (
$this->config->environment !== 'production'
|| !$this->config->security->requireHttps
) {
return;
}
$https = strtolower((string) ($_SERVER['HTTPS'] ?? ''));
$secure = $https === 'on' || $https === '1'
|| (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443;
if (
!$secure
&& ClientAddress::immediatePeerIsTrusted(
$this->config->security->trustedProxies,
)
) {
$forwarded = strtolower(trim(
explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0],
));
$secure = $forwarded === 'https';
}
if (!$secure) {
throw new HttpException(426, 'HTTPS is required.');
}
}
private function enforceRequestBody(string $method): void
{
$length = trim((string) ($_SERVER['CONTENT_LENGTH'] ?? ''));
if (
$length !== ''
&& (
filter_var($length, FILTER_VALIDATE_INT) === false
|| (int) $length < 0
|| (int) $length > $this->config->security->maximumBodyBytes
)
) {
throw new HttpException(413, 'The request body is too large.');
}
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
$contentType = strtolower(trim(
explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''))[0],
));
if ($contentType !== 'application/json') {
throw new HttpException(415, 'Content-Type must be application/json.');
}
}
}
private function enforceEnabledRoutes(string $path): void
{
$rawBlockRoutes = [
'/api/v1/blocks/latest',
'/api/v1/blocks/by-height',
'/api/v1/blocks/by-hash',
];
if (
in_array($path, $rawBlockRoutes, true)
&& !$this->config->security->enableRawBlocks
) {
throw new HttpException(403, 'Raw block downloads are disabled by this API operator.');
}
if (
str_starts_with($path, '/api/v1/torrents/')
&& !$this->config->security->enableTorrents
) {
throw new HttpException(403, 'Torrent downloads are disabled by this API operator.');
}
if (
$path === '/api/v1/headers/all'
&& !$this->config->security->enableAllHeaders
) {
throw new HttpException(
403,
'All-header downloads are disabled by this API operator.',
);
}
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
use RuntimeException;
final class SecurityStateStore
{
public function __construct(private readonly string $path)
{
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('The API security-state directory could not be created.');
}
if (!is_writable($directory)) {
throw new RuntimeException('The API security-state directory is not writable.');
}
}
/**
* @template T
* @param callable(array<string, mixed>): T $operation
* @return T
*/
public function read(callable $operation): mixed
{
$handle = fopen($this->path, 'c+b');
if ($handle === false) {
throw new RuntimeException('The API security-state file could not be opened.');
}
try {
if (!flock($handle, LOCK_SH)) {
throw new RuntimeException('The API security-state file could not be locked.');
}
rewind($handle);
$contents = stream_get_contents($handle);
$state = self::decode($contents === false ? '' : $contents);
$result = $operation($state);
flock($handle, LOCK_UN);
return $result;
} finally {
fclose($handle);
}
}
/**
* @template T
* @param callable(array<string, mixed>&): T $operation
* @return T
*/
public function mutate(callable $operation): mixed
{
$handle = fopen($this->path, 'c+b');
if ($handle === false) {
throw new RuntimeException('The API security-state file could not be opened.');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('The API security-state file could not be locked.');
}
rewind($handle);
$contents = stream_get_contents($handle);
$state = self::decode($contents === false ? '' : $contents);
$result = $operation($state);
$encoded = json_encode(
$state,
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
);
rewind($handle);
if (!ftruncate($handle, 0)) {
throw new RuntimeException('The API security state could not be saved.');
}
$offset = 0;
while ($offset < strlen($encoded)) {
$written = fwrite($handle, substr($encoded, $offset));
if ($written === false || $written === 0) {
throw new RuntimeException(
'The API security state could not be saved.',
);
}
$offset += $written;
}
fflush($handle);
@chmod($this->path, 0600);
flock($handle, LOCK_UN);
return $result;
} finally {
fclose($handle);
}
}
/** @return array<string, mixed> */
private static function decode(string $contents): array
{
if (trim($contents) === '') {
return ['rates' => [], 'nonces' => [], 'endpoints' => []];
}
try {
$state = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new RuntimeException('The API security-state file is corrupt.');
}
if (!is_array($state)) {
throw new RuntimeException('The API security-state file is invalid.');
}
$state['rates'] = is_array($state['rates'] ?? null) ? $state['rates'] : [];
$state['nonces'] = is_array($state['nonces'] ?? null) ? $state['nonces'] : [];
$state['endpoints'] = is_array($state['endpoints'] ?? null)
? $state['endpoints']
: [];
return $state;
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Security;
use Contractless\Api\Http\HttpException;
final class TransactionPolicy
{
/**
* Exact signed transaction sizes from the node's command_maps::get_bytes.
* Genesis (0) and rewards (1) are node-generated and cannot be submitted
* through the public HTTP API.
*/
private const BYTE_LENGTHS = [
2 => 750,
3 => 725,
4 => 922,
5 => 861,
6 => 1471,
7 => 1492,
8 => 781,
9 => 733,
10 => 728,
11 => 724,
12 => 723,
100 => 701,
101 => 784,
102 => 784,
103 => 785,
104 => 787,
105 => 791,
106 => 799,
107 => 995,
108 => 784,
109 => 785,
110 => 787,
111 => 791,
112 => 799,
113 => 783,
200 => 733,
201 => 734,
202 => 866,
];
public static function validate(string $transactionHex): int
{
$bytes = hex2bin($transactionHex);
if ($bytes === false || $bytes === '') {
throw new HttpException(422, 'The signed transaction is invalid.');
}
$type = ord($bytes[0]);
$expectedLength = self::BYTE_LENGTHS[$type] ?? null;
if ($expectedLength === null) {
throw new HttpException(422, 'This transaction type is not supported.');
}
if (strlen($bytes) !== $expectedLength) {
throw new HttpException(
422,
"Transaction type $type must contain exactly $expectedLength bytes.",
);
}
return $type;
}
public static function expectedLength(int $type): ?int
{
return self::BYTE_LENGTHS[$type] ?? null;
}
}

1
storage/.gitkeep Normal file
View File

@ -0,0 +1 @@