panurus

Validator Resource Limits

This page describes the resource limits enforced on untrusted token requests and actions before they reach cryptographic verification, the configuration mechanism that controls them, and the consensus-safety contract that mechanism carries.

Why limits exist

The token request validators (token/core/common, and the fabtoken/zkatdlog drivers built on top of it) accept raw, attacker-controlled bytes over the network. Aside from the signing anchor (driver.MaxAnchorSize), nothing else bounds the size of the raw request, the number of actions or signatures, the size of an individual action or signature, the number of inputs/outputs/metadata entries in an action, the length of a zero-knowledge proof, or how deeply a composite owner identity nests — unless these limits are enforced. Without them, an attacker could force unbounded allocations (make([]..., len(attackerControlledCount))), unbounded recursion (a composite identity nested inside itself), and expensive cryptographic work (proof deserialization, ZK verification) purely by shaping the wire bytes, without needing any valid signature.

Configuration mechanism

Limits are held in a single struct, driver.ResourceLimits (token/driver/limits.go), injected into every validator at construction time — the validator itself never reads a package constant. driver.DefaultResourceLimits() returns the historical, always-safe values (see the tables below); driver.ResourceLimits.WithDefaults() overlays those defaults onto any zero-valued field, so a partially-specified override never silently disables a limit by leaving it at zero.

Two sources resolve a driver.ResourceLimits value at composition-root time, both implementing driver.ResourceLimitsProvider:

A driver.StaticResourceLimits provider (a trivial wrapper returning a fixed value) is used by tests, tools, and any caller that only needs the defaults (e.g. cmd/token_validation_service, the zkatdlog regression suite).

The FSC/DI runtime (token/sdk/dig/sdk.go) registers exactly one driver.ResourceLimitsProvider in the dig container — the config-backed implementation above — and injects it into both validator construction paths, so they always enforce the same configured limits:

The resolved driver.ResourceLimits flows: provider → (Driver.NewTokenService / core.NewValidatorDriverService(limits, ...)driver.ValidatorDriver.NewValidator(pp, limits)) → the per-driver common.NewValidator(..., limits, ...)ActionDeserializer.DeserializeActions, which calls action.SetLimits(limits) on every deserialized action before Deserialize runs. Any action constructed without SetLimits (e.g. in tests or other non-validator call sites) falls back to DefaultResourceLimits() via an internal effectiveLimits() helper — never more permissive than the historical behavior.

Consensus-safety contract

Every validating peer must reject or accept the same request identically, or endorsement becomes nondeterministic. Limits are no longer baked-in constants — they are configurable — which shifts the uniformity guarantee from “guaranteed by the binary” to an explicit operator responsibility:

Enforcement points

Limits are enforced at two boundaries, both strictly before the request or action is used to allocate proportional memory or is handed to a cryptographic verifier:

1. Common request envelope (token/core/common/limits.go)

Enforced by (*Validator).CheckRawRequestSize / CheckRequestLimits (token/core/common/validator.go), reading the validator’s injected Limits field:

Field Default Checked Enforced by
MaxRequestBytes 256 KiB Raw serialized request size CheckRawRequestSize, before TokenRequest.FromBytes
MaxActions 256 Number of actions in the request CheckRequestLimits, immediately after parsing
MaxSignatures 4096 Number of request signatures CheckRequestLimits, immediately after parsing
MaxActionBytes 256 KiB Length of a single action’s raw bytes CheckRequestLimits, immediately after parsing
MaxSignatureBytes 4 KiB Length of a single auditor or action signature CheckRequestLimits, immediately after parsing

CheckRawRequestSize runs before the protobuf decode, so an oversized message never reaches an allocation proportional to its own claimed size. CheckRequestLimits runs on the parsed request, before MarshalToMessageToSign and before any signature verification, so oversized or over-counted requests never reach cryptographic work. Violations return a typed error (ErrRequestTooLarge, ErrTooManyActions, ErrTooManySignatures, ErrActionTooLarge, ErrSignatureTooLarge), wrapping the effective (possibly configured) limit value.

2. Driver-specific action internals

Each driver bounds the shape of its own action payload, checked inside Deserialize (before the proportional-size allocations for inputs/outputs) and Validate (before proof-specific cryptographic work), using the action’s effectiveLimits() (the limits injected via SetLimits, or DefaultResourceLimits() if none were set):

ZKAT-DLOG NOGH v1 (token/core/zkatdlog/nogh/v1/issue/limits.go, .../transfer/limits.go — identical field defaults for issue and transfer actions):

Field Default
MaxInputs 256
MaxOutputs 256
MaxMetadataEntries 64
MaxMetadataKeyBytes 256
MaxMetadataValueBytes 4 KiB
MaxProofBytes 128 KiB

MaxProofBytes is checked before the zero-knowledge proof body is handed to the bulletproof/CSP verifier for deserialization, so an oversized proof is rejected without running any ZK-specific cryptographic code.

FabToken v1 (token/core/fabtoken/v1/actions/limits.go — fabtoken has no ZK proof, so there is no MaxProofBytes):

Field Default
MaxInputs 256
MaxOutputs 256
MaxMetadataEntries 64
MaxMetadataKeyBytes 256
MaxMetadataValueBytes 4 KiB

Each driver-level violation returns its own typed error (e.g. ErrTooManyInputs, ErrProofTooLarge), wrapping the effective limit at check time.

Auditor-side deserializers (.../audit/auditor.go in both drivers) are not the consensus-endorsement boundary and are unaffected by this configuration mechanism — they always run with DefaultResourceLimits().

3. Composite identity nesting

A token’s owner is an identity, and three identity types are composite — their components are themselves identities:

Type Registered as Components
multisig (token/services/identity/multisig) driver.MultiSigIdentityType N component identities, all of which must sign
boolpolicy (token/services/identity/boolpolicy) driver.PolicyIdentityType N component identities, combined by a boolean expression
htlc (token/services/identity/interop/htlc) htlc.ScriptType the script’s sender and recipient

Each driver’s NewDeserializer (token/core/fabtoken/v1/driver/deserializer.go, token/core/zkatdlog/nogh/v1/driver/deserializer.go) registers the verifier multiplex as the component deserializer for all three, including for itself:

des := deserializer.NewTypedVerifierDeserializerMultiplex()
...
des.AddTypedVerifierDeserializer(htlc2.ScriptType, htlc.NewTypedIdentityDeserializer(des))
des.AddTypedVerifierDeserializer(multisig.Multisig, multisig.NewTypedIdentityDeserializer(des, des))
des.AddTypedVerifierDeserializer(boolpolicy.Policy, boolpolicy.NewTypedIdentityDeserializer(des, des))

That self-registration is what makes composite identities compose — a policy over a multisig over an x509 identity resolves correctly — and it is also what makes the recursion unbounded without an explicit budget. GetOwnerVerifier is called once per input token from the transfer validator, so an attacker-shaped owner identity drives that recursion before any signature is verified.

Two bounds close it:

Field Default Bounds
MaxIdentityDepth 5 How deeply composite identities may nest inside one another
MaxIdentityComponents 16 How many components a single composite identity may carry

Both are needed. Depth alone does not bound fan-out (one level with thousands of components is a single recursive step doing unbounded work), and fan-out alone does not bound depth. Real deployments nest 2–3 levels — a policy over a multisig over x509 — comfortably inside the default.

The depth budget is carried in context.Context (token/driver/identity_nesting.go), which is already the first parameter of every method in the recursion. driver.EnterCompositeIdentity(ctx) accounts for one level and returns the context to pass to the components; it returns an error wrapping driver.ErrIdentityNestingTooDeep past the limit. Exceeding MaxIdentityComponents returns driver.ErrTooManyIdentityComponents. Because the count rides in the context, it is per-path: sibling components each descend from their parent’s depth rather than sharing a running total, which is the correct semantics for a depth bound and the reason the fan-out bound is required alongside it.

There are four independent recursion chains, each of which accounts for its own depth — the matcher paths recurse separately from the deserialization that produced them, so a budget spent during construction must not be inherited at match time:

Chain Entry point
Verifier deserialization TypedIdentityDeserializer.DeserializeVerifier in all three packages
Matcher construction TypedIdentityDeserializer.GetAuditInfoMatcher (multisig, boolpolicy)
Matcher evaluation InfoMatcher.Match (multisig, boolpolicy), AuditInfoMatcher.Match (htlc)
Audit-info collection TypedIdentityDeserializer.GetAuditInfo in all three packages

Validators seed the configured limits into the context at each public entry point ((*Validator).withIdentityNestingLimits, token/core/common/limits.go). Composite identity deserialization is also reachable from paths that carry no ResourceLimits — a wallet resolving a recipient, an auditor inspecting a request, tests — and those still get the defaults rather than running unbounded, so a seeding site added later and forgotten weakens the bound to the default instead of disabling it.

The fan-out bound is applied on every chain above that walks a component list — verifier deserialization, matcher construction and audit-info collection — through the same validateComponentIdentities choke point, which also rejects an empty or duplicated component. The matcher-evaluation chain needs no separate check: its component count is fixed by the matcher tree built during construction, which was already bounded.

The fan-out bound is also applied on the honest-caller path, in multisig.WrapIdentities and boolpolicy.WrapPolicyIdentity, so an identity constructed in-process cannot exceed what a validator will later accept.

Choosing and changing these values

The default values are conservative but comfortably above real usage observed across the unit, regression, and integration test suites — no currently-valid request or action is rejected by any of the defaults. If a deployment needs a different limit:

  1. Confirm no currently-valid production traffic pattern needs a value close to the existing limit, to avoid an unnecessarily invasive change.
  2. Roll the configuration change out to every validating peer (and the chaincode process) before relying on it — see Consensus-safety contract above.
  3. If you are changing a default (not just deploying an override), update the exact-boundary unit tests (limit-1/limit/limit+1) and the fuzz seed corpus (testdata/fuzz/<TargetName>/) alongside DefaultResourceLimits().

Testing