Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions crates/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ This is one of four language bindings published from the same Rust core. See the
- [Sets](#sets)
- [Lists](#lists)
- [SQL Client](#sql-client)
- [`list_clusters`](#list_clusters)
- [`query_with_session`](#query_with_session)
- [`query_with_mpp_session`](#query_with_mpp_session)
- [RPC & Tooling Access](#rpc--tooling-access)
- [Crypto-micropayment lane (`rpc.call`)](#crypto-micropayment-lane-rpccall)
- [Wallet generation](#wallet-generation)
Expand Down Expand Up @@ -1756,11 +1759,26 @@ qn.kvstore.delete_list("my-list").await?;

### SQL Client

Accessed as `qn.sql`. Runs SQL queries against indexed blockchain data and fetches the database schema. Backed by `https://api.quicknode.com/sql/rest/v1/`.
Accessed as `qn.sql`. Runs SQL queries against indexed blockchain data and fetches the database schema.

- Account host (API key): `https://api.quicknode.com/sql/rest/v1/` — `query` and `get_schema`.
- Public catalog / x402 drawdown: `https://x402.quicknode.com/sql/rest/v1/` — `list_clusters` and `query_with_session`. Use a keyless client so no `x-api-key` is sent.
- MPP session: `POST https://mpp.quicknode.com/session/sql/rest/v1/query` — `query_with_mpp_session`.

##### `list_clusters`

Lists clusters from the public catalog (`GET clusters`). Unauthenticated.

**Returns**: `Vec<SqlCluster>` — each with `id` and `display_name`.

```rust
// Rust
let clusters = qn.sql.list_clusters().await?;
```

##### `query`

Executes a SQL query against a cluster and returns the result set. Paginate by writing `LIMIT`/`OFFSET` into the SQL.
Executes a SQL query against a cluster on the account host and returns the result set. Paginate by writing `LIMIT`/`OFFSET` into the SQL.

**Parameters**: `QueryParams` with `query` (String, required) and `cluster_id` (String, required).

Expand All @@ -1778,9 +1796,35 @@ let resp = qn
println!("{} rows, {:?}", resp.rows, resp.data.first());
```

##### `query_with_session`

Executes a SQL query on the x402 drawdown host with a SIWX `GatewaySession` JWT. Single attempt. A 402 `requires_payment` is `SdkError::Api` — this method never signs a per-request payment. Requires the `payments` feature.

```rust
// Rust
let session = qn.rpc.gateway_authenticate().await?;
let resp = qn.sql.query_with_session(&params, &session).await?;
```

##### `query_with_mpp_session`

Executes a SQL query on the MPP session route with a cumulative voucher. The increment is the SQL challenge `amount` (not `ChannelState.per_call`). A 402 insufficient-balance is terminal.

Takes `&mut ChannelState` and advances `cumulative_spent` whenever the voucher reached the gateway — including on a non-2xx body and on a lost response. Persist the channel on every outcome, not only after a 200: the gateway refuses a re-signed stale cumulative, so a channel left behind the gateway cannot be used again. Requires the `payments-tempo` feature.

```rust
// Rust
let result = qn
.sql
.query_with_mpp_session(&params, &payment, &mut channel)
.await;
save_channel(&channel)?; // persist first — the voucher may have settled
let result = result?;
```

##### `get_schema`

Fetches the database schema for a cluster: table names, columns, types, sort keys, and partition strategies.
Fetches the database schema for a cluster: table names, columns, types, sort keys, and partition strategies. Reads the configured SQL base URL and sends the API key.

**Parameters**: `cluster_id` (`&str`, required).

Expand Down
4 changes: 3 additions & 1 deletion crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ pub use rpc::{
};
#[cfg(feature = "payments-tempo")]
pub use rpc::{ChannelState, ChannelStatus};
#[cfg(feature = "payments-tempo")]
pub use sql::MppQueryResult;
pub use sql::{
ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics,
SqlApiClient, TableSchema,
SqlApiClient, SqlCluster, TableSchema, X402_SQL_BASE_URL,
};

use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
Expand Down
141 changes: 141 additions & 0 deletions crates/core/src/rpc/payment/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,147 @@ pub async fn voucher_call(
Ok(text)
}

/// Session route for SQL Explorer. Distinct from [`SESSION_ROUTE_NETWORK`]:
/// the SQL 402 challenge prices a query at its own `amount`, not the RPC
/// `per_call` unit.
const SQL_SESSION_ROUTE: &str = "sql/rest/v1/query";

/// Makes one MPP-session SQL query. The voucher increment is the SQL
/// challenge `amount`, not [`ChannelState::per_call`]. Returns the response
/// body and `acceptedCumulative` from the receipt (or the signed cumulative
/// if the receipt omits it). A 402 insufficient-balance is terminal.
///
/// Advances `channel.cumulative_spent` whenever the voucher reached the
/// gateway, errors included: a re-signed stale cumulative is refused, so
/// trailing the gateway strands the channel while leading it recovers.
pub async fn sql_voucher_call(
client: &reqwest::Client,
payment: &ResolvedPayment,
channel: &mut ChannelState,
body: &Value,
) -> Result<(String, u128), SdkError> {
let challenge = probe_sql_session_challenge(client, payment, body).await?;
let increment = require_amount(&challenge.request)?;
let new_cumulative = channel.cumulative_spent.saturating_add(increment);
if new_cumulative > channel.deposit {
return Err(SdkError::PaymentUnsupported {
offered: format!(
"voucher cumulative {new_cumulative} exceeds channel deposit {}; top up first",
channel.deposit
),
});
}

let payer = payment.signer.address()?;
let signature = payment.signer.sign_session_voucher(
&channel.channel_id,
new_cumulative,
channel.chain_id,
&channel.escrow_contract,
)?;
let payload = serde_json::json!({
"action": "voucher",
"channelId": channel.channel_id,
"cumulativeAmount": new_cumulative.to_string(),
"signature": signature,
});
let credential = build_credential(&challenge, &payer, channel.chain_id, &payload)?;

let url = session_base(payment, SQL_SESSION_ROUTE);
let paid = match client
.post(&url)
.header("Authorization", format!("Payment {credential}"))
.json(body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
let err = SdkError::Http(e);
return Err(match err.http_kind() {
// A connect failure never put the voucher on the wire.
Some(HttpKind::Connect) => err,
// Otherwise it may have landed; assume it did.
_ => {
channel.cumulative_spent = new_cumulative;
SdkError::PaymentIndeterminate
}
});
}
};

// Parse before `text()` consumes the response; a failed body can still bank
// the voucher.
let receipt_cumulative = paid
.headers()
.get("payment-receipt")
.and_then(|v| v.to_str().ok())
.and_then(|h| super::decode_b64url_json(h).ok())
.and_then(|v| {
v.get("acceptedCumulative")
.and_then(Value::as_str)
.map(str::to_string)
})
.and_then(|s| s.parse::<u128>().ok());

let accepted = receipt_cumulative.unwrap_or(new_cumulative);
channel.cumulative_spent = accepted;

let paid_status = paid.status();
let text = paid.text().await.map_err(SdkError::Http)?;
if !paid_status.is_success() {
return Err(SdkError::Api {
status: paid_status,
body: text,
});
}
Ok((text, accepted))
}

// Probe the SQL session route for the 402 challenge. The SQL amount is
// not the RPC lifecycle amount, so this must not reuse the pinned
// [`SESSION_ROUTE_NETWORK`] probe.
async fn probe_sql_session_challenge(
client: &reqwest::Client,
payment: &ResolvedPayment,
body: &Value,
) -> Result<SessionChallenge, SdkError> {
let url = session_base(payment, SQL_SESSION_ROUTE);
let resp = client
.post(&url)
.json(body)
.send()
.await
.map_err(SdkError::Http)?;
if resp.status().as_u16() == 404 {
return Err(SdkError::PaymentUnsupported {
offered: "the gateway does not serve the SQL session route \
(/session/sql/rest/v1/query returned 404)"
.into(),
});
}
if resp.status().as_u16() != 402 {
return Err(SdkError::PaymentUnsupported {
offered: format!(
"the SQL session endpoint did not return a 402 challenge (status {})",
resp.status().as_u16()
),
});
}
let header = resp
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.map(String::from)
.ok_or_else(|| SdkError::PaymentUnsupported {
offered: "SQL session 402 without a WWW-Authenticate header".into(),
})?;
parse_session_challenge(
&header,
super::caip2_or_bare_chain_id(&payment.pay_network)?,
)
}

// ── HTTP + credential helpers ────────────────────────────────────────────────

fn session_base(payment: &ResolvedPayment, query_network: &str) -> String {
Expand Down
Loading
Loading