How does a proxy make a deployed contract upgradeable, and what does that put at risk?
The proxy upgradeability pattern keeps state in the proxy and delegates calls to replaceable implementation logic, which makes storage layout, initializers and upgrade admin control the core smart contract security risks. Use this solidity answer to show the decision, trade-off, and evidence rather than a memorised definition.
What the interviewer is scoring
- Whether you can state what delegatecall does to the execution context, precisely
- Does the candidate raise initialisers before being asked why the constructor is a problem
- That they treat storage layout as an append-only migration surface rather than a source file
- Whether the answer names who holds the upgrade key as a design decision, not an afterthought
- Whether they can argue for immutability as a legitimate alternative
Answer
Short answer
Proxy upgradeability works by keeping contract state at one proxy address while forwarding calls with delegatecall to implementation code that can change. The main risks are broken initializers, incompatible storage layout, selector clashes, and an upgrade key that can rewrite the system.
What the proxy actually does
Deployed bytecode cannot be modified, so upgradeability is achieved by separating where the data lives from where the code lives. The address users interact with is a small proxy contract holding the state; it stores the address of an implementation contract and forwards every unmatched call to it using delegatecall.
delegatecall is the mechanism, and its behaviour is the thing to get exactly right: it executes the target's code in the caller's context. Storage reads and writes hit the proxy's storage, msg.sender and msg.value are preserved from the original call, and address(this) is the proxy. The implementation is therefore a library of behaviour that happens to be deployed as a contract, and its own storage is almost always empty.
// In the proxy's fallback. The implementation's code runs, but every SLOAD and
// SSTORE it performs lands in this contract's storage.
fallback() external payable {
address impl = _implementation();
(bool ok, bytes memory out) = impl.delegatecall(msg.data);
if (!ok) {
// Bubble the revert reason rather than swallowing it.
assembly { revert(add(out, 0x20), mload(out)) }
}
// In production this is written in assembly so returndata passes through
// untouched; the point here is the delegatecall and the context it keeps.
}
Upgrading is then a single storage write to the implementation address. Nothing about user balances moves, because the balances were never in the implementation.
flowchart TD
U[User] --> P[Proxy holds all state]
P -->|delegatecall| V1[Implementation v1]
A[Upgrade admin] -->|set implementation| P
P -.->|after upgrade| V2[Implementation v2]
V1 --> S[Proxy storage]
V2 --> SThe edge worth looking at is the one arrow that points into the proxy from outside the user path. Everything about the security of this design turns on who is allowed to draw it.
Constructors do not run, so initialisers replace them
A constructor executes at deployment, in the deploying contract's own context, and its effects land in the implementation's storage — which nobody uses. So any state a constructor would have set is simply absent behind the proxy. The replacement is an ordinary function called once, immediately after deployment, guarded against being called twice.
uint8 private _initialisedVersion;
function initialise(address owner_) external {
require(_initialisedVersion == 0, "already initialised");
_initialisedVersion = 1; // set before anything else can re-enter
_owner = owner_;
}
Two hazards follow. The first is the deployment race: between deploying the proxy and calling initialise, the contract has no owner, so the two steps must be atomic — usually by passing the initialisation calldata to the proxy's constructor. The second is subtler and has produced real incidents: the implementation contract is itself callable directly at its own address, and if its initialiser is unlocked, anyone may become its owner. On a design where the implementation can be made to delegatecall an attacker-supplied address, that ownership becomes the ability to destroy the implementation and brick every proxy pointing at it. OpenZeppelin's upgradeable base contracts expose a way to disable initialisers in the implementation's own constructor, and using it is not optional.
Storage layout becomes an append-only migration
Because the storage belongs to the proxy and the layout is decided by the implementation's declaration order, a new implementation must agree with the old one about what lives in every occupied slot. Reordering two variables, changing a type's width, or inserting a field in the middle silently reinterprets existing data: a balance mapping read through the wrong slot returns zero for everyone, and a boolean read from the low byte of an address is whatever that address happened to end with.
The practical rules are that you append new variables at the end, you never remove or reorder, and inherited contracts reserve gap slots so a base contract can grow later without shifting its children. This is the part people underestimate. The Solidity source stops being a description of a type and becomes a schema under migration, with all the discipline that implies — reviewed layout diffs, tooling that compares the deployed layout with the candidate one, and a note in the repository saying which slots are retired and must never be reused.
Transparent and UUPS put the upgrade logic in different places
A transparent proxy holds the upgrade function itself and routes on the caller: the admin's calls are handled by the proxy, everyone else's are forwarded. That prevents a function on the implementation from shadowing an admin function through a selector clash, at the cost of a check on every call and a heavier proxy.
The UUPS arrangement moves the upgrade function into the implementation, so the proxy is minimal and cheaper to call. The trade-off is precise and worth being able to state: the ability to upgrade now depends on the new implementation containing a working, correctly-authorised upgrade function. Ship one that omits it or gets the authorisation wrong, and you have upgraded into a contract that can never be upgraded again.
The risk that no pattern removes
Every one of these patterns converts an immutable contract into a mutable one controlled by a key. Users who audited the code they interacted with have no guarantee they are interacting with it tomorrow, and a compromised admin key is equivalent to a compromise of every asset the contract holds. This is why the honest answer to "how do you make this safe" is not another proxy variant but governance: a multisig with real signer diversity, a timelock long enough for users to exit before an upgrade lands, and published notice of pending upgrades so that "we upgraded quietly" is not available even to the team.
The corollary is that upgradeability is a choice with an alternative. A contract that must be credibly neutral is better deployed immutably, with a documented migration path — a new deployment users opt into — rather than an upgrade path they cannot refuse. Saying that out loud is usually what separates a senior answer here, because it treats upgradeability as a trust decision with a cost rather than as good engineering hygiene applied to a blockchain.
Upgradeability does not remove risk from a contract; it moves the risk from the code to whoever holds the key, where users can no longer audit it.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Why does EIP-1967 specify a pseudo-random storage slot for the implementation address?
- What is the selector-clash problem that a transparent proxy is designed to avoid?
- Why must an implementation contract behind a UUPS proxy have its own initialisers locked at deployment?
- How would you stage an upgrade so that a bug in the new implementation is recoverable?
Related questions
- Walk me through a re-entrancy attack, and how checks-effects-interactions prevents itmediumAlso on solidity and smart-contract-security4 min
- Why does storage layout dominate the gas cost of a contract?mediumAlso on solidity5 min
- A customer reports seeing another company's records in your admin console. Walk me through the first hour, and then tell me what you change so this class of bug cannot happen again.hardSame kind of round: design4 min
- Why is a recommender built as candidate generation followed by ranking rather than as one model?hardSame kind of round: design5 min