fix(cow-shed): account code cache leaks across chains via shared localStorage key - #985
Open
gomesalexandre wants to merge 1 commit into
Open
fix(cow-shed): account code cache leaks across chains via shared localStorage key#985gomesalexandre wants to merge 1 commit into
gomesalexandre wants to merge 1 commit into
Conversation
…lStorage key
CowShedHooks.doesAccountHaveCode() cached adapter.getCode(account) results
keyed on `account` alone, but TTLCache persists to real browser localStorage
by default and every CowShedHooks instance builds it with the exact same
hardcoded keyPrefix ('cowshed-account-code') regardless of chainId.
CowShedSdk keeps one CowShedHooks instance per chainId (hooksCache: Map<
SupportedChainId, CowShedHooks>), so in a browser two instances for
different chains -- same origin, same localStorage -- read and write the
SAME cache entry for the same address. An address can be a deployed
contract on one chain and undeployed (EOA, no code) on another: a
counterfactual Safe address, or a CREATE2 deploy that's only landed on one
chain so far. Whichever chain queries first wins the cache for every chain
after it, for the full 5-minute TTL.
That cached value gates shouldValidateEip1271Signature, so a stale "has
code" from one chain can make the SDK attempt (and potentially fail) an
EIP-1271 contract-signature check on a chain where the account is a plain
EOA, or a stale "no code" can make it skip that check where the account
actually is a contract wallet.
Fix: cache key is `${chainId}:${account}` instead of `account` alone.
The sibling eip1271SignatureCache does NOT have this problem -- its key
already includes the EIP-712 typed-data hash, and that hash covers the
signing domain, whose chainId comes from `this.chainId` (getDomain()). Left
untouched, confirmed via Codex adversarial pass reading getDomain/infoToSign.
Regression test spins up a real (in-memory, but faithful) localStorage
shared across two CowShedHooks instances on different chainIds, one
account with code on chain 1 and none on chain 100 -- fails against the old
single-key cache (returns the stale chain-1 `true`, never calls chain
100's getCode mock at all), passes with the fix.
gomesalexandre
marked this pull request as ready for review
September 2, 2026 08:56
Contributor
|
Warning Review limit reachedNext included review available in 51 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
shoom3301
approved these changes
Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tl;dr
CowShedHooks.doesAccountHaveCode()cachesadapter.getCode(account)results keyed onaccountalone.TTLCache(in@cowprotocol/sdk-common) persists to real browserlocalStorageby default, and everyCowShedHooksinstance builds this cache with the exact same hardcoded prefix ('cowshed-account-code') regardless of chain. Two chains, same browser, same address -> same cache entry.What's actually wrong
CowShedSdkkeeps oneCowShedHooksinstance per chain:Each instance constructs its own
TTLCacheobject, butTTLCache's storage backend is reallocalStorage(a genuinely global, origin-wide key-value store, not per-instance) unless it's unavailable:and the storage key is just
${keyPrefix}:${key}.accountCodeCache'skeyPrefix('cowshed-account-code') never varies by chain, anddoesAccountHaveCodenever included chain in thekeyit passed in. So in a browser dapp, calling this SDK for the same account on mainnet then Gnosis (or any two chains) hits the exact samelocalStorageentry for a full 5-minute TTL.This matters because an address's contract status genuinely differs by chain -- a counterfactual Safe address that's deployed on one chain and not yet on another is a completely normal, common state. The cached (wrong-chain) result feeds
shouldValidateEip1271Signature, which gates an extra RPC call and can throwCoWShedEip1271SignatureInvalid:A stale "has code" from chain A can make the SDK attempt (and potentially fail) EIP-1271 validation on chain B where the account is a plain EOA. A stale "no code" can make it silently skip EIP-1271 validation on a chain where the account really is a contract wallet.
Fix
doesAccountHaveCode's cache key is now`${this.chainId}:${account}`instead ofaccountalone.The sibling
eip1271SignatureCachedoes not have this problem -- its key already includes the EIP-712 typed-data hash, and that hash covers the signing domain, whosechainIdcomes fromthis.chainId(seegetDomain()/infoToSign()). Confirmed via an adversarial Codex pass reading both caches side by side; left untouched.Proof
Regression test spins up a real (in-memory but interface-faithful)
localStorage, shared across twoCowShedHooksinstances for chainId1and100. Account has code on chain 1, no code on chain 100.doesAccountHaveCodereturnstrue(the stale chain-1 result) and never even calls chain 100'sgetCodemock -- confirmed by actually running this test against the unfixed source (git stashA/B).getCodeand returnsfalse.receipts
Backend-only correctness/cache fix, no UI to screenshot -- receipts above are the full test/typecheck/lint output plus the red-before/green-after A/B via git stash.