Problem
Auditor-side transaction validation can fail with a spurious error when a
transaction references (as inputs) outputs of a very recently-audited
transaction, because the local read used for audit token lookups is not
guaranteed to reflect a transaction's outputs by the time a dependent
transaction reaches audit validation.
Where this happens
AuditorCheck (token/core/fabtoken/v1/auditor.go,
token/core/zkatdlog/nogh/v1/auditor.go) calls common.RetrieveAuditTokens
(token/core/common/auditor.go), which does a single, non-retrying call to
driver.QueryEngine.ListAuditTokens. That query engine
(token/sdk/vault/vault.go) reads directly from the SQL-backed token store
(token/services/storage/db/sql/common/tokens.go), which returns a hard
error ("token not found for key [...]") the moment a requested token row is
missing — no retry, no backoff.
That SQL row for a transaction's outputs is only written by
tokens.Service.AppendValid, which itself only runs inside the async
finality-listener callback (token/services/ttx/finality/listener.go)
fired once the network layer reports the transaction as final. This is
fully decoupled — in timing — from anything the auditor did when it signed
off on that earlier transaction. A closely-following transaction that spends
or references those same outputs can therefore reach AuditorCheck before
the listener has finished persisting them, causing audit validation to fail
even though the referenced transaction was itself already validated and
signed off by the same auditor.
Existing partial mitigation, and an inconsistency
A different call path — auditor.Service.Audit → Request.AuditRecord
(token/request.go) — uses a different query-engine wrapper
(token.QueryEngine in token/vault.go) that does retry on this exact
condition: it checks whether the owning transaction is Pending and retries
with backoff (3 retries × 3s) before giving up. That wrapper is a different
concrete type from driver.QueryEngine and cannot be substituted into the
driver-level AuditorService.QueryEngine field used by AuditorCheck, so
the retry logic never applies there.
The net effect is an inconsistency: Validate()/AuditorCheck() — which
runs before Audit() in the auditor flow — has no tolerance at all for
this race, while Audit(), which runs after, has a bounded (~9s) grace
window for the same underlying condition.
Additional context: an unused signal
tokens.Service already maintains an in-memory RequestsCache, populated
at audit-approval time (AuditApproveView.Call in
token/services/ttx/auditor.go, via CacheRequest) — i.e. before the
transaction is even committed. That cache entry contains enough information
(TxID, Index, and the token itself) to resolve the exact token IDs that
AuditorCheck looks up. However, neither RetrieveAuditTokens nor either
driver's AuditorService currently has any reference to tokens.Service —
they're constructed with only a driver.QueryEngine — so this signal is
never consulted during audit validation today.
Impact
Auditors can reject (or need to retry) valid, already-approved transactions
purely due to local read timing, not due to any actual problem with the
transaction. This is more likely under load or when transactions chain
quickly (e.g. an output spent shortly after being audited).
Suggestions for investigation (not prescribing a fix)
- Whether
AuditorCheck's lookup path should gain the same
pending-status retry/backoff tolerance that Request.AuditRecord's path
already has, to remove the inconsistency between the two gates.
- Whether the existing
tokens.Service cache could serve as an additional
fallback for resolving audit tokens that are momentarily missing from the
DB but already known to be in flight, and if so what plumbing changes
wiring tokens.Service into AuditorService construction would require
(currently no such dependency exists in either the fabtoken or zkatdlog
driver construction chains).
Problem
Auditor-side transaction validation can fail with a spurious error when a
transaction references (as inputs) outputs of a very recently-audited
transaction, because the local read used for audit token lookups is not
guaranteed to reflect a transaction's outputs by the time a dependent
transaction reaches audit validation.
Where this happens
AuditorCheck(token/core/fabtoken/v1/auditor.go,token/core/zkatdlog/nogh/v1/auditor.go) callscommon.RetrieveAuditTokens(
token/core/common/auditor.go), which does a single, non-retrying call todriver.QueryEngine.ListAuditTokens. That query engine(
token/sdk/vault/vault.go) reads directly from the SQL-backed token store(
token/services/storage/db/sql/common/tokens.go), which returns a harderror (
"token not found for key [...]") the moment a requested token row ismissing — no retry, no backoff.
That SQL row for a transaction's outputs is only written by
tokens.Service.AppendValid, which itself only runs inside the asyncfinality-listener callback (
token/services/ttx/finality/listener.go)fired once the network layer reports the transaction as final. This is
fully decoupled — in timing — from anything the auditor did when it signed
off on that earlier transaction. A closely-following transaction that spends
or references those same outputs can therefore reach
AuditorCheckbeforethe listener has finished persisting them, causing audit validation to fail
even though the referenced transaction was itself already validated and
signed off by the same auditor.
Existing partial mitigation, and an inconsistency
A different call path —
auditor.Service.Audit→Request.AuditRecord(
token/request.go) — uses a different query-engine wrapper(
token.QueryEngineintoken/vault.go) that does retry on this exactcondition: it checks whether the owning transaction is
Pendingand retrieswith backoff (3 retries × 3s) before giving up. That wrapper is a different
concrete type from
driver.QueryEngineand cannot be substituted into thedriver-level
AuditorService.QueryEnginefield used byAuditorCheck, sothe retry logic never applies there.
The net effect is an inconsistency:
Validate()/AuditorCheck()— whichruns before
Audit()in the auditor flow — has no tolerance at all forthis race, while
Audit(), which runs after, has a bounded (~9s) gracewindow for the same underlying condition.
Additional context: an unused signal
tokens.Servicealready maintains an in-memoryRequestsCache, populatedat audit-approval time (
AuditApproveView.Callintoken/services/ttx/auditor.go, viaCacheRequest) — i.e. before thetransaction is even committed. That cache entry contains enough information
(
TxID,Index, and the token itself) to resolve the exact token IDs thatAuditorChecklooks up. However, neitherRetrieveAuditTokensnor eitherdriver's
AuditorServicecurrently has any reference totokens.Service—they're constructed with only a
driver.QueryEngine— so this signal isnever consulted during audit validation today.
Impact
Auditors can reject (or need to retry) valid, already-approved transactions
purely due to local read timing, not due to any actual problem with the
transaction. This is more likely under load or when transactions chain
quickly (e.g. an output spent shortly after being audited).
Suggestions for investigation (not prescribing a fix)
AuditorCheck's lookup path should gain the samepending-status retry/backoff tolerance that
Request.AuditRecord's pathalready has, to remove the inconsistency between the two gates.
tokens.Servicecache could serve as an additionalfallback for resolving audit tokens that are momentarily missing from the
DB but already known to be in flight, and if so what plumbing changes
wiring
tokens.ServiceintoAuditorServiceconstruction would require(currently no such dependency exists in either the fabtoken or zkatdlog
driver construction chains).