Initial Contractless PHP API
This commit is contained in:
commit
c5c467c814
|
|
@ -0,0 +1,3 @@
|
|||
/api.env
|
||||
/vendor/
|
||||
/storage/security-state.json
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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])();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Api\Rpc;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class UpstreamRejectedException extends RuntimeException
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Api\Rpc;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class UpstreamResponseException extends RuntimeException
|
||||
{
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
Loading…
Reference in New Issue