|
| 1 | +# Token Chaincode Query Limits |
| 2 | + |
| 3 | +This page describes the limits enforced on the token chaincode's read-only query functions, why |
| 4 | +they exist, and how to configure them. |
| 5 | + |
| 6 | +## Why limits exist |
| 7 | + |
| 8 | +The token chaincode (`token/services/network/fabric/tcc`) exposes three read-only query functions: |
| 9 | + |
| 10 | +| Function | Argument | Work per element | |
| 11 | +| --- | --- | --- | |
| 12 | +| `queryStates` | JSON array of state keys | one `GetState` | |
| 13 | +| `queryTokens` | JSON array of `token.ID`s | one `GetState` | |
| 14 | +| `areTokensSpent` | JSON array of token/serial-number keys | one `GetState` | |
| 15 | + |
| 16 | +Every element the caller supplies translates 1:1 into a ledger read inside a *single* chaincode |
| 17 | +invocation. The `invoke` path is bounded by `driver.ResourceLimits` (see |
| 18 | +[Validator Resource Limits](../drivers/validation-resource-limits.md)), which rejects an oversized |
| 19 | +token request before any per-element work. The query path had no analogous guard: a single request |
| 20 | +carrying an arbitrarily long array drove an unbounded number of ledger reads, and — unlike `invoke` |
| 21 | +— it does not go through the size-limited transaction-submission flow. That is a resource-exhaustion |
| 22 | +/ peer-slowdown vector reachable by any client that can call the chaincode's query path, with no |
| 23 | +elevated privileges (issue #2050). |
| 24 | + |
| 25 | +## Configuration mechanism |
| 26 | + |
| 27 | +Limits are held in `tcc.QueryLimits` (`token/services/network/fabric/tcc/querylimits.go`) and read |
| 28 | +from the `QueryLimits` field of `tcc.TokenChaincode`: |
| 29 | + |
| 30 | +| Field | Default | Checked | Enforced | |
| 31 | +| --- | --- | --- | --- | |
| 32 | +| `MaxQueryRequestBytes` | 1 MiB | Raw size of the query argument | Before `json.Unmarshal`, so an oversized payload never reaches an allocation proportional to its own size | |
| 33 | +| `MaxQueryItems` | 4096 | Number of elements in the decoded array | After the decode and **before the first ledger read** | |
| 34 | + |
| 35 | +`MaxQueryItems` is the meaningful bound — it caps how many ledger reads one invocation can perform. |
| 36 | +`MaxQueryRequestBytes` is deliberately high enough that a full 4096-element batch of realistic state |
| 37 | +keys still fits, so the two limits do not shadow each other and the binding limit is predictable; it |
| 38 | +exists to reject a payload before decoding, including one made of few but enormous elements. Both |
| 39 | +defaults are far above the batch sizes produced by any in-tree caller |
| 40 | +(`lookup.DeliveryScanQueryByID`, `tokenFetcher.QueryTokens`, `spentTokenFetcher.QuerySpentTokens`). |
| 41 | + |
| 42 | +Violations return a typed error — `tcc.ErrQueryRequestTooLarge` or `tcc.ErrTooManyQueryItems` — |
| 43 | +wrapping the effective limit, surfaced to the caller as a chaincode error response. |
| 44 | + |
| 45 | +`QueryLimits.WithDefaults()` overlays `DefaultQueryLimits()` onto any field that is not a positive |
| 46 | +value, and the chaincode applies it on every query. Consequently a `TokenChaincode` built without |
| 47 | +setting `QueryLimits` is still bounded by the defaults, and a partially-specified override (or a |
| 48 | +negative value from a configuration typo) can never silently disable a limit. |
| 49 | + |
| 50 | +The standalone chaincode process (`token/services/network/fabric/tcc/main/main.go`) has no |
| 51 | +configuration service wired, so it resolves the limits from the environment via |
| 52 | +`tcc.EnvQueryLimitsProvider` — mirroring `tcc.EnvResourceLimitsProvider` for validation limits: |
| 53 | + |
| 54 | +| Environment variable | Field | |
| 55 | +| --- | --- | |
| 56 | +| `TOKEN_QUERY_MAX_REQUEST_BYTES` | `MaxQueryRequestBytes` | |
| 57 | +| `TOKEN_QUERY_MAX_ITEMS` | `MaxQueryItems` | |
| 58 | + |
| 59 | +Each variable is optional; an unset variable leaves the field at zero, which `WithDefaults` then |
| 60 | +replaces with the default. An unparseable value is a startup error, not a silent fallback. |
| 61 | + |
| 62 | +## Relationship to the consensus-safety contract |
| 63 | + |
| 64 | +Unlike `driver.ResourceLimits`, these limits are **not** consensus-relevant. The query functions |
| 65 | +perform no writes and are reached through the query/evaluate path rather than through endorsement, |
| 66 | +so a peer configured with a stricter value only refuses a request another peer would serve — it |
| 67 | +cannot make two peers disagree on a transaction's validity. Query limits are therefore an |
| 68 | +operational knob per chaincode process, not a value that must be rolled out in lockstep. |
| 69 | + |
| 70 | +## Choosing and changing these values |
| 71 | + |
| 72 | +Clients that legitimately need to look up more than `MaxQueryItems` keys should chunk their |
| 73 | +requests. If a deployment instead raises `TOKEN_QUERY_MAX_ITEMS`, keep in mind that the value |
| 74 | +directly bounds the ledger reads a single unauthenticated request can trigger — raise it only as far |
| 75 | +as observed legitimate batch sizes require, and raise `TOKEN_QUERY_MAX_REQUEST_BYTES` alongside it if |
| 76 | +the larger batch no longer fits. |
| 77 | + |
| 78 | +## Testing |
| 79 | + |
| 80 | +- **Exact-boundary unit tests** (`querylimits_test.go`): every field asserts `limit-1` / `limit` |
| 81 | + succeed and `limit+1` fails with the specific typed error, against both the defaults and an |
| 82 | + injected override. |
| 83 | +- **Regression tests** (`queryguard_test.go`): for each of the three query functions, an |
| 84 | + over-counted request and an oversized payload are both rejected with **zero** `GetState` calls; a |
| 85 | + request at exactly `MaxQueryItems` is served and performs exactly one read per element; and an |
| 86 | + unconfigured `TokenChaincode` is shown to still be bounded by the defaults. |
| 87 | +- **Provider tests**: unset environment (resolves to defaults), partial override (unset fields still |
| 88 | + default), and an unparseable value (returns an error). |
| 89 | +- **Fuzzing**: one target per query function, each asserting that for arbitrary caller bytes the |
| 90 | + function never panics and never performs more than `MaxQueryItems` ledger reads. The three are |
| 91 | + separate targets because the surface behind the shared limit check differs: |
| 92 | + |
| 93 | + | Target | Covers | |
| 94 | + | --- | --- | |
| 95 | + | `FuzzQueryStatesLedgerReadsAreBounded` | array-of-strings decode; each string is used as a ledger key verbatim | |
| 96 | + | `FuzzAreTokensSpentLedgerReadsAreBounded` | same decode, plus validator initialization from the public parameters and — with graph hiding on, fuzzed as a second argument — the key translator's composite-key builder (UTF-8 validation and rune scanning of attacker-controlled text) | |
| 97 | + | `FuzzQueryTokensLedgerReadsAreBounded` | array-of-`token.ID` decode (string + `uint64`) and output-key derivation from each element | |
| 98 | + |
| 99 | + In-code `f.Add` seeds cover both limit boundaries plus empty, truncated, wrong-shape and |
| 100 | + rejected-rune payloads. `testdata/fuzz/<TargetName>/` holds only what benefits from living on disk |
| 101 | + — the `MaxQueryItems` boundary pair (the limit that bounds ledger reads) and the crash |
| 102 | + reproducers, which Go writes there itself and whose named files report as named subtests. The |
| 103 | + corpus fuzzing *generates* is not committed: it lives in `$GOCACHE/fuzz` and CI restores it only |
| 104 | + best-effort, so these files are the durable floor. All three targets run nightly via |
| 105 | + [`.github/workflows/nightly-fuzz.yml`](../../.github/workflows/nightly-fuzz.yml). |
| 106 | + |
| 107 | + `FuzzQueryTokensLedgerReadsAreBounded` found a pre-existing nil-pointer dereference on its first |
| 108 | + run: a `null` element in the JSON array decodes to a nil `*token.ID`, which |
| 109 | + `translator.QueryTokens` dereferenced. It now reports a nil entry as an invalid request instead; |
| 110 | + the two payloads that triggered it are kept on disk (`nil-elements-panic-regression`, |
| 111 | + `single-nil-element-panic-regression`), so the panic is caught by a plain `go test` run. |
0 commit comments