feat(react): TTL-watermark invalidation delivery + chain-pinned reads - #364
alexshchur wants to merge 34 commits into
Conversation
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
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
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
There was a problem hiding this comment.
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 detectedLatest commit: a21b72c The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
…icClient, chain-aware ACP, decrypt and invalidation
…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
|
Vercel previews (commit
|
…ests, exact watermark assertions, TTL expiry end to end
architect-dev
left a comment
There was a problem hiding this comment.
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.
| 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])); | ||
| } |
There was a problem hiding this comment.
I think we can replace this with react querie's existing partialMatchKey
| @@ -45,6 +52,7 @@ export function useCofheDecrypt<U extends FheTypes, TSeletedData = UnsealedItem< | |||
There was a problem hiding this comment.
I think this needs the chanId as well?
| const targetChainId = cofheReadKeyChainId(queryFilters.queryKey); | ||
| const onOtherChain = | ||
| targetChainId !== undefined && connectedChainId !== undefined && targetChainId !== connectedChainId; | ||
| return onOtherChain | ||
| ? queryClient.invalidateQueries(queryFilters) | ||
| : invalidateQueriesWithContext(queryClient, queryFilters, { blockHashToBeAwareOf: blockHash }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
…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.
@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. |
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
enabledflip, the second stage of an ids→batch read.Now
invalidateQueriesWithContextstores a watermark: until it expires (ttlMsoption, 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 —createdAtexisted 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 likeargs: [id]reachesfn(id, seq)-shaped keys exactly like the invalidation itself does — previously the store's exact-JSON segment comparison silently missed them.consumeInvalidationContextleaves the public surface (nothing consumes on delivery any more). The repeated gate probe is near-free: once the node has caught up,eth_getBlockByHashresolves 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/useCofheReadContractsacceptchainIdto pin a read (or a whole batch) to a specific chain instead of following the connected one. Three layers, all addressed:invalidatestargets to the samechainIdso they meet;CofheProvider's newpublicClients?: Record<number, PublicClient>prop and resolved byuseCofhePublicClient(chainId?). App-supplied on purpose — the app owns transport choices, and the block-aware gate depends on batching;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_callhit a chain with no contract and every query decoded0x.Tests
Same no-mock house style. New scenarios in
react-read-contracts.web.test.tsx:Full run: 9/9 integration (3 files), 29/29 unit, package
tsc --noEmitclean, prettier clean.Revision (
c838686a): chain-pinned reads without the provider mapThe first cut (
13b59f0e, section 2 above) routed pinned reads through apublicClientsmap onCofheProvider. On review that map sent reads around the connection: only the two generic read hooks honored it, arequiresACPread 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:chainIdalone 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 newdisabledDueToWrongChainflag. Never a read of the wrong chain.publicClient(withchainId) 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 withchainIdkeeps the read disabled.useCofheActiveACP(chainId?)looks up that chain's ACP (connected chain by default — the store was already per chain).useCofheDecrypttakeschainIdand calls.setChainId();useCofheReadContractAndDecryptpasses it through.The
publicClientsprop is gone; it never shipped.Tests: the provider-map test is replaced by a
chain-pinned readssuite (7 scenarios): the guard on and off chain; a per-call client serving the singular and batch hooks (one shared key); a client/chainIdmismatch; 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 --noEmitclean, prettier + eslint clean.