Skip to content
Open
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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ reqwest = { version = "0.12", features = ["json", "gzip"] }
axum = "0.8.9"
tower-http = { version = "0.6.10", features = ["fs", "cors", "trace", "compression-gzip"] }
duckdb = { version = "1.10", features = ["bundled"] }
eot = "0.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "chrono"] }
hex = "0.4.3"
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,48 @@ Each parquet file contains contract creations with this schema:
| code_hash | binary | Keccak256 of deployed code |
| chain_id | uint64 | Chain ID |

## Opcode queries

Run the decoder after loading or extracting contract data. It stores compact,
deduplicated opcode sets for both constructor and deployed runtime bytecode:

```bash
blink decode --data-dir ./data/blink
```

The SQL explorer exposes `contract_opcodes` with one row per contract and code
kind. Opcode names are canonical uppercase mnemonics supplied by `eot`.

```sql
-- Constructor/creation bytecode only
SELECT address
FROM contract_opcodes
WHERE code_kind = 'creation'
AND list_contains(opcodes, 'CREATE2');

-- Deployed runtime bytecode only
SELECT address
FROM contract_opcodes
WHERE code_kind = 'runtime'
AND list_contains(opcodes, 'DELEGATECALL');

-- Either kind
SELECT DISTINCT address
FROM contract_opcodes
WHERE list_contains(opcodes, 'SELFDESTRUCT');
```

The HTTP SQL explorer returns at most 1,000 rows per request. Its response
includes `offset` and `has_more`; pass the next `offset` to `POST /api/query`
to page through every match. Use a deterministic `ORDER BY` when paging, and
omit an SQL `LIMIT` if the full result set should remain available.

`PUSH1` through `PUSH32` payload bytes are not interpreted as opcodes. Runtime
compiler metadata is excluded when recognized. Creation analysis follows
statically discoverable control-flow from program counter zero so embedded
runtime code and constructor arguments are not indexed as constructor
instructions merely because they contain opcode-shaped bytes.

## Performance

blink is designed for speed:
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub enum Commands {
Contracts(ContractsArgs),
/// Load local contract datasets into Blink
Load(LoadArgs),
/// Decode bytecode locally: compiler version, language, ERC standards, proxy detection
/// Decode bytecode locally: metadata plus creation/runtime opcode sets
Decode(DecodeArgs),
/// Build persistent block-time checkpoints from configured RPCs
Checkpoints(CheckpointsArgs),
Expand Down
93 changes: 91 additions & 2 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ pub struct Db {
read_only: bool,
}

pub(crate) struct LiveCreationBytecode {
pub chain_id: u64,
pub block_number: u32,
pub create_index: u32,
pub contract_address: Vec<u8>,
pub bytecode_hash: Vec<u8>,
pub code: Vec<u8>,
}

impl Db {
pub fn open_with_mode(data_dir: &Path, contracts_glob: &str, read_only: bool) -> Result<Self> {
Self::open(
Expand Down Expand Up @@ -308,11 +317,91 @@ impl Db {
})
.map(|(code_hash, code)| {
let metadata = crate::decode::bytecode_meta::analyze(&code);
(code_hash, metadata)
let opcodes = crate::decode::opcodes::opcode_names(
&code,
crate::decode::opcodes::CodeKind::Runtime,
);
(code_hash, metadata, opcodes)
})
.collect::<Vec<_>>();
let mut conn = writer.blocking_lock();
crate::decode::flush_hash_batch(&mut conn, &analyzed)
let metadata = analyzed
.iter()
.map(|(hash, metadata, _)| (hash.clone(), metadata.clone()))
.collect::<Vec<_>>();
let opcode_sets = analyzed
.into_iter()
.map(|(bytecode_hash, _, opcodes)| crate::decode::OpcodeSetRow {
bytecode_hash,
code_kind: crate::decode::opcodes::CodeKind::Runtime,
opcodes,
})
.collect::<Vec<_>>();
let metadata_inserted = crate::decode::flush_hash_batch(&mut conn, &metadata)?;
let opcode_sets_inserted = crate::decode::flush_opcode_sets(&mut conn, &opcode_sets)?;
Ok(metadata_inserted.max(opcode_sets_inserted))
})
.await
.map_err(|error| anyhow!("join error: {error}"))?
}

pub(crate) async fn decode_live_creation_bytecodes(
&self,
bytecodes: Vec<LiveCreationBytecode>,
source_file: String,
) -> Result<u64> {
if self.read_only || bytecodes.is_empty() {
return Ok(0);
}
let writer = self.writer.clone();
tokio::task::spawn_blocking(move || -> Result<u64> {
let analyzed = bytecodes
.into_par_iter()
.filter(|bytecode| {
bytecode.bytecode_hash.len() == 32
&& bytecode.contract_address.len() == 20
&& !bytecode.code.is_empty()
&& bytecode.code.len() <= 65_536
})
.map(|bytecode| {
let opcodes = crate::decode::opcodes::opcode_names(
&bytecode.code,
crate::decode::opcodes::CodeKind::Creation,
);
(
crate::decode::OpcodeSetRow {
bytecode_hash: bytecode.bytecode_hash.clone(),
code_kind: crate::decode::opcodes::CodeKind::Creation,
opcodes,
},
crate::decode::CreationBytecodeLink {
chain_id: bytecode.chain_id,
block_number: bytecode.block_number,
create_index: bytecode.create_index,
contract_address: bytecode.contract_address,
bytecode_hash: bytecode.bytecode_hash,
},
)
})
.collect::<Vec<_>>();
let mut seen = std::collections::HashSet::with_capacity(analyzed.len());
let opcode_sets = analyzed
.iter()
.filter(|(set, _)| seen.insert(set.bytecode_hash.as_slice()))
.map(|(set, _)| crate::decode::OpcodeSetRow {
bytecode_hash: set.bytecode_hash.clone(),
code_kind: set.code_kind,
opcodes: set.opcodes.clone(),
})
.collect::<Vec<_>>();
let links = analyzed
.into_iter()
.map(|(_, link)| link)
.collect::<Vec<_>>();
let mut conn = writer.blocking_lock();
let inserted = crate::decode::flush_opcode_sets(&mut conn, &opcode_sets)?;
crate::decode::flush_creation_links(&mut conn, &source_file, &links)?;
Ok(inserted)
})
.await
.map_err(|error| anyhow!("join error: {error}"))?
Expand Down
17 changes: 16 additions & 1 deletion src/db/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,23 @@ impl Db {
sql: String,
limit: u32,
chain_id: Option<u64>,
) -> Result<super::SqlQueryResult> {
self.query_sql_page(sql, limit, 0, chain_id).await
}

pub async fn query_sql_page(
&self,
sql: String,
limit: u32,
offset: u64,
chain_id: Option<u64>,
) -> Result<super::SqlQueryResult> {
let normalized = sql::normalize_read_only_sql(&sql)?;
let limit = limit.clamp(1, 1_000);
let fetch_limit = limit + 1;
self.run_read(move |conn| {
let started = Instant::now();
let wrapped = sql::wrap_dashboard_query(&normalized, limit, chain_id);
let wrapped = sql::wrap_dashboard_query(&normalized, fetch_limit, offset, chain_id);
let mut stmt = conn.prepare(&wrapped).context("prepare dashboard query")?;
let mut rows = stmt.query([]).context("execute dashboard query")?;
let (columns, column_count) = {
Expand All @@ -242,6 +253,8 @@ impl Db {
}
out.push(values);
}
let has_more = out.len() > limit as usize;
out.truncate(limit as usize);
let elapsed_ms = started.elapsed().as_millis();
if elapsed_ms >= 1_000 {
// The HTTP-level slow log can't see the request body; name
Expand All @@ -258,6 +271,8 @@ impl Db {
row_count: out.len(),
rows: out,
limit,
offset,
has_more,
elapsed_ms,
})
})
Expand Down
56 changes: 53 additions & 3 deletions src/db/sql.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Read-only SQL guard rails and JSON conversion for `POST /api/query`.

use anyhow::{anyhow, Result};
use duckdb::types::ValueRef;
use duckdb::types::{Value as DuckValue, ValueRef};
use serde_json::{Number, Value};

#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
Expand All @@ -11,6 +11,8 @@ pub struct SqlQueryResult {
pub rows: Vec<Vec<Value>>,
pub row_count: usize,
pub limit: u32,
pub offset: u64,
pub has_more: bool,
pub elapsed_ms: u128,
}

Expand Down Expand Up @@ -111,21 +113,34 @@ pub(crate) fn normalize_read_only_sql(sql: &str) -> Result<String> {
Ok(without_trailing_semicolon.to_string())
}

pub(crate) fn wrap_dashboard_query(sql: &str, limit: u32, chain_id: Option<u64>) -> String {
pub(crate) fn wrap_dashboard_query(
sql: &str,
limit: u32,
offset: u64,
chain_id: Option<u64>,
) -> String {
match chain_id {
Some(chain_id) => format!(
r#"
WITH contract_metadata AS (
SELECT *
FROM contract_metadata_all
WHERE chain_id = {chain_id}
),
contract_opcodes AS (
SELECT *
FROM contract_opcodes_all
WHERE chain_id = {chain_id}
)
SELECT *
FROM ({sql}) AS _blink_dashboard_query
LIMIT {limit}
OFFSET {offset}
"#
),
None => format!("SELECT * FROM ({sql}) AS _blink_dashboard_query LIMIT {limit}"),
None => {
format!("SELECT * FROM ({sql}) AS _blink_dashboard_query LIMIT {limit} OFFSET {offset}")
}
}
}

Expand Down Expand Up @@ -166,6 +181,41 @@ pub(crate) fn value_ref_to_json(value: ValueRef<'_>) -> Value {
days,
nanos,
} => Value::String(format!("{months} months {days} days {nanos} ns")),
ValueRef::List(..) | ValueRef::Array(..) => duckdb_value_to_json(value.to_owned()),
other => Value::String(format!("{other:?}")),
}
}

fn duckdb_value_to_json(value: DuckValue) -> Value {
match value {
DuckValue::Null => Value::Null,
DuckValue::Boolean(value) => Value::Bool(value),
DuckValue::TinyInt(value) => Value::Number(Number::from(value)),
DuckValue::SmallInt(value) => Value::Number(Number::from(value)),
DuckValue::Int(value) => Value::Number(Number::from(value)),
DuckValue::BigInt(value) => Value::Number(Number::from(value)),
DuckValue::HugeInt(value) => i64::try_from(value)
.map(Number::from)
.map(Value::Number)
.unwrap_or_else(|_| Value::String(value.to_string())),
DuckValue::UTinyInt(value) => Value::Number(Number::from(value)),
DuckValue::USmallInt(value) => Value::Number(Number::from(value)),
DuckValue::UInt(value) => Value::Number(Number::from(value)),
DuckValue::UBigInt(value) => Value::Number(Number::from(value)),
DuckValue::Float(value) => Number::from_f64(value as f64)
.map(Value::Number)
.unwrap_or(Value::Null),
DuckValue::Double(value) => Number::from_f64(value)
.map(Value::Number)
.unwrap_or(Value::Null),
DuckValue::Text(value) | DuckValue::Enum(value) => Value::String(value),
DuckValue::Blob(value) => Value::String(format!("0x{}", hex::encode(value))),
DuckValue::List(values) | DuckValue::Array(values) => Value::Array(
values
.into_iter()
.map(duckdb_value_to_json)
.collect::<Vec<_>>(),
),
other => Value::String(format!("{other:?}")),
}
}
Loading
Loading