hashTypedData places no bound on the size or depth of the type graph it walks. A payload built from a linear chain of struct types — L0.next → L1.next → …, each instantiated once — costs time quadratic in the chain length, because encodeType(T) emits T plus all of its transitive dependencies, so n distinct types each build a string of length O(n).
Measured against the current sources:
depth 500 41,089 bytes -> ok in 227ms
depth 1000 82,089 bytes -> ok in 707ms
depth 2000 166,089 bytes -> ok in 3,103ms
depth 4000 334,089 bytes -> RangeError: Maximum call stack size exceeded
There are two separate problems here.
Quadratic cost. Doubling the depth roughly quadruples the time. 3.1 seconds on a 166 KB payload.
Uncatchable failure. Past roughly depth 3000, collectStructDeps exhausts the call stack and throws a plain RangeError, not a TypedDataError. The library's error convention is that malformed input produces a coded TypedDataError, so a consumer following that convention does not catch this. In a signer, an uncaught throw on the approval path is what blanks the approval screen.
Memoizing typeHash does not fix it
This was tried in a patched copy before proposing anything else:
depth | plain ms | memoized ms | same digest
1600 | 1902 | 1822 | true
No improvement. Every type in the chain is distinct, so the cache never hits. The quadratic is inherent to the definition of encodeType, not a missing cache. A fix has to bound the input rather than memoize the work.
The exposed party is the verifier, not the signer
The Wallet is not affected. src/background/rpc.js calls checkPolicyLimits(typedInput) at line 1029 before hashTypedDataHex at line 1031, and the policy check rejects every payload above in under a millisecond.
Verifiers are affected. verifyTypedDataSignature hashes before it compares anything:
depth 500 -> 245ms before returning E_SIG_INVALID
depth 1000 -> 772ms before returning E_SIG_INVALID
depth 2000 -> 3172ms before returning E_SIG_INVALID
Three seconds spent on a payload that was always going to be rejected, without reaching the cheap chainId comparison. A dApp backend accepting signatures from untrusted clients can be stalled by small, well-formed requests.
@dusk/connect is the package those verifiers actually install. It exports hashTypedData, hashTypedDataHex, hashTypedDataDebug, validateTypedDataParams and verifyTypedDataSignature, and no limit function at all — src/entrypoints.test.ts:32 asserts checkPolicyLimits is absent. Its docs/typed-data-v1.md directs integrators to @dusk/typed-data/policy, which for a dApp that installed only @dusk/connect means importing from a transitive dependency.
That entry-point split came out of an earlier review comment and it was right for its stated reason: a verifier that applies signer limits rejects signatures over digests everyone agrees on. The consequence was that the hash path lost any reachable bound on the surface most likely to face hostile input.
Proposal
Two things are currently conflated under "limits" and should be separated.
Resource policy — how large a payload a signer is willing to accept — is signer-side, optional, and correctly lives at @dusk/typed-data/policy. No change proposed.
Structural safety of the encoder — that hashing terminates in bounded time and fails cleanly — is not policy. It is a property the encoder owes every caller, verifiers included, and it cannot be delegated to an optional import.
Concretely:
- The encoder bounds its own traversal and rejects with a coded
TypedDataError — E_COMPLEXITY, or whichever code fits — rather than a RangeError.
- The specification fixes that bound as a constant. It is a ceiling that every implementation MUST refuse above, distinct from the §11 floor that every implementation MUST accept below. Both are normative.
- The ceiling belongs in the specification rather than in each implementation. If one implementation bounds at depth 1024 and another at 4096, a payload at depth 2000 is accepted by one and refused by the other, which is the kind of divergence the vector corpus exists to prevent.
- Because the ceiling sits far above the floor, no conforming payload is affected and no digest changes.
A fixed ceiling also wants vectors: one payload just under it that every implementation must accept, one just over it that every implementation must refuse with the same code.
hashTypedDataplaces no bound on the size or depth of the type graph it walks. A payload built from a linear chain of struct types —L0.next → L1.next → …, each instantiated once — costs time quadratic in the chain length, becauseencodeType(T)emitsTplus all of its transitive dependencies, so n distinct types each build a string of length O(n).Measured against the current sources:
There are two separate problems here.
Quadratic cost. Doubling the depth roughly quadruples the time. 3.1 seconds on a 166 KB payload.
Uncatchable failure. Past roughly depth 3000,
collectStructDepsexhausts the call stack and throws a plainRangeError, not aTypedDataError. The library's error convention is that malformed input produces a codedTypedDataError, so a consumer following that convention does not catch this. In a signer, an uncaught throw on the approval path is what blanks the approval screen.Memoizing
typeHashdoes not fix itThis was tried in a patched copy before proposing anything else:
No improvement. Every type in the chain is distinct, so the cache never hits. The quadratic is inherent to the definition of
encodeType, not a missing cache. A fix has to bound the input rather than memoize the work.The exposed party is the verifier, not the signer
The Wallet is not affected.
src/background/rpc.jscallscheckPolicyLimits(typedInput)at line 1029 beforehashTypedDataHexat line 1031, and the policy check rejects every payload above in under a millisecond.Verifiers are affected.
verifyTypedDataSignaturehashes before it compares anything:Three seconds spent on a payload that was always going to be rejected, without reaching the cheap
chainIdcomparison. A dApp backend accepting signatures from untrusted clients can be stalled by small, well-formed requests.@dusk/connectis the package those verifiers actually install. It exportshashTypedData,hashTypedDataHex,hashTypedDataDebug,validateTypedDataParamsandverifyTypedDataSignature, and no limit function at all —src/entrypoints.test.ts:32assertscheckPolicyLimitsis absent. Itsdocs/typed-data-v1.mddirects integrators to@dusk/typed-data/policy, which for a dApp that installed only@dusk/connectmeans importing from a transitive dependency.That entry-point split came out of an earlier review comment and it was right for its stated reason: a verifier that applies signer limits rejects signatures over digests everyone agrees on. The consequence was that the hash path lost any reachable bound on the surface most likely to face hostile input.
Proposal
Two things are currently conflated under "limits" and should be separated.
Resource policy — how large a payload a signer is willing to accept — is signer-side, optional, and correctly lives at
@dusk/typed-data/policy. No change proposed.Structural safety of the encoder — that hashing terminates in bounded time and fails cleanly — is not policy. It is a property the encoder owes every caller, verifiers included, and it cannot be delegated to an optional import.
Concretely:
TypedDataError—E_COMPLEXITY, or whichever code fits — rather than aRangeError.A fixed ceiling also wants vectors: one payload just under it that every implementation must accept, one just over it that every implementation must refuse with the same code.