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
76 changes: 74 additions & 2 deletions gnd/docs/gnd-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,56 @@ Event parameters are automatically ABI-encoded based on the signature. Supported
}
```

## Transaction Receipts

Mock receipts are constructed for every log trigger and attached only to handlers that declare `receipt: true` in the manifest, mirroring production behaviour. Handlers without `receipt: true` receive a null receipt — the same as on a real node.

**Limitation:** Only `receipt.logs` reflects your test data. All other receipt fields (`from`, `to`, `gas_used`, `status`, etc.) are hardcoded stubs and do not correspond to real transaction data. If your handler reads those fields, the values will be fixed defaults regardless of what you put in the test JSON.

### How receipts are built

Every event gets a mock receipt attached automatically. The key rule is **`txHash` grouping**:

- Events sharing the same `txHash` share **one receipt** — `event.receipt!.logs` contains all of their logs in declaration order.
- Events without an explicit `txHash` each get a unique auto-generated hash (`keccak256(block_number || log_index)`), so each gets its own single-log receipt.

### Example: Two events sharing a receipt

```json
{
"events": [
{
"address": "0x1234...",
"event": "Transfer(address indexed from, address indexed to, uint256 value)",
"params": { "from": "0xaaaa...", "to": "0xbbbb...", "value": "100" },
"txHash": "0xdeadbeef0000000000000000000000000000000000000000000000000000000"
},
{
"address": "0x1234...",
"event": "Transfer(address indexed from, address indexed to, uint256 value)",
"params": { "from": "0xbbbb...", "to": "0xcccc...", "value": "50" },
"txHash": "0xdeadbeef0000000000000000000000000000000000000000000000000000000"
}
]
}
```

Both handlers receive a receipt where `receipt.logs` has two entries, in declaration order.

### Mock receipt defaults

| Field | Value |
|-------|-------|
| `status` | success |
| `cumulative_gas_used` | `21000` |
| `gas_used` | `21000` |
| transaction type | `2` (EIP-1559) |
| `from` | `0x000...000` |
| `to` | `null` |
| `effective_gas_price` | `0` |

Handlers without `receipt: true` in the manifest are unaffected — they never access `event.receipt`.

## Block Handlers

Block handlers are **automatically triggered** for every block. You don't need to specify block triggers in the JSON.
Expand Down Expand Up @@ -599,7 +649,7 @@ my-subgraph/
| Block handlers (all filters) | ✅ Supported |
| eth_call mocking | ✅ Supported |
| Dynamic/template data sources | ✅ Supported |
| Transaction receipts (`receipt: true`) | ❌ Not implemented — handlers get `null` |
| Transaction receipts (`receipt: true`) | ⚠️ Partial — `receipt.logs` is populated and grouped by `txHash`; other fields (gas, from, to, etc.) are hardcoded stubs (see [Transaction Receipts](#transaction-receipts)) |
| File data sources / IPFS mocking | ❌ Not implemented |
| Call triggers (traces) | ❌ Not implemented |
| `--json` CI output | ❌ Not implemented |
Expand Down Expand Up @@ -717,7 +767,7 @@ GraphQL queries → Assertions
- **Real WASM runtime:** Uses `EthereumRuntimeAdapterBuilder` with real `ethereum.call` host function
- **Pre-populated call cache:** `eth_call` responses are cached before indexing starts
- **No IPFS for manifest:** Uses `FileLinkResolver` to load manifest/WASM from build directory
- **Dummy RPC adapter:** Registered at `http://0.0.0.0:0` for capability lookup; never actually called
- **Dummy RPC adapter:** Registered at `http://0.0.0.0:0` — exists so the runtime can resolve an adapter with the required capabilities. If a mapping makes an `ethereum.call` that has no matching mock in `ethCalls`, the call misses the cache and falls through to this dummy adapter. The connection is refused immediately (port 0 is invalid), which graph-node treats as a possible reorg and restarts the block stream. The indexer then loops until the 60-second test timeout. See [Unmocked eth_call](#unmocked-eth_call-causes-60-second-timeout) in Troubleshooting.

## Troubleshooting

Expand Down Expand Up @@ -749,6 +799,28 @@ GraphQL queries → Assertions
2. Check function signature format: `"functionName(inputTypes)(returnTypes)"`
3. Ensure parameters are in correct order

### Unmocked eth_call Causes 60-Second Timeout

**Cause:** A mapping handler calls `ethereum.call` (directly or via a generated contract binding) for a call that has no matching entry in `ethCalls`. The call misses the pre-populated cache and is forwarded to the dummy RPC adapter at `http://0.0.0.0:0`. The connection is refused immediately, but graph-node interprets connection errors as a possible chain reorganisation and restarts the block stream instead of failing. The indexer loops indefinitely until the test runner's 60-second timeout expires.

**Symptom:** Test fails with `Sync timeout after 60s` with no indication of which call was missing.

**Fix:**
1. Add the missing call to `ethCalls` in your test block:
```json
"ethCalls": [
{
"address": "0xContractAddress",
"function": "myFunction(uint256):(address)",
"params": ["42"],
"returns": ["0xSomeAddress"]
}
]
```
2. If the call is not supposed to happen, check the mapping logic — a code path may be executing unexpectedly.

**Known limitation:** There is currently no fail-fast error for unmocked calls. The only signal is the timeout. A future improvement will make the dummy adapter panic immediately on a cache miss with a descriptive message.

### Block Handler Not Firing

**Cause:** Block handlers auto-fire, but might be outside data source's active range.
Expand Down
25 changes: 7 additions & 18 deletions gnd/src/commands/test/assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,10 @@ async fn run_single_assertion(
}
}

/// Reorder `actual` arrays to align with `expected`'s element ordering.
/// Reorder `actual` arrays to match `expected`'s element order for cleaner diffs.
///
/// When a test fails, the raw diff can be misleading if array elements appear
/// in a different order — every line shows as changed even if only one field
/// differs. This function reorders `actual` so that elements are paired with
/// their closest match in `expected`, producing a diff that highlights only
/// real value differences.
/// Without this, out-of-order elements show every field as changed even when
/// only one field differs.
pub(super) fn align_for_diff(
expected: &serde_json::Value,
actual: &serde_json::Value,
Expand Down Expand Up @@ -132,12 +129,8 @@ pub(super) fn align_for_diff(
}
}

/// Score how similar two JSON values are for use in [`align_for_diff`].
///
/// For objects, counts the number of fields whose values are equal in both.
/// A matching `"id"` field is weighted heavily (+100) since it is the
/// strongest signal that two objects represent the same entity.
/// For all other value types, returns 1 if equal, 0 otherwise.
/// Score JSON similarity for [`align_for_diff`].
/// Objects: matching `"id"` = 100, other equal fields = 1. Non-objects: 0 or 1.
fn json_similarity(a: &serde_json::Value, b: &serde_json::Value) -> usize {
match (a, b) {
(serde_json::Value::Object(a_obj), serde_json::Value::Object(b_obj)) => {
Expand All @@ -162,13 +155,9 @@ fn json_similarity(a: &serde_json::Value, b: &serde_json::Value) -> usize {
}
}

/// Compare two JSON values for equality (ignoring key ordering in objects).
/// Compare JSON values for equality, ignoring object key ordering.
///
/// Also handles string-vs-number coercion: GraphQL returns `BigInt` and
/// `BigDecimal` fields as JSON strings (e.g., `"1000000000000000000"`),
/// but test authors may write them as JSON numbers. This function treats
/// `String("123")` and `Number(123)` as equal when they represent the
/// same value.
/// Coerces string/number: `"123"` == `123` to handle GraphQL `BigInt`/`BigDecimal`.
fn json_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
match (a, b) {
(serde_json::Value::Null, serde_json::Value::Null) => true,
Expand Down
12 changes: 3 additions & 9 deletions gnd/src/commands/test/block_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,15 @@ impl BlockStreamBuilder<Chain> for StaticStreamBuilder {
}
}

/// A `Stream` that synchronously yields pre-defined blocks one at a time.
///
/// Each `poll_next` call returns the next block immediately (no async waiting).
/// When all blocks have been emitted, returns `None` to signal stream completion,
/// which tells the indexer that sync is done.
/// A `Stream` that yields pre-defined blocks synchronously.
/// Returns `None` when all blocks are emitted, signaling sync completion.
struct StaticStream {
blocks: Vec<BlockWithTriggers<Chain>>,
current_idx: usize,
}

impl StaticStream {
/// Create a new stream, optionally skipping past already-processed blocks.
///
/// `skip_to`: If `Some(i)`, start from block `i+1` (block `i` was already processed).
/// If `None`, start from the beginning.
/// `skip_to`: if `Some(i)`, start from block `i+1` (block `i` already processed).
fn new(blocks: Vec<BlockWithTriggers<Chain>>, skip_to: Option<usize>) -> Self {
Self {
blocks,
Expand Down
20 changes: 5 additions & 15 deletions gnd/src/commands/test/eth_calls.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
//! Pre-populates the eth_call cache with mock responses for `gnd test`.
//! Populates the eth_call cache with mock responses for `gnd test`.
//!
//! Function signatures use graph-node's convention: `name(inputs):(outputs)`
//! e.g. `"balanceOf(address):(uint256)"`, `"getReserves():(uint112,uint112,uint32)"`.
//! Call data is encoded using the same path as production graph-node, so cache
//! IDs match exactly what the runtime generates.
//! Signatures use graph-node's `name(inputs):(outputs)` convention.
//! Encoding matches production graph-node so cache IDs align with the runtime.

use super::schema::{MockEthCall, TestFile};
use super::trigger::json_to_sol_value;
Expand Down Expand Up @@ -92,16 +90,8 @@ fn encode_return_value(function_sig: &str, returns: &[serde_json::Value]) -> Res
.map_err(|e| anyhow!("Failed to encode return value: {}", e))
}

/// Convert a graph-node style function signature to alloy's expected format.
///
/// Graph-node uses `name(inputs):(outputs)` while alloy expects
/// `name(inputs) returns (outputs)`.
///
/// Examples:
/// - `"balanceOf(address):(uint256)"` → `"balanceOf(address) returns (uint256)"`
/// - `"name():(string)"` → `"name() returns (string)"`
/// - `"transfer(address,uint256)"` → `"transfer(address,uint256)"` (no change)
/// - `"balanceOf(address) returns (uint256)"` → unchanged (already alloy format)
/// Convert graph-node `name(inputs):(outputs)` to alloy `name(inputs) returns (outputs)`.
/// Passes through signatures already in alloy format or without outputs.
fn to_alloy_signature(sig: &str) -> String {
// If it already contains "returns", assume alloy format.
if sig.contains(" returns ") {
Expand Down
45 changes: 45 additions & 0 deletions gnd/src/commands/test/mock_arweave.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//! Mock Arweave resolver for `gnd test`.
//!
//! Replaces the real `ArweaveClient` with a map of pre-loaded txId → bytes.
//! Any txId not found in the map is sent to the `unresolved_tx` channel and
//! `ServerUnavailable` is returned, which causes the `PollingMonitor` to retry
//! with backoff. After sync, the runner drains the channel and reports missing
//! tx IDs as a clear test failure.

use std::collections::HashMap;

use async_trait::async_trait;
use graph::bytes::Bytes;
use graph::components::link_resolver::{ArweaveClientError, ArweaveResolver, FileSizeLimit};
use graph::data_source::offchain::Base64;
use tokio::sync::mpsc::UnboundedSender;

#[derive(Debug)]
pub struct MockArweaveResolver {
pub files: HashMap<String, Bytes>,
pub unresolved_tx: UnboundedSender<String>,
}

#[async_trait]
impl ArweaveResolver for MockArweaveResolver {
async fn get(&self, file: &Base64) -> Result<Vec<u8>, ArweaveClientError> {
self.get_with_limit(file, &FileSizeLimit::Unlimited).await
}

async fn get_with_limit(
&self,
file: &Base64,
_limit: &FileSizeLimit,
) -> Result<Vec<u8>, ArweaveClientError> {
match self.files.get(file.as_str()) {
Some(bytes) => Ok(bytes.to_vec()),
None => {
let _ = self.unresolved_tx.send(file.as_str().to_owned());
Err(ArweaveClientError::ServerUnavailable(format!(
"txId '{}' not found in mock 'arweaveFiles'",
file.as_str()
)))
}
}
}
}
46 changes: 46 additions & 0 deletions gnd/src/commands/test/mock_ipfs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Mock IPFS client for `gnd test`.
//!
//! Replaces the real `IpfsRpcClient` with a map of pre-loaded CID → bytes.
//! Any CID not found in the map is sent to the `unresolved_tx` channel and
//! an error is returned so the `OffchainMonitor` retries with backoff.
//! After sync, the runner drains the channel and reports missing CIDs.

use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use graph::bytes::Bytes;
use graph::ipfs::{
ContentPath, IpfsClient, IpfsError, IpfsMetrics, IpfsRequest, IpfsResponse, IpfsResult,
};
use tokio::sync::mpsc::UnboundedSender;

pub struct MockIpfsClient {
pub files: HashMap<ContentPath, Bytes>,
pub metrics: IpfsMetrics,
pub unresolved_tx: UnboundedSender<ContentPath>,
}

#[async_trait]
impl IpfsClient for MockIpfsClient {
fn metrics(&self) -> &IpfsMetrics {
&self.metrics
}

async fn call(self: Arc<Self>, req: IpfsRequest) -> IpfsResult<IpfsResponse> {
let path = match req {
IpfsRequest::Cat(p) | IpfsRequest::GetBlock(p) => p,
};

match self.files.get(&path) {
Some(bytes) => Ok(IpfsResponse::for_test(path, bytes.clone())),
None => {
let _ = self.unresolved_tx.send(path.clone());
Err(IpfsError::ContentNotAvailable {
path,
reason: anyhow::anyhow!("CID not found in mock 'files'"),
})
}
}
}
}
7 changes: 4 additions & 3 deletions gnd/src/commands/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ mod assertion;
mod block_stream;
mod eth_calls;
mod matchstick;
mod mock_arweave;
mod mock_chain;
mod mock_ipfs;
mod noop;
mod output;
mod runner;
Expand Down Expand Up @@ -110,8 +112,7 @@ pub async fn run_test(opt: TestOpt) -> Result<()> {
return matchstick::run(&opt).await;
}

// Build the subgraph first so the WASM and schema are available in build/.
// This mirrors what a user would do manually before running tests.
// Build the subgraph first (WASM and schema must be available in build/).
if !opt.skip_build {
step(Step::Generate, "Building subgraph");
let build_opt = crate::commands::BuildOpt {
Expand Down Expand Up @@ -170,7 +171,7 @@ pub async fn run_test(opt: TestOpt) -> Result<()> {
}
};

match runner::run_single_test(&opt, &manifest_info, &test_file).await {
match runner::run_single_test(&opt, &manifest_info, &test_file, &path).await {
Ok(result) => {
output::print_test_result(&test_file.name, &result);
if result.is_passed() {
Expand Down
10 changes: 4 additions & 6 deletions gnd/src/commands/test/noop.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
//! Noop/stub trait implementations for the mock `Chain`.
//! Noop trait implementations for the mock `Chain`.
//!
//! These types satisfy the trait bounds required by the `Chain` constructor
//! but are never called during normal test execution because:
//! - Triggers are provided directly via `StaticStreamBuilder` (no scanning needed)
//! - The real `EthereumRuntimeAdapterBuilder` is used for host functions
//! (ethereum.call, ethereum.getBalance, ethereum.hasCode), backed by the call cache
//! Satisfy `Chain` constructor bounds but are never called:
//! - Triggers come from `StaticStreamBuilder` (no scanning)
//! - Host functions use `EthereumRuntimeAdapterBuilder` with the eth_call cache

use async_trait::async_trait;
use graph::blockchain::block_stream::{BlockRefetcher, BlockWithTriggers, FirehoseCursor};
Expand Down
Loading
Loading