Skip to content

feat(react): TTL-watermark invalidation delivery + chain-pinned reads - #364

Open
alexshchur wants to merge 34 commits into
masterfrom
feat/react-deterministic-invalidation
Open

alexshchur wants to merge 34 commits into
masterfrom
feat/react-deterministic-invalidation

Conversation

@alexshchur

@alexshchur alexshchur commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Stacked on #363 (→ #362#355#354#352#349). Two design findings from an external audit of the block-aware machinery, one commit each.

1. Invalidation contexts become TTL watermarks — deterministic delivery

The context store consumed each entry on the first fetch that resolved under its prefix. That delivered block-awareness reliably to the concurrent refetches an invalidation triggers (they all read the note synchronously in the same tick) — and by ordering luck to everything staggered: a query created only after the tx's outcome was known (a record read keyed by an id the tx just produced), an enabled flip, the second stage of an ids→batch read.

Now invalidateQueriesWithContext stores a watermark: until it expires (ttlMs option, default 60s) EVERY fetch under the prefix gates on the mined block — first, tenth, or one created seconds later. Entries expire by time, never by being read; expired entries are pruned lazily on store writes (fixing the leak where an undelivered note lived forever — createdAt existed but was only used for sort order). The store's prefix matching now mirrors react-query's partial-matching semantics (deep subset per segment), so an args-narrowed watermark like args: [id] reaches fn(id, seq)-shaped keys exactly like the invalidation itself does — previously the store's exact-JSON segment comparison silently missed them. consumeInvalidationContext leaves the public surface (nothing consumes on delivery any more). The repeated gate probe is near-free: once the node has caught up, eth_getBlockByHash resolves instantly in the same JSON-RPC batch as the read.

The old semantics were encoded in our own tests ("the invalidation context is one-shot… store empty afterwards") — those assertions are deliberately flipped, and the suite gains the staggered scenario that fails under the old design: invalidate, let the refetches fully settle, then enable a reader whose key was never fetched — and assert its first fetch probes the mined block like its concurrent siblings did.

2. Chain-pinned reads

useCofheReadContract / useCofheReadContracts accept chainId to pin a read (or a whole batch) to a specific chain instead of following the connected one. Three layers, all addressed:

  • the key: the pinned id becomes the key's chain segment — pin the matching invalidates targets to the same chainId so they meet;
  • the transport: the fetch routes through an app-supplied client for that chain, declared via CofheProvider's new publicClients?: Record<number, PublicClient> prop and resolved by useCofhePublicClient(chainId?). App-supplied on purpose — the app owns transport choices, and the block-aware gate depends on batching;
  • the failure mode: without a client for the pinned chain the read stays disabled, rather than silently querying the wrong chain.

This closes a dual-chain footgun: in an app whose contracts live on a different chain than the wallet currently sits on, reads followed the wallet — eth_call hit a chain with no contract and every query decoded 0x.

Tests

Same no-mock house style. New scenarios in react-read-contracts.web.test.tsx:

  • staggered watermark: fails on the pre-PR SDK by construction (the consumed note left the late reader un-gated); asserts one extra mined-block probe for a key first fetched after the invalidation settled;
  • pinned routing: two recording transports — a pinned read fetches through the MAPPED client only (0 calls on the connected one) and its cache key carries the pinned chain id.

Full run: 9/9 integration (3 files), 29/29 unit, package tsc --noEmit clean, prettier clean.

Revision (c838686a): chain-pinned reads without the provider map

The first cut (13b59f0e, section 2 above) routed pinned reads through a publicClients map on CofheProvider. On review that map sent reads around the connection: only the two generic read hooks honored it, a requiresACP read pinned elsewhere was gated by the connected chain's ACP, pinned reads silently ran with no wallet, and every invalidation target had to be pinned by hand. It is replaced — in a new commit, so the history keeps both — by:

  • chainId alone guards the read. It is the key's chain segment; the read runs only while the wallet is on that chain, otherwise it is disabled with a new disabledDueToWrongChain flag. Never a read of the wrong chain.
  • publicClient (with chainId) serves it. Per call and explicit: the read goes through that client wherever the wallet sits, including with no wallet connected. A client whose own chain disagrees with chainId keeps the read disabled.
  • ACP and decryption follow the read's chain. useCofheActiveACP(chainId?) looks up that chain's ACP (connected chain by default — the store was already per chain). useCofheDecrypt takes chainId and calls .setChainId(); useCofheReadContractAndDecrypt passes it through.
  • Block-awareness stays on the write's chain. Invalidation targets on another chain get a plain refresh — that chain never sees the write's block, so gating on it could only time out.

The publicClients prop is gone; it never shipped.

Tests: the provider-map test is replaced by a chain-pinned reads suite (7 scenarios): the guard on and off chain; a per-call client serving the singular and batch hooks (one shared key); a client/chainId mismatch; a read with no wallet; ACP gating by the read's chain; a real mock decrypt that succeeds only through the pinned chain's ACP; and a cross-chain write target refreshed without a block probe. Mutation-checked: breaking the ACP lookup or the cross-chain rule fails exactly those three tests, and dropping .setChainId() fails exactly the decrypt test.

Full run: 15/15 integration (3 files), 29/29 unit, package and matrix tsc --noEmit clean, prettier + eslint clean.

alexshchur and others added 21 commits September 1, 2026 19:07
useCofheWriteContract({ invalidates: [{ address, functionName?, chainId? }] })
invalidates the declared read queries once the write tx is mined, carrying the
mined block's hash as invalidation context so the triggered refetches only
trust an RPC node that already knows that block. Reverted transactions
invalidate nothing; raw query keys and full filters are accepted too.

Also exports useCofheReadContract and
constructCofheReadContractQueryForInvalidation from the package root, and adds
a browser integration test (integration-matrix) driving useCofheWriteContract +
useCofheReadContract against Anvil through the real CofheProvider, asserting
exact RPC traffic. New plain SimpleStorage fixture keeps the test free of FHE
noise. The test runs in CI on the hardhat leg and skips on testnet legs.
invalidateOnceMined skipped invalidation when the receipt status was not
success. But a reverted transaction is still mined: it sits in a real block,
burned gas and advanced the nonce, so declared reads such as an ETH balance are
stale regardless of the outcome. Skipping baked in the assumption that
invalidation targets only ever describe state the SUCCESSFUL write changes -
one assumption too many for an API meant to be the only invalidation primitive
a consumer needs.

Unconditional by design - no invalidateOnRevert flag. A flag would reintroduce
the per-case reasoning the API exists to remove, and a redundant refetch of
untouched state is a cheap same-value no-op. The block-hash context is exactly
as valid for a reverted tx as for a successful one.

Not covered by the integration test yet: both existing cases use successful
writes; a revert case would need a fixture function that reverts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…try queries

Each entry now runs as its own query under the exact useCofheReadContract
key (useQueries under the hood), so write-side invalidates descriptors,
block-aware refetches, and cache sharing with singular reads all apply to
dynamic-length batches with no extra wiring. A batching transport still
coalesces the entries into one JSON-RPC request; multicall3 is no longer
needed. Exports the hook and result types from the package root; adds a
requiresACP option; multicallOptions.allowFailure keeps its semantics via
the aggregated error field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
They referenced an untracked local component and never belonged to this
branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
Drive the real write-side pipeline (invalidateOnceMined, now exported)
against a live QueryClient with QueryObserver-backed batch entries:
functionName targets refresh matching entries only, address-only targets
refresh every read of the contract, the triggered refetch holds out until
the serving node knows the mined block, the block context is one-shot,
and a batch entry dedupes onto the singular read's cache entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…nvalidation

In the spirit of react-hooks.web.test.tsx: no mocks — a consumer-style
component under the real CofheProvider in Chromium against Anvil, with a
recording EIP-1193 transport asserting exact RPC traffic. New fixture
SimpleKeyValueStore (uint256 mapping) makes the batch genuinely
dynamic-length: one getItem per key. Covered: a mined write with
invalidates refreshes every batch entry, each refetch gated on the node
knowing the mined block; the singular read of the same call shares the
batch entry cache (no duplicate fetch); without invalidates the batch
stays stale and the manual primitive refreshes it. Replaces the earlier
mock-based unit tests (deleted, and the invalidateOnceMined test export
reverted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…ray as one tuple

transformEncryptedReturnTypes treated a tuple[] / tuple[N] output as a
single tuple: named-component lookups on the array found nothing and the
result collapsed to {} — breaking any read whose return type is a struct
array (a batch getter returning Order[] through useCofheReadContract).
Struct arrays now map the tuple transform over their elements, with
fixed-size lengths enforced; regression tests cover encrypted fields,
hand-written ABIs without internalType, empty arrays, and the fixed-size
check. Drops a stray console.log from the same function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
… an address literal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
invalidates now also accepts (receipt) => targets for writes whose
targets are only known from the outcome — an id read out of the mined
event logs. Descriptors gain an optional args field (with functionName)
that extends the key prefix by the serialized args, narrowing a target to
one exact call. SimpleKeyValueStore emits ItemSet so the integration
suite proves both together: a write whose target is derived from the
receipt logs refreshes exactly the touched batch entry, block-gated,
leaving sibling entries untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…ular read too

A useCofheReadContract of the written key updates from the same shared
cache entry as the batch entry, in the same single refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
Public token balance and token allowance queries drop their bespoke
tokenBalance / tokenAllowance key families and become ordinary
cofheReadContract keys: [...readPrefix(token, fn), [serialized args]].
They are ordinary contract reads (balanceOf(account), allowance(owner,
spender)) and are now keyed like it; the native ETH balance stays a
pseudo-read keyed at the ETH sentinel address. A plain
useCofheWriteContract descriptor ({ address: token, functionName:
balanceOf | allowance }, args-narrowable) reaches them with no special
vocabulary.

Alongside, the address segment of EVERY cofheReadContract key is now
canonicalized (best-effort checksummed) inside
constructCofheReadContractQueryForInvalidation, which read keys and
invalidation descriptors both flow through, so a read key and an
invalidation target can never disagree on address case; consumers no
longer need to pre-checksum on either side.

Covered by a new Anvil integration file (react-token-balances): an
approve refreshing an args-narrowed allowance, and a mint refreshing
the ERC20 balance plus the native pseudo-read, both block-gated on the
wire and proven descriptor-case-insensitive by the lowercase sentinel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
… generic read

useTokenAllowance is now a thin wrapper around useCofheReadContract
(requiresACP: false), and createPublicTokenBalanceQueryOptions delegates
to createCofheReadContractQueryOptions for ERC20 balances - same key,
same block-aware queryFn, same recognition meta, and the SAME cache
entry as a direct read of the same call (proven in the integration test:
two observers, one query, one eth_call). The native ETH balance is the
one carve-out - eth_getBalance is not a contract read - so only its
queryFn stays bespoke; its key comes from the generic builder at the
ETH sentinel address. The hand-rolled queryFns, the exact key builders
and the unused allowance factory are deleted; only the
ForInvalidation prefix builders remain.

Also removes the trailing enabled segment from the read query key (an
old CofheError blank-screen workaround that no longer applies): keys
are pure data identity now, so a read keeps its cache entry across
disabled/enabled transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…s descriptors

constructPublicTokenBalanceQueryKeyForInvalidation and
constructTokenAllowanceQueryKeyForInvalidation had exactly one consumer
each: the internal pending-transaction tracker. They were thin wrappers
over what normalizeInvalidationTarget already produces from a plain
descriptor, i.e. the last two pieces of token-read key vocabulary.

normalizeInvalidationTarget is now exported from the write hook (still
package-internal) and the trackers two call sites build their filters
from plain descriptors ({ address, functionName, args }), with account
args checksummed to meet the hooks canonical form. One normalizer
behind ALL invalidation - write-hook declared or tracker dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…d write

Every mined tx burned gas from the sender, success or revert - so the
one read that is stale after ANY write is the senders native balance,
now expressible in the grammar as the ETH-sentinel pseudo-read.
useCofheWriteContract appends that target implicitly after every mined
write (receipt.from is the account - no wallet-state threading), even
when no invalidates were declared at all. Guarded on a mounted match so
apps with no native-balance read pay nothing and no context stash
lingers. Recipients of value transfers stay caller knowledge, declared
like any other target.

The integration suite now proves the transparency: the mint scenario
declares NO native target and the eth_getBalance refetch still fires;
the approve scenario asserts the implicit refetch on a tx that touches
no balances at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…l read errors

maybeWaitUntilRpcAware returned only on blockKnown AND read fulfilled -
a read that failed against a node that HAS the block (a revert, a
missing contract, a wrong-chain call) became a permanent 1 req/s poll
that react-query never saw as an error. Now the two conditions are
split: block known + read failed throws the read error immediately (the
gate cannot fix it; the callers retry policy takes over), and a block
the node NEVER learns - hopeless lag, or a reorged-away hash that can
never become known - bounds out after maxWaitMs (default 60s) and
degrades to the un-gated read result instead of failing it.

resolveReceiptBlockHash gets the same bound (throws after maxWaitMs
instead of polling forever - attempt was counted but never used), and
re-fetches the receipt BY TRANSACTION HASH instead of
getBlock({ blockNumber }): by-height lookup under a reorg returns
whichever block now occupies that height, quietly reintroducing the
ambiguity hash gating exists to avoid. Signature becomes
(receipt, client, { signal, maxWaitMs, pollingIntervalMs }). The write
hooks background invalidation deliberately passes no signal -
invalidation is cache-global work that must survive unmount; the bound
is its safety.

Unit tests use scripted clients by necessity: a single-node Anvil can
never be made unaware of its own block nor emit zero-sentinel receipts.
The real-chain happy paths stay covered by the integration matrix
(3 files, 7/7 green after this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…tic delivery

Consume-on-first-resolve delivered block-awareness reliably to the
concurrent refetches an invalidation triggers, and by ordering luck to
everything staggered: a query created after the txs outcome was known
(getPublish(orderId, newSeq) after a fill, getOrder(newId) after a
create), an enabled flip, the second stage of an ids->batch read. The
context store now holds per-prefix WATERMARKS: every fetch under the
prefix gates until the entry expires (ttlMs, default 60s); entries
expire by time, never by being read; expired entries are pruned lazily
(fixing the leak where an undelivered note lived forever); and prefix
matching mirrors react-querys partial-matching semantics (deep subset
per segment), so an args-narrowed watermark reaches exactly the keys
its invalidation refetched. consumeInvalidationContext is gone from
the public surface.

The integration suite gains the staggered scenario that FAILED under
the old semantics: invalidate, let the refetches settle, then enable a
reader whose key was never fetched - and assert its first fetch probes
the mined block like its concurrent siblings did. The former one-shot
store-empty assertions flip to watermark-persists assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL
…n clients on the provider

useCofheReadContract and useCofheReadContracts accept chainId to pin a
read (or a whole batch) to a specific chain instead of following the
connected one. The pinned id becomes the keys chain segment - pin the
matching invalidates targets to the same chainId so they meet - and the
fetch routes through an app-supplied client for that chain, declared
via CofheProviders new publicClients prop and resolved by
useCofhePublicClient(chainId?). Without a client for the pinned chain
the read stays disabled rather than silently querying the wrong chain.

Closes the dual-chain footgun where parking the wallet on one chain
(the auction app pins Base Sepolia) broke every read of an app whose
contracts live on another (OTC on Arbitrum Sepolia): reads followed the
wallet, eth_call hit a chain with no contract, and every query decoded
0x. The integration suite proves the routing contract with two
recording transports: a pinned read fetches through the MAPPED client
only, and its cache key carries the pinned chain id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxFGc2b3J672QLnTJdB2BL

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a21b72c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@cofhe/react Patch
@cofhe/example-react Patch
@cofhe/abi Patch
@cofhe/foundry-plugin Patch
@cofhe/hardhat-3-plugin Patch
@cofhe/hardhat-plugin Patch
@cofhe/mock-contracts Patch
@cofhe/sdk Patch
@cofhe/site Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cofhesdk-react Ready Ready Preview Sep 17, 2026 5:08am UTC
1 Skipped Deployment
Project Deployment Actions Updated
cofhesdk-docs Ignored Ignored Sep 17, 2026 5:08am UTC

Request Review

An error occurred while trying to automatically change base from fix/react-blockaware-wait-bounds to feat/react-tokenbalance-read-keys September 7, 2026 16:27
…icClient, chain-aware ACP, decrypt and invalidation
@alexshchur
alexshchur changed the base branch from fix/react-blockaware-wait-bounds to master September 14, 2026 07:14
…363)

# Conflicts:
#	packages/react/src/hooks/useCofheReadContract.ts
#	packages/react/src/hooks/useCofheReadContracts.ts
#	packages/react/src/hooks/useCofheWriteContract.ts
#	packages/react/src/utils/invalidationContext.ts
#	test/integration-matrix/test/react-hooks.web.test.tsx
#	test/integration-matrix/test/react-read-contracts.web.test.tsx
#	test/integration-matrix/test/react-token-balances.web.test.tsx
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Vercel previews (commit a21b72c)

Project Deployment Updated
docs Visit Preview Sep 17, 2026 05:10 UTC
react Visit Preview Sep 17, 2026 05:12 UTC

@architect-dev architect-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple small fix requests.

Also I got this during my audit but I'm not sure I understand it, maybe you'll have better luck:

Reorg/lagging-node case now amplifies to thousands of RPC calls — [invalidationContext.ts:76]
Because watermarks are no longer consumed, a block hash the node never learns (reorged away, or a lagging load-balanced backend) turns what used to be one bounded 60s stall into a 60s stall on every fetch under the prefix for the whole TTL — and maybeWaitUntilRpcAware issues both the probe and the full contract read once per second, discarding the read result each non-final iteration (~120 RPC calls per fetch). Two window refocuses over 10 covered reads ≈ 2,400 wasted calls in a minute. Also note the changeset's "resolves instantly in the same JSON-RPC batch" claim doesn't hold for non-batching transports like custom(window.ethereum). The code already has hooks for the fix: the unused onSuccess callback to memoize per-(client, blockHash) confirmation, probe-only inside the loop, and one shared in-flight probe per hash.

Comment on lines +40 to +55
function partialDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a && b && typeof a === 'object') {
return Object.keys(a).every((key) =>
partialDeepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
);
}
return false;
}

function queryKeyStartsWith(fullQueryKey: QueryKey, prefixQueryKey: QueryKey) {
if (prefixQueryKey.length > fullQueryKey.length) return false;

return prefixQueryKey.every((segment, index) => JSON.stringify(segment) === JSON.stringify(fullQueryKey[index]));
return prefixQueryKey.every((segment, index) => partialDeepEqual(segment, fullQueryKey[index]));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can replace this with react querie's existing partialMatchKey

@@ -45,6 +52,7 @@ export function useCofheDecrypt<U extends FheTypes, TSeletedData = UnsealedItem<

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs the chanId as well?

Comment thread packages/react/src/hooks/useCofheWriteContract.ts Outdated
Comment on lines +196 to +201
const targetChainId = cofheReadKeyChainId(queryFilters.queryKey);
const onOtherChain =
targetChainId !== undefined && connectedChainId !== undefined && targetChainId !== connectedChainId;
return onOtherChain
? queryClient.invalidateQueries(queryFilters)
: invalidateQueriesWithContext(queryClient, queryFilters, { blockHashToBeAwareOf: blockHash });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is failing open if onOtherChain is false here. An incorrect chain will be waiting for a blockHash from another chain (which it will never see). onOtherChain can be false if the parsed queryKey targetChainId doesn't resolve to a number or is undefined, which can happen if a request was created with an undefined connectedChainId.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: 4e65a4d, cb350d4, e9da0ca.

Now Instead of gating a fetch by the block hash unless we can prove the target is on another chain, we now gate only the targets we're sure are on the write tx's chain, and just invalidate the rest (plain refetch, no block-hash wait).

…xists on

Dirty every declared target, gate only the ones on the mined chain. The guard used to
watermark unless it could prove a target was on ANOTHER chain, and it could only prove that
for descriptors; a raw key with no chain segment, a filters target with a consumer key, or an
undefined connected chain all fell through and stored the mined block over reads served by
other chains - which then probed their node for a block it will never see, up to a full wait
window per fetch for the whole watermark TTL.

Now normalizeInvalidationTarget carries the target's chain as targetChainId (descriptor
chainId, else connected; a cofhe key's chain segment; an explicit targetChainId on filters),
the mined chain comes from the client that did the write, and a target is gated only on a
positive match. A cofhe prefix spanning every chain (no chain segment) is dirtied whole and
gated on the mined chain's slice via the new watermarkKey option, so "refresh everything"
keeps its block-awareness where the block exists. invalidateQueriesWithContext documents the
contract callers must keep on their own.

Tests: unit coverage of the rule on plain data; a browser regression test with a second
chain whose node never learns the mined block, in both target forms; added to test:react.
An enabled read never has an undefined chain segment: the connection sets chainId and
publicClient together, and a pinned read requires chainId. So the only key that spans every
chain is the bare prefix; treat any other shape as a key the helper must not touch.
useCofheDecrypt decrypts with the ACP and threshold network of the chain it is given
(chainId, default the connected chain), but its cache key was [decryptCiphertext, ctHash,
utype]: the same handle decrypted on two chains shared one entry, and flipping chainId on a
mounted hook never refetched, so one chain could render the other's plaintext - exactly our
fork/anvil topology.

The key now ends with the chain (constructCofheDecryptQueryKey; last so observers indexing by
key[1] keep working), useCofheReadContractAndDecrypt evicts a superseded decrypt under the
same shape, and the decrypt meta falls back to that chain. Persisted entries of the old key
shape are orphaned, which is fine.

Test: browser regression - the same ctHash decrypted on two chains is two cache entries, each
keyed by its chain; added to test:react.
[decryptCiphertext, chainId, ctHash, utype], matching [cofheReadContract, chainId, ...]; the
decryption-activity hook reads the ctHash from slot 2 accordingly.
The store mirrored react-query's partial matching with two local helpers; importing the real
matcher makes "a watermark reaches exactly the queries its invalidation refetched" true by
construction. The one shape they disagreed on - a prefix with a trailing undefined segment,
which react-query lets match the shorter key - is now pinned by a store test. The store's
remove() had no caller since contexts stopped being consumed; dropped.
@alexshchur

Copy link
Copy Markdown
Contributor Author

Also I got this during my audit but I'm not sure I understand it, maybe you'll have better luck:

Reorg/lagging-node case now amplifies to thousands of RPC calls — [invalidationContext.ts:76] Because watermarks are no longer consumed, a block hash the node never learns (reorged away, or a lagging load-balanced backend) turns what used to be one bounded 60s stall into a 60s stall on every fetch under the prefix for the whole TTL — and maybeWaitUntilRpcAware issues both the probe and the full contract read once per second, discarding the read result each non-final iteration (~120 RPC calls per fetch). Two window refocuses over 10 covered reads ≈ 2,400 wasted calls in a minute. Also note the changeset's "resolves instantly in the same JSON-RPC batch" claim doesn't hold for non-batching transports like custom(window.ethereum). The code already has hooks for the fix: the unused onSuccess callback to memoize per-(client, blockHash) confirmation, probe-only inside the loop, and one shared in-flight probe per hash.

@architect-dev , this is a real edge case, but the number is too high: a window refocus doesn't start a second fetch (react-query joins the one in flight, cancelRefetch: false), so it's ~1,200, not 2,400.

Worst case is a reorg indeed. Then each covered read polls for a minute, gives up, and returns the non-block-aware value. The watermark expires at the same moment, so that happens once per read.

My proposal: exponential backoff in the wait loop (1s doubling, capped at 10s), so a stalled read makes ~10 probes over the minute instead of 60.

For the audit's example (i.e. 10 covered reads, reorged block): ~10 ticks × 2 calls × 10 reads ≈ 200 calls over the minute, down from ~1,200.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants