From c5c467c814e9fb782c670cbcfd8e07350e4d1c88 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Mon, 27 Jul 2026 15:21:26 -0600 Subject: [PATCH] Initial Contractless PHP API --- .gitignore | 3 + README.md | 432 +++++++++++++++++++++ api.env.example | 45 +++ composer.json | 16 + composer.lock | 49 +++ public/index.php | 216 +++++++++++ src/Application.php | 32 ++ src/ApplicationFactory.php | 75 ++++ src/Config/ApiConfig.php | 89 +++++ src/Config/Environment.php | 100 +++++ src/Config/ReliabilityConfig.php | 22 ++ src/Config/SecurityConfig.php | 188 ++++++++++ src/Http/ExceptionResponder.php | 45 +++ src/Http/HttpException.php | 18 + src/Http/JsonResponse.php | 35 ++ src/Http/Request.php | 189 ++++++++++ src/Http/RequestContext.php | 25 ++ src/Http/Router.php | 41 ++ src/Logging/SafeLogger.php | 23 ++ src/Routes/RemainingRoutes.php | 426 +++++++++++++++++++++ src/Rpc/EndpointHealthTracker.php | 111 ++++++ src/Rpc/RequestCredentials.php | 87 +++++ src/Rpc/RpcReplyDecoder.php | 518 ++++++++++++++++++++++++++ src/Rpc/TrackedEndpointTransport.php | 40 ++ src/Rpc/UpstreamRejectedException.php | 11 + src/Rpc/UpstreamResponseException.php | 11 + src/Security/Authenticator.php | 120 ++++++ src/Security/ClientAddress.php | 105 ++++++ src/Security/RateLimiter.php | 103 +++++ src/Security/RequestIdentity.php | 16 + src/Security/SecurityMiddleware.php | 152 ++++++++ src/Security/SecurityStateStore.php | 119 ++++++ src/Security/TransactionPolicy.php | 72 ++++ storage/.gitkeep | 1 + 34 files changed, 3535 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api.env.example create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 public/index.php create mode 100644 src/Application.php create mode 100644 src/ApplicationFactory.php create mode 100644 src/Config/ApiConfig.php create mode 100644 src/Config/Environment.php create mode 100644 src/Config/ReliabilityConfig.php create mode 100644 src/Config/SecurityConfig.php create mode 100644 src/Http/ExceptionResponder.php create mode 100644 src/Http/HttpException.php create mode 100644 src/Http/JsonResponse.php create mode 100644 src/Http/Request.php create mode 100644 src/Http/RequestContext.php create mode 100644 src/Http/Router.php create mode 100644 src/Logging/SafeLogger.php create mode 100644 src/Routes/RemainingRoutes.php create mode 100644 src/Rpc/EndpointHealthTracker.php create mode 100644 src/Rpc/RequestCredentials.php create mode 100644 src/Rpc/RpcReplyDecoder.php create mode 100644 src/Rpc/TrackedEndpointTransport.php create mode 100644 src/Rpc/UpstreamRejectedException.php create mode 100644 src/Rpc/UpstreamResponseException.php create mode 100644 src/Security/Authenticator.php create mode 100644 src/Security/ClientAddress.php create mode 100644 src/Security/RateLimiter.php create mode 100644 src/Security/RequestIdentity.php create mode 100644 src/Security/SecurityMiddleware.php create mode 100644 src/Security/SecurityStateStore.php create mode 100644 src/Security/TransactionPolicy.php create mode 100644 storage/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3871a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/api.env +/vendor/ +/storage/security-state.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5b35a7 --- /dev/null +++ b/README.md @@ -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. diff --git a/api.env.example b/api.env.example new file mode 100644 index 0000000..e613f92 --- /dev/null +++ b/api.env.example @@ -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 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2509f54 --- /dev/null +++ b/composer.json @@ -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/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..8358e18 --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..1641a35 --- /dev/null +++ b/public/index.php @@ -0,0 +1,216 @@ +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); +} diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..40b5db4 --- /dev/null +++ b/src/Application.php @@ -0,0 +1,32 @@ + */ + 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]; + } +} diff --git a/src/ApplicationFactory.php b/src/ApplicationFactory.php new file mode 100644 index 0000000..02ccff5 --- /dev/null +++ b/src/ApplicationFactory.php @@ -0,0 +1,75 @@ +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, + ); + } +} diff --git a/src/Config/ApiConfig.php b/src/Config/ApiConfig.php new file mode 100644 index 0000000..4320ce0 --- /dev/null +++ b/src/Config/ApiConfig.php @@ -0,0 +1,89 @@ + $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 + */ + 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; + } +} diff --git a/src/Config/Environment.php b/src/Config/Environment.php new file mode 100644 index 0000000..6864549 --- /dev/null +++ b/src/Config/Environment.php @@ -0,0 +1,100 @@ + */ + 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.", + ), + }; + } +} + diff --git a/src/Config/ReliabilityConfig.php b/src/Config/ReliabilityConfig.php new file mode 100644 index 0000000..6a9ee4e --- /dev/null +++ b/src/Config/ReliabilityConfig.php @@ -0,0 +1,22 @@ +int('RPC_FAILURE_THRESHOLD', 2, 1, 100), + $environment->int('RPC_COOLDOWN_SECONDS', 30, 1, 3_600), + ); + } +} diff --git a/src/Config/SecurityConfig.php b/src/Config/SecurityConfig.php new file mode 100644 index 0000000..3bbe9ef --- /dev/null +++ b/src/Config/SecurityConfig.php @@ -0,0 +1,188 @@ + $trustedProxies + * @param list $corsOrigins + * @param array $apiKeys + * @param array $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 */ + 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 */ + 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 */ + 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 + */ + 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; + } +} diff --git a/src/Http/ExceptionResponder.php b/src/Http/ExceptionResponder.php new file mode 100644 index 0000000..7a92ed0 --- /dev/null +++ b/src/Http/ExceptionResponder.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/src/Http/HttpException.php b/src/Http/HttpException.php new file mode 100644 index 0000000..08144fb --- /dev/null +++ b/src/Http/HttpException.php @@ -0,0 +1,18 @@ + $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 $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; + } +} + diff --git a/src/Http/Request.php b/src/Http/Request.php new file mode 100644 index 0000000..c8a8b53 --- /dev/null +++ b/src/Http/Request.php @@ -0,0 +1,189 @@ + 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 */ + 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 $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; + } +} diff --git a/src/Http/RequestContext.php b/src/Http/RequestContext.php new file mode 100644 index 0000000..66d2aaf --- /dev/null +++ b/src/Http/RequestContext.php @@ -0,0 +1,25 @@ +> */ + 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])(); + } +} diff --git a/src/Logging/SafeLogger.php b/src/Logging/SafeLogger.php new file mode 100644 index 0000000..b6c3642 --- /dev/null +++ b/src/Logging/SafeLogger.php @@ -0,0 +1,23 @@ + 'request_failed', + 'request_id' => $requestId, + 'error_type' => $error::class, + ]; + error_log( + '[contractless-api] ' + . json_encode($event, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ); + } +} diff --git a/src/Routes/RemainingRoutes.php b/src/Routes/RemainingRoutes.php new file mode 100644 index 0000000..83a3087 --- /dev/null +++ b/src/Routes/RemainingRoutes.php @@ -0,0 +1,426 @@ +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); + } +} diff --git a/src/Rpc/EndpointHealthTracker.php b/src/Rpc/EndpointHealthTracker.php new file mode 100644 index 0000000..f780db9 --- /dev/null +++ b/src/Rpc/EndpointHealthTracker.php @@ -0,0 +1,111 @@ +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 $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; + }, + ); + } +} diff --git a/src/Rpc/RequestCredentials.php b/src/Rpc/RequestCredentials.php new file mode 100644 index 0000000..b392238 --- /dev/null +++ b/src/Rpc/RequestCredentials.php @@ -0,0 +1,87 @@ +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), + ); + } +} diff --git a/src/Rpc/RpcReplyDecoder.php b/src/Rpc/RpcReplyDecoder.php new file mode 100644 index 0000000..c212195 --- /dev/null +++ b/src/Rpc/RpcReplyDecoder.php @@ -0,0 +1,518 @@ + */ + 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 + */ + 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 + */ + 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 */ + 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 + */ + 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 */ + 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 */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/src/Rpc/TrackedEndpointTransport.php b/src/Rpc/TrackedEndpointTransport.php new file mode 100644 index 0000000..8045bb6 --- /dev/null +++ b/src/Rpc/TrackedEndpointTransport.php @@ -0,0 +1,40 @@ +transport->exchange( + $handshake, + $request, + $uid, + $validateHandshake, + ); + $this->health->recordSuccess($this->endpointId); + return $reply; + } catch (TransportException $error) { + $this->health->recordFailure($this->endpointId, $error); + throw $error; + } + } +} diff --git a/src/Rpc/UpstreamRejectedException.php b/src/Rpc/UpstreamRejectedException.php new file mode 100644 index 0000000..4f2f9f8 --- /dev/null +++ b/src/Rpc/UpstreamRejectedException.php @@ -0,0 +1,11 @@ +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.'); + } + } +} diff --git a/src/Security/ClientAddress.php b/src/Security/ClientAddress.php new file mode 100644 index 0000000..f0a65cf --- /dev/null +++ b/src/Security/ClientAddress.php @@ -0,0 +1,105 @@ + $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 $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 $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); + } +} + diff --git a/src/Security/RateLimiter.php b/src/Security/RateLimiter.php new file mode 100644 index 0000000..38b57dc --- /dev/null +++ b/src/Security/RateLimiter.php @@ -0,0 +1,103 @@ + '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.'); + } + } +} diff --git a/src/Security/RequestIdentity.php b/src/Security/RequestIdentity.php new file mode 100644 index 0000000..960c8f3 --- /dev/null +++ b/src/Security/RequestIdentity.php @@ -0,0 +1,16 @@ +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.', + ); + } + } +} diff --git a/src/Security/SecurityStateStore.php b/src/Security/SecurityStateStore.php new file mode 100644 index 0000000..75e25e7 --- /dev/null +++ b/src/Security/SecurityStateStore.php @@ -0,0 +1,119 @@ +): 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&): 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 */ + 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; + } +} diff --git a/src/Security/TransactionPolicy.php b/src/Security/TransactionPolicy.php new file mode 100644 index 0000000..23cd726 --- /dev/null +++ b/src/Security/TransactionPolicy.php @@ -0,0 +1,72 @@ + 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; + } +} diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/.gitkeep @@ -0,0 +1 @@ +