Skip to content

fix(cow-shed): account code cache leaks across chains via shared localStorage key - #985

Open
gomesalexandre wants to merge 1 commit into
cowprotocol:mainfrom
gomesalexandre:r22_cowshed_cache
Open

fix(cow-shed): account code cache leaks across chains via shared localStorage key#985
gomesalexandre wants to merge 1 commit into
cowprotocol:mainfrom
gomesalexandre:r22_cowshed_cache

Conversation

@gomesalexandre

Copy link
Copy Markdown

tl;dr CowShedHooks.doesAccountHaveCode() caches adapter.getCode(account) results keyed on account alone. TTLCache (in @cowprotocol/sdk-common) persists to real browser localStorage by default, and every CowShedHooks instance 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

CowShedSdk keeps one CowShedHooks instance per chain:

protected hooksCache = new Map<SupportedChainId, CowShedHooks>()

Each instance constructs its own TTLCache object, but TTLCache's storage backend is real localStorage (a genuinely global, origin-wide key-value store, not per-instance) unless it's unavailable:

this.storage = useLocalStorage ? new LocalStorageWrapper() : new MemoryStorage()

and the storage key is just ${keyPrefix}:${key}. accountCodeCache's keyPrefix ('cowshed-account-code') never varies by chain, and doesAccountHaveCode never included chain in the key it 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 same localStorage entry 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 throw CoWShedEip1271SignatureInvalid:

const isAccountSmartContract = await this.doesAccountHaveCode(user)
const shouldValidateEip1271Signature = isAccountSmartContract && signingScheme === ContractsSigningScheme.EIP712

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 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 (see getDomain()/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 two CowShedHooks instances for chainId 1 and 100. Account has code on chain 1, no code on chain 100.

  • Old code: chain 100's doesAccountHaveCode returns true (the stale chain-1 result) and never even calls chain 100's getCode mock -- confirmed by actually running this test against the unfixed source (git stash A/B).
  • New code: chain 100 correctly calls its own getCode and returns false.

receipts

$ pnpm jest   (packages/cow-shed)
Test Suites: 2 passed, 2 total
Tests:       1 skipped, 29 passed, 30 total

$ pnpm typecheck
$ tsc --noEmit   -> clean

$ pnpm lint
$ eslint src/**/*.ts --quiet   -> clean

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.

…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
gomesalexandre marked this pull request as ready for review September 2, 2026 08:56
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 51 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 81758aa6-5ebc-4b7b-b7b4-78e6f9b94cb4

📥 Commits

Reviewing files that changed from the base of the PR and between f1eddc8 and ee95a41.

📒 Files selected for processing (2)
  • packages/cow-shed/src/contracts/CoWShedHooks.test.ts
  • packages/cow-shed/src/contracts/CoWShedHooks.ts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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