Cover illustration: independent state checking across a change of contract logic.
An upgrade test can pass while the upgraded contract has already lost its accounting history. The test may check that the implementation address changed, that a new method returns the expected value, or that deployment completed. None of those checks establishes that earlier users still have the same rights after the transition.
A stateful invariant harness makes a stronger, bounded claim: after each generated action, the contract must still agree with an independently maintained model. The upgrade itself becomes one action in that sequence. Grants, spending, pauses and rejected calls happen on both sides of it.
The example below runs against an actual UUPS proxy. Its correct candidate passed a seeded campaign of 128 sequences with 64 handler calls each. A deliberately broken migration failed, and Foundry reduced its failing sequence to pause(true) followed by upgrade(). These are results from a teaching fixture, not evidence that an unrelated protocol is safe.
Define the state that must survive
Consider a small credit ledger. An administrator grants integer credits to registered actors. Each actor can spend its own credits. Pausing blocks grants and spending. Version two adds a migration marker while retaining existing balances and ownership.
There are no deposits, token transfers, exchange rates or redeemable assets. Removing those features keeps the example focused on upgrade continuity. A vault handling money needs additional properties for custody, withdrawal behavior and external calls; copying this ledger would not provide them.
For an upgrade project, the acceptance scope of Pharos Production blockchain development services should specify which existing rights must survive a release. A statement that the proxy is upgradeable leaves that question open. The useful deliverable is a property that a reviewer can run against the proposed implementation.
Write the properties before constructing the handler. Otherwise, it is easy to model whatever the implementation happens to do and call that behavior correct.
| Property | Independent expectation | Where the example checks it |
|---|---|---|
| Historical credits survive | Each tracked actor retains credits minus successful spending | Model invariant after generated calls |
| Accounting stays consistent | Contract total equals modeled actor balances and the model accumulator | Same invariant |
| Upgrade authority stays fixed | Only the handler acting as administrator can upgrade | Rejection probe and owner comparison |
| Pause behavior survives | A paused actor cannot spend; resuming permits the selected lifecycle to continue | Exact revert check and deterministic lifecycle |
| Initialization cannot repeat | Proxy and implementation initialization reject a second or direct attempt | Rejection probe |
| Candidate activation is observable | Implementation slot, version and migration marker match the intended state | Same invariant |
| Failed migration is atomic | An unpaused migration attempt leaves the original observable state intact | Separate deterministic test |
The third column matters. A property named in a document but absent from executable assertions is still an assumption. Conversely, a test that checks only a total can miss a redistribution between users. The example compares individual accounts as well as the aggregate.
A migration does not always preserve every value literally. A system might replace one unit with another or split an account into separate records. In that case, define a compatibility relation before testing: which economic entitlement stays equivalent, which metadata may change and what conversion rule determines the expected result. Equality is appropriate for this fixture because its upgrade promises no accounting conversion.
Write down who can change each expected value. Spending changes one actor and the aggregate. A grant changes the recipient and the aggregate. Pausing changes only the operational flag. The intended upgrade changes implementation metadata and the migration marker. This compact transition contract helps reviewers notice an assertion that accidentally permits migration to rewrite unrelated balances.
The model can still be wrong. Adding the same mistaken fee formula to both implementation and handler would produce agreement without correctness. Review the expected transition against product requirements, accounting rules and representative examples before relying on randomized exploration. Independence is a design choice, not a consequence of storing variables in a different contract.
Pin a small, reproducible environment
This run used Forge 1.5.1, Solidity 0.8.28, forge-std v1.9.7 and OpenZeppelin Contracts v5.4.0. Those are reproduction pins, not a claim that they are the newest versions available. The EVM target is Cancun and the optimizer uses 200 runs.
Start in a new directory. The dependency revisions below are the exact commits used for the example. Install Forge 1.5.1 through the official Foundry distribution before running the commands. No RPC endpoint, wallet or funded account is required.
mkdir upgrade-invariants
cd upgrade-invariants
mkdir -p src test lib
git clone --branch v1.9.7 --depth 1 https://github.com/foundry-rs/forge-std.git lib/forge-std
git clone --branch v5.4.0 --depth 1 https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts
# Verify dependency revisions:
git -C lib/forge-std rev-parse HEAD
# 77041d2ce690e692d6e03cc812b57d1ddaa4d505
git -C lib/openzeppelin-contracts rev-parse HEAD
# c64a1edb67b6e3f4a15cca8909c9482ad33a02b0
Save this as foundry.toml:
[profile.default]
src = "src"
test = "test"
libs = ["lib"]
solc_version = "0.8.28"
evm_version = "cancun"
optimizer = true
optimizer_runs = 200
remappings = ["forge-std/=lib/forge-std/src/", "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"]
[profile.default.invariant]
runs = 128
depth = 64
fail_on_revert = true
show_metrics = true
The configuration makes unexpected handler reverts fail the campaign. Expected denials are asserted inside the handler, so they do not appear as failed handler calls. That distinction will explain the zero-revert metric later.
The official Foundry invariant-testing guide documents handler targeting and ghost variables. This article applies those mechanisms to one specific upgrade transition. It does not require increasing a fuzz budget until an attractive result appears.
Keep the implementation intentionally small
Save the following as src/Ledger.sol. The first implementation stores credits and their total. The second adds one field and a versioned migration. The final contract is an intentionally defective candidate used only to check that the test can fail.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
// Teaching fixture: credits are integers, not redeemable assets.
contract LedgerV1 is Initializable, UUPSUpgradeable {
address public owner;
bool public paused;
mapping(address => uint256) public credit;
uint256 public total;
error Unauthorized();
error Paused();
error Insufficient();
error MustPause();
constructor() { _disableInitializers(); }
modifier onlyOwner() {
if (msg.sender != owner) revert Unauthorized();
_;
}
function initialize(address admin) external initializer { owner = admin; }
function setPaused(bool value) external onlyOwner { paused = value; }
function grant(address user, uint256 amount) external onlyOwner {
if (paused) revert Paused();
credit[user] += amount;
total += amount;
}
function spend(uint256 amount) external {
if (paused) revert Paused();
if (credit[msg.sender] < amount) revert Insufficient();
credit[msg.sender] -= amount;
total -= amount;
}
function version() external pure virtual returns (uint256) { return 1; }
function _authorizeUpgrade(address) internal override onlyOwner {}
}
contract LedgerV2 is LedgerV1 {
uint256 public migrationMarker;
function initializeV2() public virtual reinitializer(2) onlyOwner {
if (!paused) revert MustPause();
migrationMarker = 2;
}
function version() external pure override returns (uint256) { return 2; }
}
// Deliberate negative control. Never deploy this candidate.
contract BrokenV2 is LedgerV2 {
function initializeV2() public override reinitializer(2) onlyOwner {
if (!paused) revert MustPause();
migrationMarker = 2;
total = 0;
}
}
The proxy receives encoded initializer data during construction. Its owner becomes the handler, which will represent the authorized administrator. Each implementation locks its own initializer in its constructor. These are different storage contexts: initializing the proxy does not make direct initialization of an implementation a useful or safe operation.
OpenZeppelin gives the relevant warning directly:
Do not leave an implementation contract uninitialized.
OpenZeppelin, Writing Upgradeable Contracts.
The example inherits the pinned UUPS implementation and defines its authorization hook with onlyOwner. Inspect the v5.4.0 UUPS source when reproducing this behavior with a different dependency version. A method named upgradeToAndCall alone does not establish who may execute it.
Version two deliberately preserves the base declaration order and appends its marker. This is a simple fixture, not a general storage-layout approval procedure. Real changes involving inheritance, packed values or namespaced storage require their own compatibility analysis.
The pause requirement belongs to initializeV2, not to every possible upgrade call. An authorized owner could choose another candidate or omit migration data. The positive campaign models the intended release procedure; it does not prove that a malicious administrator cannot bypass that procedure. If pausing before every upgrade is a production requirement, encode and test that requirement at the authorization boundary.
The broken candidate resets total without changing account credits. It is intentionally obvious. Its purpose is to establish that the oracle notices a state discontinuity at migration time, before anyone needs to spend afterward.
Model history outside the proxy
The handler keeps a separate expected balance for each of three actors. It also maintains an expected total, pause state and implementation address. None of those expectations is copied from the proxy after an upgrade.
That separation is the heart of the test. If the handler read the migrated total and adopted it as the new expected value, the broken migration would teach the test to accept its own corruption. The model must describe the intended effect of an accepted action, not merely repeat the result returned by the system.
Seed each actor with 100 credits before fuzzing. An empty ledger would make a reset-to-zero mutation invisible until later activity created a difference. A small nonzero history makes this defect observable even when the generated sequence contains only a pause and an upgrade.
The actor set is deliberately closed. Grants target only these addresses, which makes their sum meaningful. In a real protocol, maintain a registry of every account the campaign can credit or debit, including fee recipients and custody contracts. Otherwise, an aggregate comparison can exclude balances that the test created itself.
bound limits grants to positive amounts and successful spending to the actor's modeled balance. It helps the campaign reach useful states, but it also narrows the tested domain. Overspending, zero-value behavior and arithmetic extremes are outside that success path and deserve targeted tests when they matter to the actual contract.
The model changes only after a successful call. A paused spending attempt expects the precise Paused error and leaves expected balances unchanged. Catching every exception and continuing would obscure both an unexpected denial and a defect in the handler.
The handler drives the proxy. The model records intended effects independently; assertions compare the two after each generated action.
Make the allowed actions explicit
Create test/UpgradeInvariant.t.sol with the next two code blocks in order. The first contains its imports and the handler. The second contains the test contract in the same file.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {LedgerV1, LedgerV2, BrokenV2} from "../src/Ledger.sol";
contract Handler is Test {
LedgerV1 public ledger;
address public immutable first;
address public immutable next;
address public expectedImplementation;
uint256[3] public expected;
uint256 public expectedTotal;
bool public expectedPaused;
bool public upgraded;
uint256 public grants;
uint256 public spends;
uint256 public blockedSpends;
uint256 public probes;
uint256 public upgrades;
uint256 public postUpgradeSpends;
constructor(address candidate) {
next = candidate;
first = address(new LedgerV1());
ledger = LedgerV1(address(new ERC1967Proxy(
first, abi.encodeCall(LedgerV1.initialize, (address(this)))
)));
expectedImplementation = first;
for (uint256 i; i < 3; ++i) {
ledger.grant(actor(i), 100);
expected[i] = 100;
expectedTotal += 100;
}
}
function actor(uint256 i) public pure returns (address) {
return address(uint160(0x100 + i));
}
function grant(uint256 who, uint256 raw) external {
uint256 i = who % 3;
uint256 amount = bound(raw, 1, 10_000);
if (expectedPaused) {
vm.expectRevert(LedgerV1.Paused.selector);
ledger.grant(actor(i), amount);
return;
}
ledger.grant(actor(i), amount);
expected[i] += amount;
expectedTotal += amount;
grants++;
}
function spend(uint256 who, uint256 raw) external {
uint256 i = who % 3;
if (expectedPaused) {
vm.expectRevert(LedgerV1.Paused.selector);
vm.prank(actor(i));
ledger.spend(1);
blockedSpends++;
return;
}
if (expected[i] == 0) return;
uint256 amount = bound(raw, 1, expected[i]);
vm.prank(actor(i));
ledger.spend(amount);
expected[i] -= amount;
expectedTotal -= amount;
spends++;
if (upgraded) postUpgradeSpends++;
}
function pause(bool value) external {
ledger.setPaused(value);
expectedPaused = value;
}
function upgrade() external {
if (upgraded || !expectedPaused) return;
ledger.upgradeToAndCall(next, abi.encodeCall(LedgerV2.initializeV2, ()));
expectedImplementation = next;
upgraded = true;
upgrades++;
}
function probe() external {
vm.expectRevert(LedgerV1.Unauthorized.selector);
vm.prank(actor(0));
ledger.upgradeToAndCall(next, "");
vm.expectRevert(LedgerV1.Unauthorized.selector);
vm.prank(actor(0));
ledger.setPaused(!expectedPaused);
vm.expectRevert(Initializable.InvalidInitialization.selector);
ledger.initialize(actor(0));
vm.expectRevert(Initializable.InvalidInitialization.selector);
LedgerV1(first).initialize(actor(0));
vm.expectRevert(Initializable.InvalidInitialization.selector);
LedgerV1(next).initialize(actor(0));
if (upgraded) {
vm.expectRevert(Initializable.InvalidInitialization.selector);
LedgerV2(address(ledger)).initializeV2();
}
probes++;
}
}
probe groups rejected operations whose expected outcome is unchanged state. It attempts an unauthorized upgrade and pause change, repeats proxy initialization and tries to initialize both implementation contracts directly. After a successful upgrade, it also repeats the version-two initializer.
The probe uses specific errors. An unrelated revert is not acceptable evidence that access control worked. The model invariant then checks the owner and implementation slot as well as accounting state. Keeping those checks together helps expose a control-plane change even when user balances still look correct.
The testing methods listed in smart contract development services at Pharos Production include Foundry fuzz testing. For an upgrade release, that activity becomes reviewable when its handover includes the actual handler selectors, independent model and failing counterexample. The service description supplies context for the method; the executable fixture supplies this article's evidence.
There are five fuzz targets: grant, spend, pause, upgrade and probe. Public getters and inherited test helpers are excluded from the selector list. Targeting the handler contract explicitly also prevents automatically discovered implementation contracts from becoming unintended campaign targets.
The outer caller chosen by the fuzzer is not a modeled end user. Authorized calls originate from the handler, while spending uses vm.prank to represent one of the fixed actors. That keeps the authority model understandable. Expanding the sender population without defining roles would change the experiment.
Check the whole state, then force a complete lifecycle
contract UpgradeInvariantTest is StdInvariant, Test {
Handler internal handler;
bytes32 internal constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function setUp() public {
address candidate = vm.envOr("BROKEN", false)
? address(new BrokenV2()) : address(new LedgerV2());
handler = new Handler(candidate);
bytes4[] memory selectors = new bytes4[](5);
selectors[0] = Handler.grant.selector;
selectors[1] = Handler.spend.selector;
selectors[2] = Handler.pause.selector;
selectors[3] = Handler.upgrade.selector;
selectors[4] = Handler.probe.selector;
targetContract(address(handler));
targetSelector(FuzzSelector(address(handler), selectors));
}
function invariant_modelMatchesProxy() public view {
LedgerV1 v = handler.ledger();
uint256 sum;
for (uint256 i; i < 3; ++i) {
assertEq(v.credit(handler.actor(i)), handler.expected(i), "actor credit");
sum += handler.expected(i);
}
assertEq(v.total(), sum, "total vs model");
assertEq(v.total(), handler.expectedTotal(), "model accumulator");
assertEq(v.owner(), address(handler), "owner");
assertEq(v.paused(), handler.expectedPaused(), "pause state");
assertEq(address(uint160(uint256(vm.load(address(v), IMPLEMENTATION_SLOT)))),
handler.expectedImplementation(), "implementation");
assertEq(v.version(), handler.upgraded() ? 2 : 1, "version");
if (handler.upgraded()) {
assertEq(LedgerV2(address(v)).migrationMarker(), 2, "migration");
}
}
function test_failedMigrationIsAtomic() public {
LedgerV1 v = handler.ledger();
address candidate = handler.next();
vm.expectRevert(LedgerV1.MustPause.selector);
vm.prank(address(handler));
v.upgradeToAndCall(candidate, abi.encodeCall(LedgerV2.initializeV2, ()));
invariant_modelMatchesProxy();
}
function test_requiredLifecycle() public {
handler.grant(0, 17);
handler.spend(1, 9);
handler.pause(true);
handler.spend(0, 1);
handler.probe();
handler.upgrade();
invariant_modelMatchesProxy();
handler.probe();
handler.pause(false);
handler.spend(0, 1);
invariant_modelMatchesProxy();
assertGt(handler.grants(), 0);
assertGt(handler.spends(), 0);
assertGt(handler.blockedSpends(), 0);
assertEq(handler.upgrades(), 1);
assertGt(handler.postUpgradeSpends(), 0);
assertEq(handler.probes(), 2);
}
}
The ERC-1967 specification identifies the implementation storage slot. Reading it gives the test an observation independent of version(). A candidate that simply reports version two must not satisfy an assertion that a particular implementation was installed.
All persistent state checks live in one invariant function. They therefore inspect the same sequence at each check. The assertions cover the chosen accounts, total, owner, pause state and active implementation, followed by the migration marker when version two should be active.
A successful call to upgrade does not reset the model. It changes only expected implementation metadata. Preserving the ghost balances across that boundary is what makes a reset or redistribution visible.
Random selection does not promise that every sequence completes the entire release lifecycle. upgrade returns without action before pausing or after an earlier upgrade. spend can return when its selected account has no credits. Those choices are visible limits on exploration, not evidence that every generated call accomplished useful work.
The deterministic lifecycle test closes one specific gap. It guarantees a grant and spend before migration, a denied spend while paused, an upgrade, a repeated-initialization probe and successful spending after resumption. Its counter assertions make that path explicit. They do not establish transition coverage for all random campaigns.
The atomicity test exercises a different branch: migration while unpaused must fail, and the invariant must still describe version one afterward. Notice that handler.next() is read before arming expectRevert and prank. An external getter placed between those cheatcodes and the intended call can consume the next-call expectation and produce a fixture failure unrelated to migration behavior.
Run the positive case and the negative control
Run the normal suite with the fixed seed:
FOUNDRY_CACHE_PATH=cache-positive forge test --fuzz-seed 0x9162026 -vv
The September 16, 2026 run returned three passing tests: the model invariant, failed-migration atomicity and the required lifecycle. The invariant campaign executed 8,192 handler calls across 128 runs at depth 64. No unexpected handler revert or discarded call was reported.
| Handler selector | Calls reported in the passing campaign |
|---|---|
| grant | 1,661 |
| pause | 1,653 |
| probe | 1,591 |
| spend | 1,636 |
| upgrade | 1,651 |
These figures are selector invocations, not successful economic operations. A paused grant follows an expected rejection path. An upgrade invocation can do nothing because its precondition is absent or migration already happened. The explicit success counters in the lifecycle test answer a narrower, separate question.
Now select the broken candidate without changing the property:
BROKEN=true FOUNDRY_CACHE_PATH=cache-negative forge test --match-test invariant_modelMatchesProxy --fuzz-seed 0x9162026 -vv
This command is expected to exit unsuccessfully. In the observed run, Foundry found an eight-call failing sequence and shrank it to two calls: pause, then upgrade. The initial seeded credits explain why those two calls suffice. Resetting the aggregate violates the model even without a generated grant.
Separate cache directories keep the negative-control failure corpus apart from the positive campaign. The candidate switch is explicit through BROKEN; it does not rewrite the invariant or relax an assertion. Do not run the negative control as an ordinary green CI job and then ignore its exit status.
A passing negative control would block acceptance of this harness. It would mean the intended defect was not reached, the observation was missing, or the oracle had accepted the defect. Raising the run count would not be the first repair; inspect the target path and expected state first.
Review the harness as executable code
Inspect every early return. In this handler, an upgrade outside the modeled paused state is intentionally skipped. That helps explore the intended workflow, but it removes unauthorized sequencing from that action's domain. The separate atomicity test covers an unpaused migration attempt; it does not cover every possible authorized upgrade payload. A reviewer should be able to map each exclusion to a separate check or an explicit limitation.
Inspect every privilege shortcut as well. vm.prank changes the caller for testing; it does not demonstrate that a deployed administrator can assemble signatures or execute a governance proposal. The fixed actor addresses are identities inside the local test environment. They are not funded production accounts, and the campaign makes no statement about key custody.
Expected-revert tests need a successful neighboring path. If all spending reverted for an unrelated reason, a pause denial alone could look reassuring. The lifecycle therefore includes spending before pausing and after resumption. This establishes that the tested denial sits between working paths for the selected actor and amount. It remains a concrete example, not a proof of universal withdrawal availability.
Finally, distinguish state safety from progress. Equality checks can show that an attempted action did not corrupt the observed ledger. They cannot show that a necessary upgrade will eventually be proposed, approved or executed. Operational readiness requires a separate owner and execution procedure. A property called eventual recovery would need a defined time model and assumptions about the actors who must act.
Preserve the failure as release evidence
A seed alone is not a portable proof. Keep the source revision with compiler settings and dependency commits. Preserve the generated sequence, the candidate-selection environment and the tool version. A later toolchain can make different generation or shrinking decisions even when the numeric seed matches.
For a real defect, convert the reduced sequence into a named regression test. Include its required initial state. Here, removing the seeded balances would change the meaning of the two-call reproducer, so a bare list of method names is incomplete evidence.
Keep the first failing log as well as the minimized trace. Do not treat every number in a failure message as a value recomputed from the shortest sequence; inspect the replay before reporting intermediate balances. The article's reproducible claim is the accounting failure and the reduced action sequence, not a guessed value at an unseen trace step.
When diagnosing a failed campaign, identify whether the failing assertion belongs to the protocol model, an expected-revert probe or the harness itself. A bad caller, wrong selector or misplaced cheatcode can stop useful exploration. Correct the fixture without weakening the requirement, then rerun the affected campaign with a retained explanation.
Release evidence should name the exact candidate it covers. A green result for yesterday's implementation is not automatically evidence for a rebuild, a dependency change or different initializer arguments. Treat those changes as new inputs to the relevant checks.
Extend the model to the real release boundary
Replace the integer ledger only after writing the properties of the actual system. For a share-based vault, model the intended relationship between deposits, shares and withdrawals, including permitted rounding. Avoid computing the expected answer by calling the same conversion function that the implementation uses; both sides would inherit the same mistake.
External assets introduce additional behavior. Fee-on-transfer tokens, callbacks and unusual return values can invalidate a handler that assumes a simple transfer. Those cases require explicit fixtures and observations. This example neither executes external transfers nor tests reentrancy.
A governance-controlled system needs its real authorization route. Represent proposal creation, delay and execution through the timelock or multisig rather than using a privileged prank for every update. Keep expected denials for unauthorized callers. A mock owner proves only the owner-based boundary implemented here.
Migration can be incremental. If users are converted lazily, the model needs pre-migration and post-migration account states with rules for crossing between them. A single global version flag cannot describe users whose data is at different stages. Define which operations remain valid at each stage and which historical quantities must still reconcile.
Events need their own observations when off-chain consumers depend on them. This harness reads contract state and does not assert event contents or ordering. An indexer could therefore receive an incorrect migration event while every property here passes. Add an event assertion or integration test where an external consumer relies on that message to change its interpretation of stored data.
Similarly, an authorization probe for upgrading does not establish authorization on every administrator method. The example exercises a particular set of denied calls. A production review should enumerate privileged entry points and identify the test responsible for each one, including newly introduced methods in the candidate implementation.
A production pause policy may permit withdrawals while blocking deposits. Encode that asymmetry directly instead of copying this fixture's blanket pause on grants and spending. Likewise, distinguish safe resumption from a claim that reverting implementation code restores all earlier state. An invariant campaign does not undo transactions or establish rollback feasibility.
When adding another property, identify the defect it would catch that existing assertions miss. A second assertion of the same total under another name adds little. A check for an account that was previously absent from the actor registry changes coverage. A check for an external recipient changes the observation boundary. Keep that distinction visible in code review so the suite grows with the system's behavior rather than with a checklist of reassuring names.
Retain layout validation alongside behavioral tests. This fixture checks three known accounts and a few public fields; it cannot inspect every possible mapping key or prove that all storage interpretations are compatible. Static compatibility checks, deployment validation and a fork rehearsal each answer questions the local campaign leaves open.
The practical release decision is specific: the reviewed candidate agrees with the stated model over the executed paths, the required lifecycle completes, and a controlled violation causes failure. List the untested paths beside that result. That gives the next engineer something to reproduce, challenge and extend before the upgrade reaches users.
More insights to read
- Five Smart Contract Upgrade Patterns Compared
- Upgradeable Solidity Smart Contracts. Part 1 — Versioning
- Smart Contracts. Their Potential and Real Limitations. Part 1
- Smart Contracts. Their Potential and Real Limitations. Part 2
- Web3. Smart Contracts. Oracles. Part 1
About the author
Dmytro Nasyrov. Photo supplied by the author.
Written by Dmytro Nasyrov PhD, software architect with 24 years of production experience. Dmytro is the founder and CTO of Pharos Production. He works on production software architecture for FinTech, AI, Web3 and blockchain systems.


Top comments (0)