Contractless-PHP-API/README.md

508 lines
17 KiB
Markdown

# 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 included `public/.htaccess` routes Apache and normal Hestia
Nginx-to-Apache installations through `public/index.php`. Apache must have
`mod_rewrite` enabled and permit `.htaccess` overrides.
For a pure Nginx or Nginx-to-PHP-FPM installation, add this inside the domain's
server configuration:
```nginx
location / {
try_files $uri $uri/ /index.php?$query_string;
}
```
After changing an Nginx configuration, validate and reload it:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
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.
### Test Tool
The CLI-only test tool loads a wallet locally, prompts privately for its
decryption key, generates the handshake proof, and tests the default
`/api/v1/height` route:
```bash
php tools/test_api.php \
https://api.contractless.dev \
/private/path/to/test.wallet
```
An alternative GET route may be provided:
```bash
php tools/test_api.php \
https://api.contractless.dev \
/private/path/to/test.wallet \
/api/v1/network
```
Wallet decryption and Falcon signing happen only inside the CLI process. The
tool sends the address, public key, and handshake signature to the API. It
never sends the wallet file, private key, or decryption key and returns a
nonzero exit status when the HTTP request fails.
The test tool requires the PHP GD and OpenSSL extensions in addition to the
Contractless Skein and Falcon modules.
If the HTTP API returns `503`, the tool also tests every RPC endpoint from the
local `api.env` directly. It prints the real transport, handshake, or protocol
error to the terminal without exposing that diagnostic through the public API.
## 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 |
| `POST` | `/api/v1/addresses/register` | Submit a locally signed 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()`.
Wallet registration accepts:
```json
{
"address": "40-character-address.cltc",
"public_key": "1794-character-public-key-hex",
"signature": "1332-character-registration-signature-hex"
}
```
The authenticated address and public key must match the registration body.
The API forwards the registration through `Client::registerWallet()`; it never
creates the registration signature.
Browser extensions must also be included in `API_CORS_ORIGINS` using their
exact origin:
```text
API_CORS_ORIGINS=https://wallet.example.com,chrome-extension://extension-id
```
## 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 create wallet-registration signatures. A browser
wallet must create and sign registration data locally before submitting it
through `/api/v1/addresses/register`. The API never registers a
wallet using credentials owned by the API operator.