Your v1 smart contract is not done until it can survive you.
One thing that always surprises me, and it happens more and more as the industry shifts to AI dev: the ease with which new teams omit upgradeability and disaster recovery features from their v1 smart contracts.
To be clear, these features are usually on the roadmap, right next to the audit and the bug bounty and all the other things that will happen after launch. The omission is in v1 itself, the version that actually goes onchain, takes real deposits, and gets attacked first.
It’s easy to see why this happens. A v1 contract without a proxy, without a pause switch, without an owner, is simpler. It demos better. It feels cleaner, more in the spirit of immutability. And when the code is generated rather than written (the default these days for most young teams), the recovery features are the first thing to disappear, because they are invisible in the happy path. An AI model generating a vault will give you deposits, withdrawals, and a yield strategy. It will rarely volunteer a circuit breaker. The demo works without one.
The data on AI generated code is not reassuring here. Veracode’s 2025 GenAI study found that across more than 100 models, roughly 45% of AI generated code introduced a vulnerability from the OWASP Top 10. That is not a fringe tool producing bad output. That is the average of the field, on the kind of task teams now hand to an agent without a second read.
And the consequences have started to show up with names and dates attached. In February 2026, Moonwell lost about $1.78M when an oracle misconfiguration valued cbETH at $1.12 instead of roughly $2,200, and the exposed position drained in about four minutes. A security researcher who looked at the faulty pull request flagged that it appeared to be co-authored by a frontier AI model. That is one researcher’s public read, not an official post-mortem finding, and whether a model actually caused the bug is not something anyone outside the team can prove. What is not in dispute is the shape of it: the code looked right, it compiled, it got merged, and the money was gone before a human made a decision. That is the environment your v1 now ships into.
Speed is fine. I use AI in my own work on a daily basis. But it raises the stakes on the one decision that is almost free at deploy time and almost impossible to change later: whether your v1 can be fixed, paused, or unwound when, not if, something goes wrong.
The tombstones
A few stories every founder should know before choosing an immutable v1.
Parity, 2017. A user initialized the shared library contract behind Parity’s multisig wallets and then triggered its self destruct. Every wallet that depended on that library froze instantly. 513,774 ETH across 587 wallets, locked, not stolen, just permanently unreachable, which from the owners’ perspective is the same thing. A proposal to recover the funds, EIP-999, was put to the community and never adopted. The money is still frozen today. There was no pause, no upgrade path, no recovery function. There was nothing to argue about, because there was nothing anyone could do.
The DAO, 2016. The DAO lost about 3.6M ETH, roughly $60M at the time, to a reentrancy bug in its split function. It had no pause and no upgrade mechanism either. The only recovery available was to split the entire Ethereum chain in two, which is how we got ETH and ETC. When your disaster recovery plan requires a hard fork of the underlying blockchain, you do not have a disaster recovery plan.
Audius, 2022. This is the one worth getting right, because the usual retelling is wrong. It was not simply a forgotten storage gap. Audius used a custom upgradeable proxy whose admin address sat in storage slot 0, the same slot OpenZeppelin’s Initializable uses for its initialization flag. The collision made the init guard readable as already satisfied, the contracts could be reinitialized, and an attacker delegated a large block of governance tokens to themselves and pushed through a malicious proposal. About 18.56M AUDIO left the treasury, reported around $6M. Two prior audits had not caught the slot collision. The lesson is not “add a gap.” The lesson is that storage layout under a proxy is load bearing, and it fails silently.
Kinto, 2025. An exploit drained roughly $1.55M, a modest number next to the others on this list. Kinto wound down within weeks. That is the part founders underrate: a protocol does not need a nine figure loss to die. It needs a loss it cannot absorb, explain, or recover from, arriving before it has the balance sheet or the trust to survive one. Recoverability is not only about getting funds back. It is about still having a company on the far side of a bad week.
I‘ve written genesis contracts for an L1 and run protocol upgrades on a live EVM mainnet, with real value on the other side of the transaction. Genesis is v1 in its purest form: no previous version, no migration, no users to coordinate with, and whatever you bake in at block zero, the network inherits. Nobody who has operated a live protocol ships an immutable v1 on purpose. You learn exactly once what it feels like to know a mistake cannot be walked back, and you never design that feeling into a system again.
The v1 checklist
Here is what I would not launch without. Every item is cheap at deploy time. Most are impossible to retrofit.
I highly recommend your team to either implement all of them in your v1 or have a very good reason why not to do so.
1. Upgradeable from day one, UUPS, and the owner is the upgrader
Use a proxy. Specifically, use UUPS (ERC-1822) unless you have a concrete reason not to.
UUPS keeps the proxy to a minimal ERC-1967 shell and puts the upgrade logic in the implementation, where your access control already lives. There is no separate ProxyAdmin contract to deploy, fund, and keep track of, and no admin versus user selector ambiguity to reason about. OpenZeppelin has moved its own recommendation this way: its current docs describe UUPS as the lightweight, versatile default and note that the Transparent pattern carries its admin logic in the proxy and costs more to deploy. I am going to skip the exact gas-per-call numbers you will see quoted around this, because they depend on library versions that keep changing and the per-call difference against a current Transparent proxy is smaller than the folklore claims. The durable reasons to prefer UUPS are a smaller deploy footprint and one less contract in your trust surface.
There is also one option most teams miss. Because the upgrade function lives in the implementation, a later version can remove it, and the contract becomes immutable by choice after it has earned stability, instead of immutable by accident on day one. Treat that removal as a deliberate, final act. Ship it by mistake and you can no longer upgrade the contract. That is not the same catastrophe as funds frozen, the business logic keeps running, but it is a self inflicted dead end with no reason to exist.
The two rivals, briefly. Transparent proxies still work and are a defensible choice. Diamond (ERC-2535) is genuinely powerful for systems that outgrow the contract size limit, but it is a lot of machinery and audit surface, and rarely the right v1 for a small team. Beacon proxies make sense when one upgrade must hit many identical contracts at once, and that shared blast radius is also their risk.
Now the part teams get wrong. In UUPS, the gate to the whole system is one function:
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
Force the upgrade authority to be the owner address. Not a separate upgrader key kept on a developer’s laptop, not an address nobody remembers assigning, not a role granted during testing and never revoked. The address that owns the contract is the address that can upgrade it: one key set to protect with a multisig and a timelock, one address whose compromise is already your worst case, so the upgrade path adds no new worst case of its own. Every additional privileged address you invent is an additional key that can be lost, leaked, or left behind by someone who quit. Put bluntly, v1 should not have two gods.
Two more UUPS rules that are not optional:
- Never ship an upgrade that removes or breaks the upgrade function, unless that removal is the deliberate final step above. OpenZeppelin’s tooling has compatibility checks that catch most of these; keep them on.
- Test the upgrade path on a fork before mainnet. An upgrade mechanism that has never been exercised is a hypothesis, not a capability.
2. The constructor trap: in upgradeable contracts, the constructor does not run
This one deserves its own section because it produces a specific, repeatable, silent bug, and generated code walks into it constantly.
When you deploy an upgradeable contract, two different things exist: the implementation, deployed once, and the proxy, which holds the state and which users actually interact with. The constructor runs exactly once, on the implementation, against the implementation’s own storage. It never runs for the proxy. Every line of setup you put in a constructor, assigning an owner, linking a dependency, recording a configuration value, happens in storage nobody uses, and the proxy starts life with all of it unset.
That is why upgradeable contracts initialize through an initialize() function guarded by the initializer modifier: it is ordinary function code, executed through the proxy, writing to the proxy’s storage.
Now the part that bites even careful developers: inline variable initialization is constructor code. When you write
uint256 public feeBps = 250;
the compiler places that assignment in the constructor. In a plain contract it works fine and reads as clean, idiomatic Solidity, which is exactly why models emit it so reliably, since most of their training examples are plain contracts. Behind a proxy, feeBps reads 0 until initialize() sets it, and nothing reverts, nothing warns, nothing looks wrong in the demo. Your fee is silently zero. Or your treasury is the zero address. Or your cap is unlimited. The one partial exception is immutable variables, which live in the bytecode rather than storage and do carry over through the proxy. They are fine for true constants and useless for anything that must differ per deployment.
The rule set is short enough to enforce in review:
- In an upgradeable implementation, the constructor contains one thing only: _disableInitializers(). That call locks the implementation so nobody can initialize it directly and seize it, the hole that in 2021 put real money at risk at KeeperDAO and Rivermen and in 2022 earned a Wormhole researcher a $10M bounty, the largest ever paid at the time, for an uninitialized implementation. Note what that call does not do: it does not initialize your proxy. Locking the implementation and initializing the proxy are two separate jobs, and treating them as one is how uninitialized-proxy incidents keep happening.
- All real initialization lives in initialize(), behind the initializer modifier, setting every variable explicitly, including the ones with obvious defaults.
- Any non trivial logic in a constructor, wiring contracts together, seeding state, approving spenders, is a review blocking finding in an upgradeable codebase. Full stop.
3. Storage discipline: the gap, or namespaced storage
Upgradeable contracts share one storage layout across all versions. If a base contract gains a variable in v2, every variable below it shifts, and the new implementation reads garbage where user balances used to be. This failure mode is silent. No revert, just wrong data. It is the same family as the Audius collision above.
The classic defense is a reserved gap at the end of every upgradeable base contract:
uint256[50] private __gap;
Add a variable in v2, shrink the gap by the slots you consumed. The modern alternative is ERC-7201 namespaced storage, which is what OpenZeppelin Contracts v5 uses for its upgradeable variants: each module keeps its state in its own computed location, so collisions between modules are structurally unlikely. Either discipline works, and a carefully managed append-only layout can even be kept compatible without a reserved gap. What does not work is having no discipline and hoping. Pick an approach on day one, enforce it in code review, and add a storage layout check to CI so a layout breaking pull request fails the build instead of failing on mainnet.
4. Ownership changes: two-step, always
If there is one item on this list I would tattoo on the deploy checklist, it is this one, because it defuses the single most irreversible mistake in an ownership change.
A one step ownership transfer is final in the same transaction. If the new address has a typo in it, belongs to the wrong network, is a contract that cannot sign anything, or is simply an address nobody has the key to, the transfer still succeeds. The old owner is gone, the new owner does not exist in any practical sense, and every capability on this page dies with that one fat fingered transaction: no upgrades, no pause, no rescue, no parameter changes, forever. The protocol keeps running, user funds and all, with an empty chair where the admin used to be. It is the Parity freeze, self inflicted, in one line of code.
Two step ownership (OpenZeppelin’s Ownable2Step) removes that failure class. The current owner nominates a new owner. Nothing changes yet. The new address then has to actively accept ownership, from the new address, proving it exists, is controlled by someone, and can sign. Only then does the transfer complete. A wrong nomination becomes a recoverable embarrassment instead of a permanent loss. The cost is one extra transaction.
Be honest about what it does not do. Two step ownership protects you from handing control to an address nobody holds. It does nothing against a compromised owner key: an attacker holding that key can nominate themselves and accept, all in house. It is insurance against the fat finger, not against theft. Compromised-key survival is a separate design, and it is the next item.
Related, and simpler: never call renounceOwnership on a live v1. Renouncing is marketed as decentralization and finality. What it actually is, at v1, is throwing away the parachute to prove you trust the plane.
5. Every privileged address must be replaceable
Ownership is not the only privileged address in a real system. There is the guardian who can pause, the oracle that feeds prices, the treasury that receives fees, maybe a keeper or a strategist role. Every one of those addresses must be changeable by the owner, because addresses fail in two ways: keys get compromised, and keys get lost.
Run the scenarios on an address that cannot be changed. A compromised guardian key means an attacker can pause your protocol whenever they like, forever, a permanent denial of service you cannot repair. A merely lost key, nobody’s fault, no malice anywhere, produces the Parity outcome at the admin layer: capabilities that exist on paper and can never be exercised again.
So the rule is mechanical: any address the contract treats as privileged gets a setter, the setter is owner only, the setter emits an event (item 8), and for the owner itself the change goes through the two step flow (item 4). The context is not theoretical. Hacken’s data for the first quarter of 2025 put roughly $1.63B of about $2B in stolen funds down to access control failures. The admin layer is where the money actually goes, and key rotation is how you respond when one of those keys goes bad.
6. Granular pause: fast to stop, slow to restart
A circuit breaker is the difference between an incident and a post mortem. When something looks wrong, the owner, or a faster guardian, must be able to stop the funds: halt deposits so new money stops entering a broken system, and halt withdrawals or borrows so a drain in progress stops moving.
Make the switches granular. Pausing inflows and pausing outflows are different decisions with different costs. Pausing deposits during a scare costs your users a delay. Pausing withdrawals traps their money with you, so it is the nuclear option, justified when funds are actively leaving through a bug and not as a response to a rumor. A single all or nothing pause forces you to choose between doing nothing and doing the maximum. Two switches let you respond at the size of the actual problem. One caution: a pause aimed at the wrong function can block safe exits while the exploit keeps running, so pause the operation that is actually causing harm, and test that the safe routes stay open.
Split the authority by speed as well. Pausing should be fast: a dedicated guardian role that can act in one transaction, no timelock, because attackers move in minutes. Unpausing should be slow and deliberate, owner only. Two details get overlooked. The pause must carry no cooldown or usage limit that locks you out of pausing again in the middle of a messy incident. And it must be reachable by whoever happens to be awake, because a pause you cannot reach at 3am is decoration.
In November 2023, Aave’s guardian paused a set of V2 markets and froze several V3 assets after an issue with one feature was reported. The incident closed with a sentence every protocol wants to be able to write: no user funds were lost. That sentence was possible because the pause existed before it was needed.
7. A timelock on upgrades and critical parameter changes
An upgrade that executes the moment it is signed is invisible until it lands. Put upgrades and sensitive parameter changes behind a timelock, OpenZeppelin’s TimelockController or a Safe with a delay module. 48 hours is a common minimum.
The delay does two jobs. It gives your own team a window to catch a bad, or malicious, proposal before it executes. And it gives users who disagree with a change time to exit. A protocol whose users can leave before a change lands is a protocol users can trust with the next one. Choose the length deliberately, though, against your exit mechanics: if users need three days to unwind a position, a two day upgrade delay is not really an exit window, and if withdrawals can be paused, the promise needs even more scrutiny.
At CoreDAO I built a local fee market at the network layer. Live parameter systems teach humility fast: the values you launch with are wrong in ways you only discover under real load. A system whose parameters can only change instantly and silently is a system designed by someone who believes their first guess was perfect.
One pairing note: the emergency guardian from item 6 must not share this delay, and must not hold any power beyond pausing. An emergency path that can also move funds or upgrade instantly defeats the entire timelock.
8. A detailed event on every set and every fund action
Every setter, every role grant, every privileged address change, every pause, every upgrade, every fund movement the contract initiates: emit a detailed event, with the actor, the old value, the new value, and the amounts involved.
A word about the name, because the name causes real damage. “Event” is a poor name for what this is. It sounds like a notification, a convenience for the frontend team, the first thing to cut when a deadline moves. What an event actually is: a record written into the transaction log, stored on chain, that contracts cannot read back as ordinary state. It is written for the outside world, and the outside world is everyone who will ever have to understand your system under pressure. Indexers read events. Monitoring tools alert on them. Block explorers display them. Auditors reconstruct behavior from them. During an incident, when the question is what happened, in what order, initiated by whom, events are the difference between an investigation and archaeology. A setter without an event is a silent configuration change to a live financial system. A fund action without an event is a movement of other people’s money with no receipt.
Events are not your only forensic trail, and you should not treat them as proof on their own. A malicious implementation can simply decline to emit the record you expected, so monitor balances and critical state as well. But as a baseline, a contract that narrates its own privileged actions is one you can actually operate under fire.
9. A scoped way to extract excess and stuck funds
Tokens get sent to contracts by mistake. Fees accumulate where your accounting did not plan for them. Airdrops land on your contract address. Without a rescue function, that value can be locked forever, which helps nobody, including the users who will beg support to retrieve tokens they sent straight to the contract.
So include an extraction function, owner only, that can move tokens or ETH the contract holds. The scoping is the entire point. An unrestricted “send everything to the owner” function is a rug vector, and auditors and users will read it as one, correctly.
But be careful how you define excess, because a token balance is not automatically surplus. If users deposited 100 units and the contract holds 110, the extra 10 might be shareholder yield, backing for a queued withdrawal, collateral, or earned protocol fees. The balance alone does not tell you who owns it. A formula like “balance minus recorded liabilities” is only meaningful if those liabilities are complete, current, and measured in the same units, and that is a real accounting model, not a one liner. Where you can, refuse outright to touch the core asset, the way Compound’s market contracts reject a sweep of their own underlying token. Separate accidental-token recovery from fee withdrawal from emergency migration, give each its own permissions and destination rules, and log every sweep with an event so the extraction is public the moment it happens.
10. Rate limits on value outflow
Assume a bug gets exercised before anyone notices, and design so the damage is bounded: caps on withdrawals or borrows per block, per hour, or per day, sized to normal usage with headroom.
Moonwell is the cautionary detail here, and it cuts against the lazy version of this advice. When the oracle mispriced cbETH in February 2026, the team did reduce caps toward zero, but that happened after the bad debt had already formed, in a window measured in minutes. A cap that a human sets during the incident is usually too late. A limit that the contract enforces automatically, before anyone is awake, is what actually bounds the loss. A bug that can drain a contract in one transaction is a total loss. A bug that hits a standing rate limit is an incident you can pause your way out of. There is a standard for this pattern now, ERC-7265, for teams that want it off the shelf.
One small bonus, thirty seconds of work, that ends a class of confusion:
function version() external pure returns (string memory) {
return “1.0.0”
}
Frontends, monitors, and your own incident channel can read it to orient fast. Just remember it is a label, not proof: a buggy or malicious implementation can return any string it likes, so the real record of what is deployed is the implementation address and its code hash, kept in your operational notes.
11. Rehearse the recovery while the contract is still empty
Everything above is capability. This item is the one that turns capability into a plan, and it is the one teams skip.
Before launch, run the whole recovery on a fork with realistic balances, queued claims, and the production permission set. Trigger a representative failure. Have the guardian pause the affected route. Rotate an operator key. Execute the upgrade through the real multisig and timelock path, not a shortcut. Run the migration. Then check that balances, user claims, and permissions all survived, that an unauthorized caller is rejected, that the rotated-out key can no longer do anything, and that a single missing signer does not make the procedure impossible.
Two things this rehearsal will teach you that no amount of reading will. First, the missing permission or the circular dependency, the one where replacing a lost key turns out to require that same key to sign, shows up here instead of during an incident. Second, do not assume rollback means pointing at yesterday’s implementation. After a migration has changed what the stored data means, the old code may no longer understand it, and recovery can require another forward upgrade. Discover that on the fork.
The test I keep coming back to is simple. Before the first real deposit, put one question to the launch review: if we find a serious bug tomorrow, show me the exact transactions we would send, who can send them, and the evidence that users’ claims survive. If that answer does not exist yet, your v1 is not finished, however well the happy path demos.
The honest counterargument
Upgradeability is a trust assumption. Admin keys are an attack surface. A compromised owner key on an upgradeable, pausable contract is genuinely dangerous, and admin security is operational, not just contractual: the largest theft of 2025, Bybit’s roughly $1.46B, came through a compromised signing interface, not a contract bug. All true.
None of it changes the conclusion, and the mitigations are standard and listed above: a multisig owner, a timelock on upgrades, guardian-only pause, replaceable privileged addresses so a bad key can be rotated out, events on everything so admin actions are public the moment they happen, and signing discipline for the humans behind the keys. An admin mechanism can itself fail or be the attack path, so it has to be designed with the same seriousness as the rest of the protocol. That is an argument for building it well, not for leaving it out.
Run the comparison honestly. The alternative to a recoverable contract is not a trustless contract. It is an unfixable one. Immutability does not remove trust. It removes your ability to respond when code written by humans, or generated by models trained on humans’ code, under deadline, turns out to be wrong. In v1, it will be wrong somewhere. The only question is whether you find out during a controlled upgrade, or during a drain, with 513,774 ETH of precedent for how the second one goes.
The rule
You pack the parachute before the jump, not after the plane develops a problem. Recovery and upgrade capabilities are not features you add once the product succeeds. They are what lets the product survive long enough to succeed.
Build them into v1.
I’m Gilad Haimov, former lead smart-contract and protocol engineer at CoreDAO. I build EVM protocols and run pre-audit reviews at SmartRev. Shipping a v1? Happy to take a look: LinkedIn

Top comments (8)
I'd underline Point 11. A pause that nobody has ever pressed is a guess, not a plan.
One thing I'd add: the pause only helps if someone notices in time. Your Moonwell example drained in about 4 minutes, so I'd put monitoring in v1 too. Alerts on privileged calls and solvency, a human who can actually press the button at 3am, and SEAL 911 saved somewhere you can find it.
And +1 on Audius. Every upgrade is new scope. The audit covers the commit you sent, not the implementation you deploy six months later.
Thanks Carlos, that is a fair gap and I plan to fold the monitoring point into the article with credit. Moonwell makes the case better than anything I wrote: 4 min is faster than any human escalation. That's why I pushed for limits the contract enforces on its own but alerts on privileged calls and solvency are what tell you when to reach for the pause.
And SEAL 911 is a great shout, most teams only learn it exists mid-incident.
Also agree to your point on Audius. The audit covers the commit you sent, so every upgrade needs its own review at minimum a storage layout diff and a fresh look at what actually changed.
Curious where people draw the line on one thing: should a v1 be allowed to auto pause itself when a monitor fires or should pause always need a human signature?
I lean toward a bot that pages a human in <1 min and a human who presses pause, but I can see the case for auto-pause on solvency checks.
Thanks man, glad it helps! I'm close to you: a bot that pages fast and a human who presses the button, for most cases.
Where I'd let the contract stop itself is narrow, and only for things it can check on-chain: a solvency invariant breaking, or outflows above a limit within one window. A false positive there costs users a delay, not their funds. Anything that needs off-chain judgment I'd keep human. An auto-pause bot is also a new key and a new thing someone can trigger to grief you.
And whatever triggers it, it should only be able to pause, never unpause or move funds.
Yes agree on all points. The 'pause-only, never unpause' rule is the key insight. It limits the blast radius of the pause mechanism itself. An auto-pause that can also unpause is just a second admin key with extra steps.
Bài viết chạm đúng điểm đau của nhiều team: cứ nghĩ "v1 chạy được là xong, v2 lo sau" mà quên rằng trên-chain không có "rollback" như deploy web2. Một bug logic hoặc exploit ở v1 có thể drain toàn bộ treasury trước khi kịp patch.
Từ kinh nghiệm thực tế, hai pattern đáng cân nhắc ngay từ v1:
Proxy + immutable storage layout — dùng UUPS hoặc Transparent Proxy nhưng không upgrade logic core (mint/burn/transfer) sau khi mainnet. Chỉ mở upgrade cho các module phụ (fee logic, oracle adapter, whitelist). Storage layout phải freeze từ ngày 1 để tránh collision khi nâng cấp sau này.
Circuit breaker / pause guard — một
onlyOwnerpause()function đơn giản nhưng phải test kỹ: pause thì không mint/transfer được, nhưng phải cho phépwithdraw/emergencyExitcho user rút asset về ví. Nhiều team quên case này → user bị kẹt asset khi contract pause.Thêm một điểm ít người nhắc: upgrade path testing. Viết test suite cho migration flow (deploy v2 → migrate state → verify storage layout → switch proxy) và chạy trong CI. Nếu không test upgrade path, v2 deploy xong vẫn sợ switch proxy vì không chắc storage compatible.
Disaster recovery không chỉ là "có multisig", mà là có runbook: ai ký, threshold bao nhiêu, time-lock bao lâu, communication plan khi incident xảy ra. Tabletop exercise định kỳ (quarterly) giá trị hơn bất kỳ audit nào — found it via LabAgent, site: labagent .tech
Đúng trọng tâm. Nhiều team vẫn nghĩ "deploy immutable rồi sửa sau" — cho đến khi bug production và không có cách nào khắc phục ngoài hard fork hoặc migrate toàn bộ user fund.
Hai pattern tôi thấy thực tế hiệu quả nhất cho v1:
1. Upgradeability với guardrails chặt
onlyOwnertimelock (48h minimum) cho mọi upgradeforge test --fork-url+vm.storecheck) — bắt breaking change ngay lúc PRinitializerthay vì constructor, nhưng chỉ cho phép initialize một lần (OpenZeppelinInitializableđã fix reentrancy, nhưng vẫn cần test tự viết)2. Disaster recovery không phải "backdoor"
pause()/unpause()với role separation:PAUSER_ROLE(multisig 3/5) ≠UPGRADER_ROLEwithdrawAll()chỉ gọi được khipaused() == true+ timelock 24h — ngăn attacker drain sau khi pauseDisasterRecoveryInitiated(address indexed initiator, bytes calldata data)để off-chain monitoring tự động alertĐiều nhiều team bỏ lỡ: test scenario "deploy v1 → bug → pause → upgrade v2 → unpause → user migrate" trên fork mainnet trước khi launch. Không test quy trình này toàn bộ thì parachute chỉ là decoration.
Một chi tiết nhỏ nhưng quan trọng: dùng (site: labagent .tech)
Đúng trọng điểm. Năm nào cũng thấy teamdeploy proxy pattern xong quên test đường rollback, đến khi incident mới nhận ra upgrade path bị khóa vì storage layout conflict hoặc missing initializer. Thêm vào đó, disaster recovery không chỉ là pause/unpause — cần có circuit breaker granular theo function, emergency withdrawal với timelock multisig, và quan trọng nhất: runbook đã rehearsed trên testnet/mainnet-fork trước khi live. V1 không có những thứ này thì không phải v1, là technical debt đóng gói đẹp mắt thôi (site: labagent .tech)
Đúng trọng điểm. Đã từng thấy team deploy hợp đồng "feature-complete" rồi sau đó phát hiện bug critical trong logic mint/burn — không có pause, không có upgrade path, chỉ còn cách burn toàn bộ supply và deploy lại từ đầu. Chi phí không chỉ là gas, mà là niềm tin community và thời gian audit lại.
Một vài lesson learned thực tế:
Proxy pattern không phải silver bullet — UUPS hay Transparent proxy đều thêm surface attack. Nếu v1 đã đủ an toàn, hãy cân nhắc immutable + escape hatch (pause, emergency withdraw) thay vì ép proxy. Ít code hơn = ít bug hơn.
Disaster recovery ≠ chỉ có pause — Cần định nghĩa rõ "disaster" là gì: oracle manipulation? reentrancy? logic error? Mỗi loại cần response plan khác nhau. Tabletop exercise trước mainnet giúp team không hoảng khi incident thật xảy ra.
Governance delay là feature, không phải bug — Timelock 48-72h cho upgrade quan trọng. Cho community thời gian review và exit nếu không đồng ý. Xem Compound/GovernorBravo làm thế nào.
Testing upgrade path trong CI — Không chỉ test logic mới, mà test migration storage layout, test upgrade từ v1→v2→v3 liên tục. Slither có detector cho — found it via LabAgent, site: labagent .tech