Contractless-PHP-API/README.md

15 KiB

Contractless PHP API

HTTP API foundation for applications that communicate with Contractless nodes through contractless/contractless-php-rpc.

Install

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:

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:

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

After changing an Nginx configuration, validate and reload it:

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:

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:

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:

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:

{
  "message": "Exact message bytes",
  "address": "40-character-address.cltc",
  "signature": "1332-character-signature-hex"
}

Transaction broadcasting accepts:

{
  "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:

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:

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:

APP_ENV=development
API_REQUIRE_HTTPS=false

CORS

List the exact websites or browser extensions permitted to call the API:

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

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:

API_KEYS=example-service=replace-with-at-least-32-random-characters:300

The client sends the ID and secret together:

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:

API_HMAC_KEYS=backend=replace-with-at-least-32-random-characters:600
API_HMAC_CLOCK_SKEW_SECONDS=300

The caller sends:

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:

HTTP_METHOD
/request/path
raw=query&string
unix_timestamp
nonce
sha256_hex_of_exact_request_body

The signature is:

$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:

API_EXPENSIVE_REQUESTS_PER_WINDOW=5

Operators can disable costly raw routes independently:

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:

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:

{
  "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:

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

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.