Contractless-PHP-API/README.md

20 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.

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:

php tools/test_api.php \
  https://api.contractless.dev \
  /private/path/to/test.wallet

An alternative GET route may be provided:

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:

{
  "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(). When the node rejects a transaction, the API returns its bounded, valid UTF-8 verification reason with HTTP 422. Malformed, binary, and oversized upstream errors remain generic. This allows wallets to distinguish problems such as an insufficient balance, an unregistered participant, or a fee below the minimum.

Wallet registration accepts:

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

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:

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
GET /api/v1/nfts/media?name=...&series=... NFT/RWA metadata and API image location
GET /api/v1/nfts/image?name=...&series=... Cached NFT/RWA image or common placeholder

NFT And RWA Media

NFT and RWA transactions store an IPFS CID rather than placing media directly on the blockchain. API operators choose whether their API retrieves and caches that media:

LOCAL_CACHE=NO
IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs/
NFT_CACHE_PATH=storage/nft-cache
NFT_PLACEHOLDER_IMAGE=public/images/nft-placeholder.svg
NFT_IPFS_TIMEOUT_SECONDS=10
NFT_METADATA_MAX_BYTES=1048576
NFT_DOWNLOAD_MAX_BYTES=25000000
NFT_IMAGE_MAX_BYTES=5000000
NFT_IMAGE_MAX_WIDTH=1200
NFT_IMAGE_MAX_HEIGHT=1200
NFT_IMAGE_MAX_SOURCE_PIXELS=40000000

The default IPFS gateway is the same gateway used by the Contractless GUI wallet.

When LOCAL_CACHE=NO, the API does not contact the IPFS gateway. It returns the CID from the NFT transaction, image_available: false, and an image URL that serves the common placeholder.

When LOCAL_CACHE=YES, the API:

  1. Gets the CID from the NFT transaction returned by the configured node.
  2. Checks NFT_CACHE_PATH using the CID and series number.
  3. Retrieves missing metadata and media through IPFS_GATEWAY.
  4. Returns the placeholder when the gateway times out or the content is unavailable or invalid.
  5. Uses PHP GD to reduce images that exceed the configured dimensions or cached-image byte limit.
  6. Stores the metadata and processed image locally.
  7. Serves future requests from the local cache.

For an NFT series, the API follows the GUI wallet convention and requests CID/metadata/SERIES.json. A one-of-one NFT or RWA uses the transaction CID as its metadata location.

Example media response:

{
  "success": true,
  "data": {
    "name": "EXAMPLE",
    "cid": "bafy...",
    "series": 0,
    "image_available": true,
    "image_url": "/api/v1/nfts/image?name=EXAMPLE&series=0",
    "metadata": {
      "name": "Example NFT",
      "image": "ipfs://bafy.../image.png"
    }
  }
}

The CID is always returned, including when the API serves its placeholder. Applications may therefore retrieve the original media through another IPFS gateway.

NFT_CACHE_PATH must be writable by the PHP process and should remain outside the public web directory. Operators may serve the image route through a CDN if desired. Cached content is not committed to Git.

The PHP API requires the cURL and GD extensions when local NFT caching is enabled. IPFS_GATEWAY is not used while LOCAL_CACHE=NO.

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 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.