diff --git a/wasm-wrappers/fdw/infura_fdw/Cargo.toml b/wasm-wrappers/fdw/infura_fdw/Cargo.toml new file mode 100644 index 00000000..c182ee4b --- /dev/null +++ b/wasm-wrappers/fdw/infura_fdw/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "infura_fdw" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen-rt = "0.26.0" +serde_json = "1.0" + +[package.metadata.component] +package = "supabase:infura-fdw" + +[package.metadata.component.dependencies] + +[package.metadata.component.target] +path = "wit" + +[package.metadata.component.target.dependencies] +"supabase:wrappers" = { path = "../../wit" } diff --git a/wasm-wrappers/fdw/infura_fdw/README.md b/wasm-wrappers/fdw/infura_fdw/README.md new file mode 100644 index 00000000..cc7d5bcc --- /dev/null +++ b/wasm-wrappers/fdw/infura_fdw/README.md @@ -0,0 +1,123 @@ +--- +source: https://docs.infura.io/ +documentation: https://docs.infura.io/api/networks/ethereum +author: supabase +tags: + - wasm + - official +--- + +# Infura + +Infura provides blockchain infrastructure that allows applications to access Ethereum network data through JSON-RPC APIs. + +## Available Versions + +| Version | Release Date | +| ------- | ----------- | +| 0.1.0 | TBD | + +## Preparation + +1. Create an Infura account and project +2. Get your Project ID from the project settings +3. Create a foreign data wrapper and server + +```sql +create extension if not exists wrappers with schema extensions; + +create foreign data wrapper wasm_wrapper + handler wasm_handler + validator wasm_validator; + +-- Using direct project ID +create server infura_server + foreign data wrapper wasm_wrapper + options ( + project_id 'your-project-id' + ); + +-- Using vault for project ID +insert into vault.secrets (name, secret) +values ('infura', 'your-project-id') +returning key_id; + +create server infura_server + foreign data wrapper wasm_wrapper + options ( + project_id_id 'key-id-from-above' + ); +``` + +## Entities + +### Block Number + +```sql +create foreign table infura.block_number ( + number bigint +) + server infura_server + options ( + table 'eth_blockNumber' + ); +``` + +### Blocks + +```sql +create foreign table infura.blocks ( + number bigint, + hash text, + parent_hash text, + nonce text, + miner text, + difficulty bigint, + total_difficulty bigint, + size bigint, + gas_limit bigint, + gas_used bigint, + timestamp bigint +) + server infura_server + options ( + table 'eth_getBlockByNumber' + ); +``` + +## Supported Data Types + +| Postgres Type | Infura Type | Description | +| ------------ | ----------- | ----------- | +| bigint | hex number | Used for block numbers, gas values, and timestamps | +| text | string/hex | Used for hashes, addresses, and other hex strings | + +## Limitations + +- Only supports HTTP endpoints (no WebSocket support) +- Rate limits apply based on your Infura plan +- Some complex queries may timeout due to blockchain data size +- Currently only supports eth_blockNumber and eth_getBlockByNumber methods +- All numeric values are returned as bigint, which may not capture the full range of some Ethereum values + +## Examples + +```sql +-- Get latest block number +SELECT number FROM infura.block_number; + +-- Get block details +SELECT number, hash, miner, timestamp +FROM infura.blocks +WHERE number = (SELECT number FROM infura.block_number); + +-- Get gas usage over time +SELECT + number, + gas_used, + gas_limit, + (gas_used::float / gas_limit::float * 100)::numeric(5,2) as gas_usage_percent +FROM infura.blocks +ORDER BY number DESC +LIMIT 10; +``` diff --git a/wasm-wrappers/fdw/infura_fdw/src/lib.rs b/wasm-wrappers/fdw/infura_fdw/src/lib.rs new file mode 100644 index 00000000..d4306a15 --- /dev/null +++ b/wasm-wrappers/fdw/infura_fdw/src/lib.rs @@ -0,0 +1,196 @@ +#[cfg(test)] +pub use self::InfuraFdw; + +use serde_json::{json, Value as JsonValue}; +use wit_bindgen_rt::*; + +struct InfuraFdw { + base_url: String, + headers: Vec<(String, String)>, + project_id: String, + table: String, + src_rows: Vec, + src_idx: usize, +} + +impl InfuraFdw { + // Convert hex string to decimal + fn hex_to_decimal(hex: &str) -> Result { + i64::from_str_radix(hex.trim_start_matches("0x"), 16) + .map_err(|e| format!("Failed to convert hex to decimal: {}", e)) + } + + // Create JSON-RPC request + fn create_request(&self, method: &str, params: Vec) -> Result { + let url = format!("{}/{}", self.base_url, self.project_id); + let body = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1 + }); + + let mut headers = self.headers.clone(); + headers.push(("Content-Type".to_string(), "application/json".to_string())); + + Ok(http::Request { + method: http::Method::Post, + url, + headers, + body: body.to_string(), + }) + } + + // Create new instance + fn new(base_url: String, project_id: String) -> Self { + Self { + base_url, + headers: Vec::new(), + project_id, + table: String::new(), + src_rows: Vec::new(), + src_idx: 0, + } + } + + // Process API response + fn process_response(&mut self, response: &str) -> Result<(), String> { + let value: JsonValue = serde_json::from_str(response) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if let Some(error) = value.get("error") { + return Err(format!("API error: {}", error)); + } + + if let Some(result) = value.get("result") { + self.src_rows = vec![result.clone()]; + self.src_idx = 0; + Ok(()) + } else { + Err("No result in response".to_string()) + } + } +} + +wit_bindgen::generate!({ + path: "../../wit", + world: "wrappers", +}); + +struct Component; + +impl wrappers::Wrappers for Component { + fn create_fdw() -> Box { + Box::new(InfuraFdw::new( + String::from("https://mainnet.infura.io/v3"), + String::new(), // project_id will be set from options + )) + } +} + +impl wrappers::Fdw for InfuraFdw { + fn begin_scan( + &mut self, + quals: Vec, + attrs: Vec, + table_name: String, + ) -> Result<(), String> { + self.table = table_name; + + let method = match self.table.as_str() { + "eth_getBlockByNumber" => "eth_getBlockByNumber", + "eth_blockNumber" => "eth_blockNumber", + _ => return Err(format!("Unsupported table: {}", self.table)), + }; + + let request = self.create_request(method, vec![])?; + let response = http::send_request(&request) + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if response.status_code != 200 { + return Err(format!("HTTP error: {}", response.status_code)); + } + + self.process_response(&response.body) + } + + fn iter_scan(&mut self) -> Result>, String> { + if self.src_idx >= self.src_rows.len() { + return Ok(None); + } + + let row = &self.src_rows[self.src_idx]; + self.src_idx += 1; + + let mut data = Vec::new(); + match self.table.as_str() { + "eth_blockNumber" => { + if let Some(block_num) = row.as_str() { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(block_num)?)); + } + } + "eth_getBlockByNumber" => { + if let Some(obj) = row.as_object() { + // Convert block data to appropriate types + if let Some(num) = obj.get("number").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(num)?)); + } + if let Some(hash) = obj.get("hash").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::String(hash.to_string())); + } + if let Some(parent) = obj.get("parentHash").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::String(parent.to_string())); + } + if let Some(nonce) = obj.get("nonce").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::String(nonce.to_string())); + } + if let Some(miner) = obj.get("miner").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::String(miner.to_string())); + } + if let Some(difficulty) = obj.get("difficulty").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(difficulty)?)); + } + if let Some(total) = obj.get("totalDifficulty").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(total)?)); + } + if let Some(size) = obj.get("size").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(size)?)); + } + if let Some(gas_limit) = obj.get("gasLimit").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(gas_limit)?)); + } + if let Some(gas_used) = obj.get("gasUsed").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(gas_used)?)); + } + if let Some(timestamp) = obj.get("timestamp").and_then(|v| v.as_str()) { + data.push(wrappers::Datum::I64(Self::hex_to_decimal(timestamp)?)); + } + } + } + _ => return Err(format!("Unsupported table: {}", self.table)), + } + + Ok(Some(data)) + } + + fn end_scan(&mut self) -> Result<(), String> { + self.src_rows.clear(); + self.src_idx = 0; + Ok(()) + } + + fn validator(&self, options: Vec<(String, Option)>) -> Result<(), String> { + for (key, value) in options { + match key.as_str() { + "project_id" => { + if value.is_none() { + return Err("project_id is required".to_string()); + } + self.project_id = value.unwrap(); + } + _ => return Err(format!("Unknown option: {}", key)), + } + } + Ok(()) + } +} diff --git a/wasm-wrappers/fdw/infura_fdw/tests/lib.rs b/wasm-wrappers/fdw/infura_fdw/tests/lib.rs new file mode 100644 index 00000000..2c8fad1f --- /dev/null +++ b/wasm-wrappers/fdw/infura_fdw/tests/lib.rs @@ -0,0 +1,75 @@ +use serde_json::json; + +// Import the crate being tested +use infura_fdw::*; + +#[test] +fn test_hex_to_decimal() { + let fdw = InfuraFdw::new( + String::from("https://mainnet.infura.io/v3"), + String::from("test-project-id"), + ); + + assert_eq!(fdw.hex_to_decimal("0x1234").unwrap(), 4660); + assert_eq!(fdw.hex_to_decimal("0xabcd").unwrap(), 43981); + assert_eq!(fdw.hex_to_decimal("0x0").unwrap(), 0); + assert!(fdw.hex_to_decimal("invalid").is_err()); +} + +#[test] +fn test_create_request() { + let fdw = InfuraFdw::new( + String::from("https://mainnet.infura.io/v3"), + String::from("test-project-id"), + ); + + // Test eth_blockNumber request + let req = fdw.create_request("eth_blockNumber", vec![]).unwrap(); + assert_eq!(req.method, http::Method::Post); + assert_eq!(req.url, "https://mainnet.infura.io/v3/test-project-id"); + + let body: serde_json::Value = serde_json::from_str(&req.body).unwrap(); + assert_eq!(body["jsonrpc"], "2.0"); + assert_eq!(body["method"], "eth_blockNumber"); + assert!(body["params"].as_array().unwrap().is_empty()); + + // Test eth_getBlockByNumber request + let req = fdw.create_request("eth_getBlockByNumber", vec!["0x1234".to_string(), "true".to_string()]).unwrap(); + assert_eq!(req.method, http::Method::Post); + + let body: serde_json::Value = serde_json::from_str(&req.body).unwrap(); + assert_eq!(body["method"], "eth_getBlockByNumber"); + assert_eq!(body["params"][0], "0x1234"); + assert_eq!(body["params"][1], "true"); +} + +#[test] +fn test_process_response() { + let mut fdw = InfuraFdw::new( + String::from("https://mainnet.infura.io/v3"), + String::from("test-project-id"), + ); + + // Test block number response + let response = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1234567" + }).to_string(); + + assert!(fdw.process_response(&response).is_ok()); + assert_eq!(fdw.src_rows.len(), 1); + assert_eq!(fdw.src_rows[0].as_str().unwrap(), "0x1234567"); + + // Test error response + let error_response = json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "Method not found" + } + }).to_string(); + + assert!(fdw.process_response(&error_response).is_err()); +} diff --git a/wrappers/dockerfiles/wasm/server.py b/wrappers/dockerfiles/wasm/server.py index beeb0f1a..16eb0ce5 100644 --- a/wrappers/dockerfiles/wasm/server.py +++ b/wrappers/dockerfiles/wasm/server.py @@ -320,6 +320,42 @@ def do_POST(self): "success": true } ''' + elif fdw == "infura": + content_length = int(self.headers['Content-Length']) + request_body = self.rfile.read(content_length).decode('utf-8') + request = json.loads(request_body) + method = request.get('method') + + responses = { + 'eth_blockNumber': { + 'jsonrpc': '2.0', + 'id': request.get('id', 1), + 'result': '0x1234567' + }, + 'eth_getBlockByNumber': { + 'jsonrpc': '2.0', + 'id': request.get('id', 1), + 'result': { + 'number': '0x1234567', + 'hash': '0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + 'parentHash': '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'nonce': '0x123456', + 'miner': '0xdef0123456789abcdef0123456789abcdef012345', + 'difficulty': '0x5678', + 'totalDifficulty': '0x12345678', + 'size': '0x1234', + 'gasLimit': '0x5678', + 'gasUsed': '0x1234', + 'timestamp': '0x5678' + } + } + } + + body = json.dumps(responses.get(method, { + 'jsonrpc': '2.0', + 'id': request.get('id', 1), + 'error': {'code': -32601, 'message': 'Method not found'} + })) else: self.send_response(404) return