Skip to content

Commit 3787b76

Browse files
Gnd test improvements (#6442)
* gnd: implement transaction receipt support for gnd test Build mock receipts from block events and attach them to log triggers. Events sharing the same txHash share a receipt whose logs contains all their logs in declaration order; events without an explicit txHash each get a unique auto-generated hash and their own single-log receipt. Receipts are only attached to logs whose event selector (topic0) matches a handler that declares receipt: true in the manifest, mirroring production behaviour where graph-node only fetches receipts from the RPC for those handlers. The selector is computed using the same normalisation as graph-node's MappingEventHandler::topic0() to handle the manifest's indexed-before-type convention (e.g. Transfer(indexed address,...)). Most receipt fields (gas, from, to, status) are hardcoded stubs; only receipt.logs reflects the actual test data. * gnd: fix txHash deserialization in test schema * gnd: add mock IPFS client for file data source testing Replaces the dummy IpfsRpcClient with a MockIpfsClient that serves pre-loaded CID → bytes from a "files" array in test JSON files. Missing CIDs are reported with a clear error instead of a 60-second timeout. Also adds IpfsResponse::for_test() helper and documents the unmocked eth_call timeout in the troubleshooting guide. * gnd: add mock Arweave resolver for file/arweave data source testing Introduces `MockArweaveResolver` and the `arweaveFiles` schema field so `gnd test` can serve pre-loaded Arweave content without hitting the network. Unresolved tx IDs are collected and reported as a clear test failure, mirroring the existing IPFS mock behaviour. * gnd: add file data source tests and split fixture subgraphs Add IPFS and Arweave file data source test fixtures and split the monolithic gnd_test fixture into four focused subgraphs: - token/ ERC20 events, eth_call mocking, dynamic templates - blocks/ Block handlers (every, once, polling filters) - receipts/ Transaction receipts (receipt: true handlers) - file-data-sources/ IPFS and Arweave file data sources Each fixture is a standalone subgraph with its own schema, mappings, ABIs, and test JSON files. The Rust test harness is updated to use a generic setup_fixture(name) helper, with one test function per fixture. * gnd: trim verbose inline comments in test module * gnd: replace eth_call cache with mock transport layer Replace the pre-populated eth_call cache approach with a mock Alloy transport that intercepts RPC calls at the transport level. This makes tests exercise the real production code path end-to-end. - Add MockTransport serving eth_call, eth_getBalance, eth_getCode - Add TestRuntimeAdapterBuilder to convert PossibleReorg → Deterministic - Add getBalanceCalls/hasCodeCalls to test JSON schema - Unmocked RPC calls now fail immediately with descriptive errors - Disable RPC retries in test mode for fast failure * gnd: update test docs for mock transport and file data sources - Document getBalanceCalls and hasCodeCalls mock fields - Document IPFS and Arweave file data source mocking - Update architecture section to describe mock transport - Update troubleshooting for immediate unmocked call errors - Update known limitations table * gnd: Separate tests and fix retry count env var value Signed-off-by: Maksim Dimitrov <dimitrov.maksim@gmail.com> --------- Signed-off-by: Maksim Dimitrov <dimitrov.maksim@gmail.com>
1 parent 7638f0a commit 3787b76

48 files changed

Lines changed: 2145 additions & 449 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gnd/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ graph-store-postgres = { path = "../store/postgres" }
3131
# Test command dependencies
3232
hex = "0.4"
3333
async-trait = { workspace = true }
34+
tower = { workspace = true }
3435

3536
# Direct dependencies from current dev.rs
3637
anyhow = { workspace = true }

gnd/docs/gnd-test.md

Lines changed: 174 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ gnd test --matchstick
2222

2323
Tests are JSON files that define:
2424
- Mock blockchain blocks with events
25-
- Mock `eth_call` responses
25+
- Mock Ethereum RPC responses (`eth_call`, `eth_getBalance`, `eth_getCode`)
2626
- GraphQL assertions to validate entity state
2727

2828
Place test files in a `tests/` directory with `.json` or `.test.json` extension.
@@ -82,6 +82,8 @@ Place test files in a `tests/` directory with `.json` or `.test.json` extension.
8282
| `baseFeePerGas` | No | None (pre-EIP-1559) | Base fee in wei |
8383
| `events` | No | Empty array | Log events in this block |
8484
| `ethCalls` | No | Empty array | Mock `eth_call` responses |
85+
| `getBalanceCalls` | No | Empty array | Mock `eth_getBalance` responses for `ethereum.getBalance()` |
86+
| `hasCodeCalls` | No | Empty array | Mock `eth_getCode` responses for `ethereum.hasCode()` |
8587

8688
### Empty Blocks
8789

@@ -144,6 +146,56 @@ Event parameters are automatically ABI-encoded based on the signature. Supported
144146
}
145147
```
146148

149+
## Transaction Receipts
150+
151+
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.
152+
153+
**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.
154+
155+
### How receipts are built
156+
157+
Every event gets a mock receipt attached automatically. The key rule is **`txHash` grouping**:
158+
159+
- Events sharing the same `txHash` share **one receipt**`event.receipt!.logs` contains all of their logs in declaration order.
160+
- 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.
161+
162+
### Example: Two events sharing a receipt
163+
164+
```json
165+
{
166+
"events": [
167+
{
168+
"address": "0x1234...",
169+
"event": "Transfer(address indexed from, address indexed to, uint256 value)",
170+
"params": { "from": "0xaaaa...", "to": "0xbbbb...", "value": "100" },
171+
"txHash": "0xdeadbeef0000000000000000000000000000000000000000000000000000000"
172+
},
173+
{
174+
"address": "0x1234...",
175+
"event": "Transfer(address indexed from, address indexed to, uint256 value)",
176+
"params": { "from": "0xbbbb...", "to": "0xcccc...", "value": "50" },
177+
"txHash": "0xdeadbeef0000000000000000000000000000000000000000000000000000000"
178+
}
179+
]
180+
}
181+
```
182+
183+
Both handlers receive a receipt where `receipt.logs` has two entries, in declaration order.
184+
185+
### Mock receipt defaults
186+
187+
| Field | Value |
188+
|-------|-------|
189+
| `status` | success |
190+
| `cumulative_gas_used` | `21000` |
191+
| `gas_used` | `21000` |
192+
| transaction type | `2` (EIP-1559) |
193+
| `from` | `0x000...000` |
194+
| `to` | `null` |
195+
| `effective_gas_price` | `0` |
196+
197+
Handlers without `receipt: true` in the manifest are unaffected — they never access `event.receipt`.
198+
147199
## Block Handlers
148200

149201
Block handlers are **automatically triggered** for every block. You don't need to specify block triggers in the JSON.
@@ -309,7 +361,7 @@ Mock contract calls made from mapping handlers using `contract.call()`:
309361
| `function` | Yes | Full signature: `"functionName(inputTypes)(returnTypes)"` |
310362
| `params` | Yes | Array of input parameters (as strings) |
311363
| `returns` | Yes | Array of return values (as strings, ignored if `reverts: true`) |
312-
| `reverts` | No | Default `false`. If `true`, the call is cached as `Retval::Null` |
364+
| `reverts` | No | Default `false`. If `true`, the mock transport returns an RPC error |
313365

314366
### Function Signature Format
315367

@@ -364,6 +416,110 @@ From the ERC20 test:
364416
}
365417
```
366418

419+
## ethereum.getBalance() Mocking
420+
421+
Mock balance lookups made from mapping handlers using `ethereum.getBalance()`:
422+
423+
```json
424+
{
425+
"getBalanceCalls": [
426+
{
427+
"address": "0xaaaa000000000000000000000000000000000000",
428+
"value": "1000000000000000000"
429+
}
430+
]
431+
}
432+
```
433+
434+
### getBalanceCalls Fields
435+
436+
| Field | Required | Description |
437+
|-------|----------|-------------|
438+
| `address` | Yes | Account address (checksummed or lowercase hex) |
439+
| `value` | Yes | Balance in Wei as a decimal string |
440+
441+
## ethereum.hasCode() Mocking
442+
443+
Mock code existence checks made from mapping handlers using `ethereum.hasCode()`:
444+
445+
```json
446+
{
447+
"hasCodeCalls": [
448+
{
449+
"address": "0x1234000000000000000000000000000000000000",
450+
"hasCode": true
451+
}
452+
]
453+
}
454+
```
455+
456+
### hasCodeCalls Fields
457+
458+
| Field | Required | Description |
459+
|-------|----------|-------------|
460+
| `address` | Yes | Contract address (checksummed or lowercase hex) |
461+
| `hasCode` | Yes | Whether the address has deployed bytecode |
462+
463+
## File Data Sources
464+
465+
Mock IPFS and Arweave file contents for file data source handlers. Files are defined at the top level of the test JSON (not inside blocks).
466+
467+
### IPFS Files
468+
469+
```json
470+
{
471+
"name": "File data source test",
472+
"files": [
473+
{
474+
"cid": "QmExample...",
475+
"content": "{\"name\": \"Token\", \"description\": \"A token\"}"
476+
},
477+
{
478+
"cid": "QmAnother...",
479+
"file": "fixtures/metadata.json"
480+
}
481+
],
482+
"blocks": [...],
483+
"assertions": [...]
484+
}
485+
```
486+
487+
#### files Fields
488+
489+
| Field | Required | Description |
490+
|-------|----------|-------------|
491+
| `cid` | Yes | IPFS CID (`Qm...` or `bafy...`). The mock ignores hash/content relationship |
492+
| `content` | One of `content`/`file` | Inline UTF-8 content |
493+
| `file` | One of `content`/`file` | File path, resolved relative to the test JSON |
494+
495+
### Arweave Files
496+
497+
```json
498+
{
499+
"name": "Arweave data source test",
500+
"arweaveFiles": [
501+
{
502+
"txId": "abc123",
503+
"content": "{\"name\": \"Token\"}"
504+
},
505+
{
506+
"txId": "def456/metadata.json",
507+
"file": "fixtures/arweave-data.json"
508+
}
509+
],
510+
"blocks": [...],
511+
"assertions": [...]
512+
}
513+
```
514+
515+
#### arweaveFiles Fields
516+
517+
| Field | Required | Description |
518+
|-------|----------|-------------|
519+
| `txId` | Yes | Arweave transaction ID or bundle path (e.g. `"txid/filename.json"`) |
520+
| `content` | One of `content`/`file` | Inline UTF-8 content |
521+
| `file` | One of `content`/`file` | File path, resolved relative to the test JSON |
522+
367523
## Assertions
368524

369525
GraphQL queries to validate the indexed entity state after processing all blocks.
@@ -597,10 +753,12 @@ my-subgraph/
597753
|---------|--------|
598754
| Log events | ✅ Supported |
599755
| Block handlers (all filters) | ✅ Supported |
600-
| eth_call mocking | ✅ Supported |
756+
| `eth_call` mocking | ✅ Supported |
757+
| `ethereum.getBalance()` mocking | ✅ Supported |
758+
| `ethereum.hasCode()` mocking | ✅ Supported |
601759
| Dynamic/template data sources | ✅ Supported |
602-
| Transaction receipts (`receipt: true`) | ❌ Not implemented — handlers get `null` |
603-
| File data sources / IPFS mocking | ❌ Not implemented |
760+
| 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)) |
761+
| File data sources (IPFS + Arweave) | ✅ Supported |
604762
| Call triggers (traces) | ❌ Not implemented |
605763
| `--json` CI output | ❌ Not implemented |
606764
| Parallel test execution | ❌ Not implemented |
@@ -714,10 +872,8 @@ GraphQL queries → Assertions
714872
**Key design principles:**
715873

716874
- **Isolated database per test:** Each test gets a pgtemp database dropped on completion (default), or a shared persistent database with post-test cleanup (`--postgres-url`)
717-
- **Real WASM runtime:** Uses `EthereumRuntimeAdapterBuilder` with real `ethereum.call` host function
718-
- **Pre-populated call cache:** `eth_call` responses are cached before indexing starts
875+
- **Mock transport layer:** A mock Alloy transport serves `eth_call`, `eth_getBalance`, and `eth_getCode` from test JSON data. All three flow through the real production code path — only the transport returns mock responses. Unmocked RPC calls fail immediately with a descriptive error.
719876
- **No IPFS for manifest:** Uses `FileLinkResolver` to load manifest/WASM from build directory
720-
- **Dummy RPC adapter:** Registered at `http://0.0.0.0:0` for capability lookup; never actually called
721877

722878
## Troubleshooting
723879

@@ -740,14 +896,19 @@ GraphQL queries → Assertions
740896
2. Simplify mapping logic
741897
3. Check for infinite loops in handler code
742898

743-
### eth_call Returns Wrong Value
899+
### Unmocked RPC Call
900+
901+
**Cause:** A mapping handler calls `ethereum.call`, `ethereum.getBalance`, or `ethereum.hasCode` for a call that has no matching mock entry.
744902

745-
**Cause:** Call cache miss — no matching mock in `ethCalls`.
903+
**Symptom:** Test fails immediately with a descriptive error like:
904+
```
905+
gnd test: unmocked eth_call to 0x1234... at block hash 0xabcd...
906+
Add a matching 'ethCalls' entry to this block in your test JSON.
907+
```
746908

747909
**Fix:**
748-
1. Verify `address`, `function`, and `params` exactly match the call from your mapping
749-
2. Check function signature format: `"functionName(inputTypes)(returnTypes)"`
750-
3. Ensure parameters are in correct order
910+
1. Add the missing mock to the appropriate field in your test block (`ethCalls`, `getBalanceCalls`, or `hasCodeCalls`)
911+
2. If the call is not supposed to happen, check the mapping logic — a code path may be executing unexpectedly
751912

752913
### Block Handler Not Firing
753914

gnd/src/commands/test/assertion.rs

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,10 @@ async fn run_single_assertion(
7777
}
7878
}
7979

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

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

165-
/// Compare two JSON values for equality (ignoring key ordering in objects).
158+
/// Compare JSON values for equality, ignoring object key ordering.
166159
///
167-
/// Also handles string-vs-number coercion: GraphQL returns `BigInt` and
168-
/// `BigDecimal` fields as JSON strings (e.g., `"1000000000000000000"`),
169-
/// but test authors may write them as JSON numbers. This function treats
170-
/// `String("123")` and `Number(123)` as equal when they represent the
171-
/// same value.
160+
/// Coerces string/number: `"123"` == `123` to handle GraphQL `BigInt`/`BigDecimal`.
172161
fn json_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
173162
match (a, b) {
174163
(serde_json::Value::Null, serde_json::Value::Null) => true,

gnd/src/commands/test/block_stream.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,15 @@ impl BlockStreamBuilder<Chain> for StaticStreamBuilder {
6363
}
6464
}
6565

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

7673
impl StaticStream {
77-
/// Create a new stream, optionally skipping past already-processed blocks.
78-
///
79-
/// `skip_to`: If `Some(i)`, start from block `i+1` (block `i` was already processed).
80-
/// If `None`, start from the beginning.
74+
/// `skip_to`: if `Some(i)`, start from block `i+1` (block `i` already processed).
8175
fn new(blocks: Vec<BlockWithTriggers<Chain>>, skip_to: Option<usize>) -> Self {
8276
Self {
8377
blocks,

0 commit comments

Comments
 (0)