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