Skip to content

Latest commit

 

History

History
462 lines (348 loc) · 20.6 KB

client-guide.md

File metadata and controls

462 lines (348 loc) · 20.6 KB

Client guide

This document describes how to integrate the Fibre gateway. The request and response formats are in the API reference. What we promise is in the service contract.

Contents

Quick start

This gateway provides easy access to a Celestia Fibre deployment. The gateway accepts a raw payload, Reed-Solomon encodes it, and sends the resulting blob to Celestia for storage. On success, the gatway returns a blob_id - a binding commitment to the posted data. Before accepting, clients should verify that the returned commitment matches their payload using the provided library (see Rust example below).

Item Value
Endpoint https://cf.celestia-corto.com:8443
Token A bearer token from Celestia, sent out of band
Code Read access to celestiaorg/fibre-gateway-client, for the Rust and Python libraries
A test blob 131,067 to 134,217,723 bytes (128 KiB − 5 to 128 MiB − 5)

Note: The −5 reserves space for Fibre's 5-byte blob header. The gateway adds this header for you; send only your data. Send the token using an authroization header (Authorization: Bearer <token>) on every request.

Scaling

To minimize costs, the gateway scales down after inactivity. Before running a load test, we recommend scaling it up manually to ensure that capacity is available.

Endpoint Meaning
POST /v1/capacity with {"gib_per_second":10,"minutes":60} or {"instances":20,"minutes":60} Keep at least that throughput or instances (2–20) up for minutes (1–120)
GET /v1/capacity Current reservations, fleet size and load
DELETE /v1/capacity Release your reservation early
GET /v1/capacity/load?minutes=60 Fleet put load history (what the page plots)

Or use the web page at https://cf.celestia-corto.com:8443/ui: enter GiB/s and minutes, and it tells you when the capacity is serving.

Curl (Put and Get without Verification)

GW=https://cf.celestia-corto.com:8443
TOKEN=<your token>

head -c 134217723 /dev/urandom > blob.bin   # 128 MiB − 5, the largest size

curl --fail-with-body --max-time 160 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @blob.bin "$GW/v1/put" -o put.json

curl --fail-with-body --max-time 130 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data "{\"blob_id\":\"$(jq -r .blob_id put.json)\"}" "$GW/v1/get" -o blob.out

cmp blob.bin blob.out && echo ok

The put takes a few seconds: it returns after the chain confirms the blob. put.json looks like this:

{"chain_id":"corto-10","tx_hash":"<64 hex>","blob_id":"<66 hex>","promise_height":123,
 "commitment_proof":{"row_root_siblings":["<64 hex>","<64 hex>"]}}

Fields are described in the API reference

Put, Verification and Get in Rust

The crate fibre-gateway-client is on crates.io. Its source is in clients/rust.

[dependencies]
fibre-gateway-client = { version = "0.1", features = ["http"] }
bytes = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"

This is clients/rust/examples/quickstart.rs:

use bytes::Bytes;
use fibre_gateway_client::Client;

const GATEWAY: &str = "https://cf.celestia-corto.com:8443";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = std::env::args().nth(1).ok_or("usage: quickstart <blob>")?;
    let data = Bytes::from(std::fs::read(path)?);
    let client = Client::new(GATEWAY, std::env::var("TOKEN")?);

    // `put` checks the commitment proof against `data` before it returns the receipt.
    let receipt = client.put(data.clone()).await?;
    // After the check, `blob_id` is all you need to keep.
    std::fs::write("blob_id.txt", &receipt.blob_id)?;

    let back = client.get(&receipt.blob_id).await?;
    if back.as_slice() != data.as_ref() {
        return Err("read-back does not match".into());
    }
    println!("ok {} {}", receipt.blob_id, receipt.tx_hash);
    Ok(())
}

Run it from clients/rust:

TOKEN=<your token> cargo run --release --features http --example quickstart -- blob.bin

You do not need to verify anything yourself: Client::put checks the commitment against your data before it returns, and fails with HttpError::Verify if it does not match. In Python, call verify from fibre_verify.py once after the put (Python).

The Client already uses the correct timeouts (160 s put, 130 s get, 10 s connect) and never replays a put.

Before production

  • Errors and retries: which errors to retry, and why a retried put may store a second copy.
  • Limits: sizes, concurrency per instance, retention.
  • Connect: keep-alive and spreading load over instances.
  • Blob sizes: the size range, and which blobs pay for padding.
  • Retention and what to store: keep the blob_id, the put time and, if you padded, the real length. Blobs can be read for 24 h.
  • Capacity: reserve instances before a large job.
  • On-chain check (optional): confirm the chain accepted the payment.

The service contract lists what we promise and measured performance. There is no SLA yet.

Support: <support contact>.

Terms

Term Meaning
Blob The bytes you put, 128 KiB − 5 to 128 MiB − 5
Rows The blob is laid out as 4096 equal rows: a 5-byte header, your data, then zero padding. The network adds 12,288 parity rows
Row root Merkle root over all 16,384 rows
RLC Random linear combination: one 16-byte value per row, with coefficients derived from the row root. It ties every row's content to the commitment
Commitment SHA256(row_root ‖ rlc_root), where rlc_root is the Merkle root of the per-row RLC values
blob_id Version byte 00, then the commitment. 33 bytes, 66 hex characters
Receipt chain_id, tx_hash, blob_id and promise_height from the put response. Only blob_id is needed to read the blob
Commitment proof Two row tree nodes (row_root_siblings) that let you recompute the row root from your own data

Connect

Item Value
Endpoint https://cf.celestia-corto.com:8443. Port 8443 only
Private endpoint https://private.cf.celestia-corto.com:8443: the same instances on their private IPs. Only for clients in the gateway's VPC or a peered VPC, in the gateway's Availability Zone, AZ ID euc1-az2 (match the ID, not the AZ name: names differ per account). Same-AZ traffic is free of AWS data-transfer charges. From anywhere else it does not connect
DNS The name round-robins over the instances serving traffic
TLS TLS 1.2 or newer. Public Let's Encrypt certificate for cf.celestia-corto.com and *.cf.celestia-corto.com
Chain corto-10

The same certificate and tokens work on every name.

HTTP client settings:

  • Keep-alive. Reuse one client and its connection pool. Do not open a connection per request.
  • Spread load. Each instance takes at most 48 puts at once (see Limits), so concurrency above that must reach several instances. A connection stays on the instance it first reached, and a single HTTP/2 connection carries every request to one instance. Use HTTP/1.1 with one connection per request in flight, and connect each one to a different address from the DNS answer: system resolvers usually return the addresses in the same order, and most HTTP libraries connect to the first one, so every connection lands on the same instance. The Rust crate's Client does this for you. It can also spread HTTP/2 traffic by keeping one connection per instance and sending requests to them in turn (Protocol::Http2), but HTTP/1.1 stays the default: it uploads large blobs a little faster, because HTTP/2's server-controlled flow-control window throttles a single connection's upload.
  • No automatic replay. Never let your HTTP library resend a put body on its own. Retry with a new request (see Errors and retries). The Rust crate's Client never replays a POST.

Tokens

Celestia will issue you tokens out-of-band. Reach out via Slack or email if additional tokens are needed.

  • Use bearer authorization headers: Authorization: Bearer <token>
  • 401 unauthorized means the token is missing, wrong or retired, or the header was sent twice.

Blob sizes

A put accepts 131,067 through 134,217,723 bytes (128 KiB − 5 through 128 MiB − 5). The −5 reserves space for Fibre's 5-byte blob header (1 version byte and 4 bytes for the data length) Other sizes will be rejected with 413. Fibre encodes and charges every blob as at least 256 KiB (4096 rows × 64-byte minimum row), so blobs below 256 KiB − 5 bytes pay for padding.

The gateway returns exactly the bytes you put. It never strips padding. If your data is smaller than the minimum, pad it yourself and store the real length. If your data is larger than the maximum, split it into chunks and embed metadata to allow reconstruction of the original blob.

Limits

Limit Value
Blob size 131,067 to 134,217,723 bytes
Concurrent puts per instance 48. We recommend no more than 32
Concurrent gets per instance 32
Request headers 16 KiB, sent within 5 s
Retention 24 h after the put
Idempotency None. A retried put may store a second copy

The put and get limits are per instance, fixed by the gateway, and shared by every token, priority tokens included; no token can raise them. Fleet-wide capacity is therefore about 48 × active puts (see Capacity), and only if your connections are spread over the instances (see Connect).

A slot is held for the whole request, up to about 155 s for a put. Above the limit the gateway returns 429 capacity at once; it does not queue. If you need more capacity, reserve it or ask for more instances.

Capacity

A sudden large job can see 429 until enough capacity is ready. Before a large job, reserve a minimum number of instances for a while:

Request Meaning
POST /v1/capacity with {"instances":20,"minutes":60} Keep at least instances (2–20) up for minutes (1–120)
GET /v1/capacity Current reservation and fleet size

Both return:

{"floor":20,"expires_at":"2026-10-04T13:00:00+00:00","target":20,"active":8,"running":12,"eta_seconds":150}
Field Meaning
floor Reserved instances. 0 when no reservation is active
expires_at When the reservation ends, or null
target Instances the fleet is scaling to
active Instances serving traffic now
running Instances started, including ones still warming up
eta_seconds Estimated time until target instances serve traffic
  • Start the job after eta_seconds, or poll GET until active reaches target.
  • There is one reservation for the whole fleet. A new POST replaces it; at most one change per 10 s.
  • Only tokens allowed to reserve capacity may call it; others get 403 forbidden. Ask us to allow yours.
  • Capacity calls do not use put or get slots.

Timeouts

Operation Gateway deadline Client timeout to use
Connect — 10 s
Put 150 s, plus 5 s to write the response 160 s
Get 120 s, plus 5 s to write the response 130 s

The put deadline covers receiving the body, upload, chain confirmation and the receipt lookup. The body must arrive in full before the deadline.

The Rust crate's Client uses these timeouts by default. To change them:

use std::time::Duration;
use fibre_gateway_client::{Client, Timeouts};

let timeouts = Timeouts { put: Duration::from_secs(200), ..Timeouts::default() };
let client = Client::with_timeouts(GATEWAY, token, timeouts);

Errors and retries

Every status code is listed in the API reference.

Status Retry? How
429, 503 Yes Exponential backoff from 1 s, cap 30 s, full jitter, up to 10 attempts
502, 504 on get Yes Same backoff
502, 504 on put Yes, with a new request Start at 5 s, same cap. May store a second copy
Network error during a put Yes, with a new request Same as 502 on put
400, 401, 404, 413 No Fix the request or token first

Priority traffic. Under high load an instance may refuse some tokens with 429 priority_only. Treat it like any other 429, but when the Retry-After header is present wait that many seconds before retrying.

Unknown outcome. After a 502 or 504 on put, or a network error once the body started, the blob may already be stored and paid for. A retry stores and pays again. That is fine: keep the receipt you finally get. 502 proof_unavailable means the blob is stored and paid; report it to us.

Retention and what to store

Blobs can be read for 24 h after the put. After that /v1/get returns 404.

For each blob, store:

What Why
blob_id /v1/get takes it as the request body
The time of the put To know when the 24 h ends
Your real data length, if you padded The gateway returns the padded bytes

Verification

With our libraries you do not implement any verification.

  • Rust Client: Client::put checks the commitment before it returns. There is nothing else to do.
  • Rust with your own HTTP stack: call fibre_gateway_client::verify(&data, &put.receipt, &put.commitment_proof).
  • Python: call verify(data, put["blob_id"], put["commitment_proof"]["row_root_siblings"]) right after the put.

Each check fails if the gateway committed to bytes other than yours. After it passes, keep only the blob_id. Reads need no check on your side: the gateway checks every piece it downloads against blob_id.

Checking the chain is optional, see below. To write your own verifier, see the reference verifier in clients/python/fibre_verify.py.

On-chain check (optional)

The commitment check shows that blob_id names your data. It does not show that the chain accepted it. Fetch the transaction from any validator RPC, v0 through v9. They use HTTPS with a Let's Encrypt certificate; plain http:// is refused.

curl -s "https://v0.cf.celestia-corto.com:26657/tx?hash=0x<tx_hash>"

Check, in result.tx_result:

  • code is absent or 0 (CometBFT omits a zero code).
  • An event with type celestia.fibre.v1.EventPayForFibre has attribute commitment equal to blob_id without its first byte. The value is base64 inside JSON quotes: "value":"\"q83v...=\"".
  • Optionally, its signer attribute is the gateway account you expect.

result.tx (base64 protobuf) holds the MsgPayForFibre. Its payment_promise has commitment, blob_version (0), namespace and blob_size (4096 × row_size) if you want to check those too.

One RPC is one node's view. Query more than one, or use a light client, if that matters. Blobstream will be the trust-minimized way to prove inclusion.

This Rust function, from clients/rust/examples/put_verify_get.rs, does the check. It needs base64 = "0.22" and serde_json = "1".

use base64::{engine::general_purpose::STANDARD, Engine};

type Error = Box<dyn std::error::Error>;

/// Checks that a CometBFT `/tx` response succeeded and holds an
/// `EventPayForFibre` for `commitment`.
fn check_pay_for_fibre(tx: &serde_json::Value, commitment: &[u8]) -> Result<(), Error> {
    let result = &tx["result"]["tx_result"];
    // CometBFT omits `code` when it is 0.
    if result["code"].as_u64().unwrap_or(0) != 0 {
        return Err(format!("tx failed: {tx}").into());
    }
    let events = result["events"].as_array().ok_or("no events")?;
    for event in events {
        if event["type"] != "celestia.fibre.v1.EventPayForFibre" {
            continue;
        }
        for attr in event["attributes"].as_array().ok_or("no attributes")? {
            if attr["key"] != "commitment" {
                continue;
            }
            // The value is a JSON string holding base64.
            let value: String = serde_json::from_str(attr["value"].as_str().ok_or("bad value")?)?;
            if STANDARD.decode(value)? == commitment {
                return Ok(());
            }
        }
    }
    Err("no EventPayForFibre for this commitment".into())
}

Call it with &parse_blob_id(&receipt.blob_id)?[1..] as the commitment. The example runs the whole flow: put, commitment check, on-chain check, get and hash check.

Client libraries

Language Where Commitment check HTTP client
Rust clients/rust, crate fibre-gateway-client verify Client (feature http)
Python clients/python/fibre_verify.py verify Use requests or similar

Verifying a 128 MiB − 5 blob on one laptop core (Apple M5 Pro) takes about 0.2 s in Rust (23 ms on all 18 cores) and 3.7 s in pure Python. Use Rust in production.

Rust

Add it from crates.io:

[dependencies]
fibre-gateway-client = { version = "0.1", features = ["http"] }
bytes = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
  • Client::new(url, token) builds an async client with the default timeouts. Requests require a Tokio runtime.
  • client.put(data).await takes bytes::Bytes, uploads, verifies the commitment on Tokio's blocking pool and returns the Receipt. It fails with HttpError::Verify if the proof does not match your data.
  • client.get(blob_id).await returns the blob bytes.
  • client.capacity(instances, minutes).await reserves capacity; client.capacity_status().await reads it.
  • A non-2xx response is HttpError::Status(e): e.status, the body's e.code (like capacity or invalid_length) and e.message, and e.retry_after from the Retry-After header. code and message are None when the body is not the gateway's JSON, for example an error page from a proxy. err.status() returns the status for any error.
  • Network and response decoding errors are HttpError::Request(e), where e is a reqwest::Error; use e.is_timeout() for timeouts.
  • HttpError::Join means the verification task failed to complete.
  • Without the http feature you get the types and verify only, for use with your own HTTP stack.

Examples, run from clients/rust:

TOKEN=<token> cargo run --release --features http --example quickstart -- blob.bin
GW=https://cf.celestia-corto.com:8443 TOKEN=<token> RPC=https://v0.cf.celestia-corto.com:26657 \
  cargo run --release --features http --example put_verify_get -- blob.bin

Python

clients/python/fibre_verify.py verify(data, blob_id, row_root_siblings) raises VerifyError on a mismatch.

A put and get with requests (pip install requests):

import requests
from fibre_verify import verify  # clients/python/fibre_verify.py

BASE = "https://cf.celestia-corto.com:8443"
TOKEN = "<token>"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}


def put(data: bytes) -> dict:
    assert 131_067 <= len(data) <= 134_217_723
    r = requests.post(
        f"{BASE}/v1/put", data=data, timeout=(10, 160),
        headers={**HEADERS, "Content-Type": "application/octet-stream"},
    )
    if r.status_code != 200:
        raise RuntimeError(f"put: HTTP {r.status_code}: {r.text.strip()}")
    put = r.json()
    # Raises VerifyError if the receipt does not commit to data.
    verify(data, put["blob_id"], put["commitment_proof"]["row_root_siblings"])
    return put


def get(blob_id: str) -> bytes:
    r = requests.post(
        f"{BASE}/v1/get", json={"blob_id": blob_id}, timeout=(10, 130),
        headers={**HEADERS, "Content-Type": "application/json"},
    )
    if r.status_code != 200:
        raise RuntimeError(f"get: HTTP {r.status_code}: {r.text.strip()}")
    return r.content


data = bytes(128 * 1024 * 1024 - 5)  # fill with your payload
blob_id = put(data)["blob_id"]  # keep only this
assert get(blob_id) == data

A network error after the put body started sending is an unknown outcome. Retry it with a new request (see Errors and retries).