Skip to content
Merged
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
679 changes: 656 additions & 23 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ colored = "2.0.4"
thiserror = "1.0.50"
fuel-abi-types = "0.7.0"
hypersync-client = "1.2.0"
hyperfuel-client = "4.0.0"
polars-arrow = "0.42"
hypersync-client-solana = "0.0.7"
hypersync-solana-net-types = "0.0.7"
faster-hex = "0.9"
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/fuel_hypersync_source/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
use anyhow::{Context, Result};
use napi_derive::napi;

/// Configuration for the HyperFuel client.
#[napi(object)]
#[derive(Default, Clone)]
pub struct ClientConfig {
pub url: String,
pub api_token: String,
}

impl TryFrom<ClientConfig> for hyperfuel_client::ClientConfig {
type Error = anyhow::Error;

fn try_from(config: ClientConfig) -> Result<Self> {
// hyperfuel_client::ClientConfig holds a `url::Url`; go through serde so
// it parses the (already validated) endpoint without us taking a direct
// dependency on the url crate.
let json = serde_json::json!({
"url": config.url,
"api_token": config.api_token,
// Retries are handled by the indexer, not the binary client.
"max_num_retries": 0,
});
serde_json::from_value(json).context("build hyperfuel client config")
}
}
96 changes: 96 additions & 0 deletions packages/cli/src/fuel_hypersync_source/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use anyhow::Context;
use napi_derive::napi;

mod config;
mod query;
mod types;

use config::ClientConfig;
use query::Query;
use types::{convert_response, ConvertError, QueryResponse};

#[napi]
pub struct HyperfuelClient {
inner: hyperfuel_client::Client,
}

#[napi]
impl HyperfuelClient {
#[napi(factory)]
pub fn new(cfg: ClientConfig, user_agent: String) -> napi::Result<HyperfuelClient> {
let client_config: hyperfuel_client::ClientConfig =
cfg.try_into().context("build config").map_err(map_err)?;
let inner = hyperfuel_client::Client::new_with_agent(client_config, user_agent)
.context("build client")
.map_err(map_err)?;
Ok(HyperfuelClient { inner })
}

#[napi]
pub async fn get_height(&self) -> napi::Result<i64> {
let height = self
.inner
.get_height()
.await
.map_err(|e| request_err("Failed to get HyperFuel height", e))?;
height.try_into().context("convert height").map_err(map_err)
}

#[napi]
pub async fn get_selected_data(&self, query: Query) -> napi::Result<QueryResponse> {
let query: hyperfuel_client::net_types::Query =
query.try_into().context("parse query").map_err(map_err)?;
let res = self
.inner
.get_arrow(&query)
.await
.map_err(|e| request_err("Failed to get data from HyperFuel", e))?;
convert_response(res).map_err(convert_error_to_napi)
}
}

/// The client embeds a `{:?}` debug dump in its error message; keep only the
/// first line so it stays readable when the indexer surfaces it on retries.
fn request_err(prefix: &str, e: anyhow::Error) -> napi::Error {
let message = format!("{e}");
let summary = message.lines().next().unwrap_or(message.as_str());
napi::Error::from_reason(format!("{prefix}: {summary}"))
}

/// Encodes `ConvertError::MissingFields` as a JSON payload in the napi
/// error's message — the same protocol as hypersync_source, which the
/// ReScript side recovers via JSON.parse and a `kind` dispatch.
fn convert_error_to_napi(err: ConvertError) -> napi::Error {
match err {
ConvertError::MissingFields(fields) => {
let payload = serde_json::json!({
"kind": "MissingFields",
"fields": fields,
})
.to_string();
napi::Error::new(napi::Status::InvalidArg, payload)
}
ConvertError::Other(e) => map_err(e),
}
}

fn map_err(e: anyhow::Error) -> napi::Error {
napi::Error::from_reason(format!("{:?}", e))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn convert_error_serializes_as_expected_json() {
let err =
ConvertError::MissingFields(vec!["receipt.txId".to_string(), "block.time".to_string()]);
let napi_err = convert_error_to_napi(err);
let parsed: serde_json::Value =
serde_json::from_str(&napi_err.reason).expect("payload must be JSON");
assert_eq!(parsed["kind"], "MissingFields");
assert_eq!(parsed["fields"][0], "receipt.txId");
assert_eq!(parsed["fields"][1], "block.time");
}
}
102 changes: 102 additions & 0 deletions packages/cli/src/fuel_hypersync_source/query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use anyhow::{anyhow, Context, Result};
use hyperfuel_client::format::{Hash, Hex};
use hyperfuel_client::net_types;
use napi::bindgen_prelude::BigInt;
use napi_derive::napi;

/// Query for retrieving Fuel receipts and their blocks.
#[napi(object)]
#[derive(Default)]
pub struct Query {
pub from_block: i64,
#[napi(js_name = "toBlock")]
pub to_block_exclusive: Option<i64>,
pub receipts: Option<Vec<ReceiptSelection>>,
pub field_selection: FieldSelection,
}

#[napi(object)]
#[derive(Default)]
pub struct ReceiptSelection {
pub root_contract_id: Option<Vec<String>>,
pub receipt_type: Option<Vec<u8>>,
pub rb: Option<Vec<BigInt>>,
pub tx_status: Option<Vec<u8>>,
}

#[napi(object)]
#[derive(Default)]
pub struct FieldSelection {
pub block: Option<Vec<String>>,
pub receipt: Option<Vec<String>>,
}

fn parse_hashes(v: Option<Vec<String>>) -> Result<Vec<Hash>> {
v.unwrap_or_default()
.into_iter()
.map(|s| Hash::decode_hex(&s).map_err(|e| anyhow!("failed to parse hash {s}: {e:?}")))
.collect()
}

fn bigints_to_u64(v: Option<Vec<BigInt>>) -> Result<Vec<u64>> {
v.unwrap_or_default()
.into_iter()
.map(|b| {
let (sign_bit, value, lossless) = b.get_u64();
if sign_bit || !lossless {
anyhow::bail!("rb filter value must be an unsigned 64-bit integer");
}
Ok(value)
})
.collect()
}

impl TryFrom<ReceiptSelection> for net_types::ReceiptSelection {
type Error = anyhow::Error;

fn try_from(s: ReceiptSelection) -> Result<Self> {
Ok(net_types::ReceiptSelection {
root_contract_id: parse_hashes(s.root_contract_id)?,
receipt_type: s.receipt_type.unwrap_or_default(),
rb: bigints_to_u64(s.rb).context("parse rb filter")?,
tx_status: s.tx_status.unwrap_or_default(),
..Default::default()
})
}
}

impl From<FieldSelection> for net_types::FieldSelection {
fn from(f: FieldSelection) -> Self {
net_types::FieldSelection {
block: f.block.unwrap_or_default().into_iter().collect(),
receipt: f.receipt.unwrap_or_default().into_iter().collect(),
..Default::default()
}
}
}

impl TryFrom<Query> for net_types::Query {
type Error = anyhow::Error;

fn try_from(q: Query) -> Result<Self> {
let from_block = u64::try_from(q.from_block).context("from_block must be >= 0")?;
let to_block = q
.to_block_exclusive
.map(|b| u64::try_from(b).context("toBlock must be >= 0"))
.transpose()?;
let receipts = q
.receipts
.unwrap_or_default()
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>>>()?;

Ok(net_types::Query {
from_block,
to_block,
receipts,
field_selection: q.field_selection.into(),
..Default::default()
})
}
}
Loading