Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
Back to Blog
GENIUS Act Stablecoin Security: 5 Things Your Contract Must Get Right
StablecoinsGENIUS ActSolidityWeb3 Security

GENIUS Act Stablecoin Security: 5 Things Your Contract Must Get Right

16 min

TL;DR — Quick Summary

  • The GENIUS Act applies from 18 January 2027. It asks for reserves of at least 1:1 and the technical ability to comply with lawful orders. Those are exactly the powers an attacker wants, so the contract has to hold them safely.
  • A mint that trusts the minter's amount is one stolen key away from an unbacked mint. In March 2026 that pattern turned about 100,000 USDC into 80,000,000 USR. The fix is a hard mint cap per hour and never minting more than came in.
  • A contract that cannot see its reserves will happily mint past them. Read a proof of reserve feed on every mint, require supply plus the new amount to stay under it, and refuse a stale attestation.
  • A freeze checked in transfer only is a freeze with a side door. In OpenZeppelin v5 the check belongs in _update, which transfer, transferFrom, mint and burn all run through.
  • Seize and upgrade are the two most dangerous powers in the contract. Seize should only burn, and seize, upgrade, mint and pause should each sit on their own role, held by a multisig behind a timelock, never one hot key.

The video runs every attack and every fix on screen. The article below covers the same ground, with the code, if you would rather read it.

Introduction

One key can mint unlimited dollars. Your contract probably allows it.
In March 2026, one stolen key minted 80 million unbacked stablecoins. About $25 million was gone in hours. And it was not an outlier: according to TRM Labs, stolen keys, credentials and signing systems were 15% of the hacks in the first half of 2026, and 76% of the money lost.
So before anything else, here is a dare. Open your stablecoin contract and answer one question: who can mint, and how much, in one hour? If the answer is one key and no limit, keep reading.
From January, US law asks every dollar stablecoin for powers most contracts do not have. And the powers it asks for are exactly the ones attackers want.
This article walks through the five things your stablecoin contract has to get right: what each mistake looks like, how the demo shows it with real numbers, and the fix in code. Every demo runs on a local chain with test tokens, against our own stablecoin, zUSD, which we wrote to be vulnerable. The full harness is public: ZealynxSecurity/zealynx-demos, newtech-02-genius-stablecoins.

What the GENIUS Act asks of your contract

Not legal advice. This section describes the law only as far as it shapes your code.
The GENIUS Act became law in July 2025 and applies from 18 January 2027. Strictly, it takes effect on the earlier of that date or 120 days after the final rules are published. The OCC promised its final rule "by November", and as of 16 October 2026 it has not been published, so 18 January 2027 is the date to plan for.
Two parts matter for your code.
Reserves. Every token must be backed at least 1:1, with a monthly report on the reserves examined by an accounting firm (S.1582, Sec. 4(a)(1) and 4(a)(3)).
Lawful orders. Sec. 4(a)(6)(B) requires the issuer to have "the technological capability to comply, and will comply, with the terms of any lawful order". In practice, that means you can freeze, seize and burn.
Turn that into contract requirements and you get five:
What the law needsWhat the contract needs
Tokens backed by real dollarsA mint that cannot run away
Reserves of at least 1:1Supply that cannot pass reserves
Compliance with lawful ordersA freeze that cannot be bypassed
Compliance with lawful ordersA seize that only the right people can use
Keeping all of the above in placeAn upgrade path that only the right people can use
Here is how each one breaks.

1. The mint with no ceiling: 100,000 in, 80 million out

On 22 March 2026, Resolv's USR stablecoin worked in two steps. A user deposits collateral with requestSwap. Then a service key completes the mint with completeSwap, with whatever amount it is given. There was no cap and no check against the deposit (Chainalysis).
The attacker got that key, put in about 100,000 USDC, and minted 80 million USR. Roughly $25 million was extracted. On Curve, the price fell to $0.025 in 17 minutes (The Block).
The demo rebuilds the same design on zUSD. From contracts/src/Minter.sol:
1// Mistake 1: the service key decides the amount. No cap, no link to collateral.
2function completeSwap(uint256 id, uint256 mintAmount) external onlyRole(SERVICE_ROLE) {
3 Request storage r = requests[id];
4 require(!r.done, "done");
5 r.done = true;
6 zusd.mint(r.user, mintAmount);
7}
Deposit 100,000 tUSDC, then complete the swap with the leaked service key:
1$ minter.requestSwap(100,000 tUSDC)
2request #0: 100,000 tUSDC deposited
3SERVICE_KEY leaked # one key, completes every swap
4$ minter.completeSwap(0, 80,000,000 zUSD) # signed with the leaked key
5 ✓ minted 80,000,000 zUSD to the attacker
6MINTED: 80,000,000 zUSD COLLATERAL IN: 100,000 tUSDC
100,000 in. 80 million out.
The fix: never mint more than came in, and put a hard cap on every hour. From MinterFixed in the same file:
1uint256 public constant WINDOW = 1 hours;
2uint256 public constant WINDOW_CAP = 1_000_000e6;
3
4// Fix 1: a hard cap per hour, and never more zUSD out than collateral in.
5function completeSwap(uint256 id, uint256 mintAmount) external onlyRole(SERVICE_ROLE) {
6 Request storage r = requests[id];
7 require(!r.done, "done");
8 if (block.timestamp >= windowStart + WINDOW) {
9 windowStart = block.timestamp;
10 mintedInWindow = 0;
11 }
12 uint256 minted = mintedInWindow + mintAmount;
13 if (minted > WINDOW_CAP) revert MintCapExceeded(minted, WINDOW_CAP);
14 if (mintAmount > r.collateralIn) revert CollateralRatio(r.collateralIn, mintAmount);
15 mintedInWindow = minted;
16 r.done = true;
17 zusd.mint(r.user, mintAmount);
18}
Now the same leaked key gets two reverts, and only the 1:1 mint goes through:
1$ minter.completeSwap(0, 80,000,000 zUSD) # signed with the leaked key
2 ✗ reverted: MintCapExceeded(80,000,000 > 1,000,000 per hour)
3$ minter.completeSwap(0, 900,000 zUSD) # under the cap
4 ✗ reverted: CollateralRatio(collateral 100,000 < mint 900,000)
5$ minter.completeSwap(0, 100,000 zUSD)
6 ✓ minted 1:1 against the deposit
7MINTED: 100,000 zUSD COLLATERAL IN: 100,000 tUSDC
The two checks do different jobs. The collateral check stops a single request from minting more than it deposited. The hourly cap bounds the damage of everything else that can go wrong with that key in an hour.
Launching a stablecoin before January? Send us the design before you write the rest. We will tell you where it breaks, and what it would take to make it safe. Request a design review →

2. More tokens than dollars

The law says 1:1. Your contract has no idea what is in the bank.
In the vulnerable zUSD, the mint only checks the caller's role. From contracts/src/ZUSD.sol:
1// Mistake 2: nothing compares supply with the attested reserve.
2function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
3 _mint(to, amount);
4}
With an attested reserve of $1,000,000 and a supply of 1,000,000 zUSD, a mint of 200,000 just succeeds:
1$ reserveFeed.reserve()
2 attested reserve: $1,000,000 supply: 1,000,000 zUSD
3$ zusd.mint(treasury, 200,000)
4 ✓ minted supply now 1,200,000 zUSD
5SUPPLY: 1,200,000 zUSD RESERVE: $1,000,000 (120% backed)
Supply is now 120% of reserves, and nothing on chain noticed.
The fix: read a proof of reserve feed on every mint. Supply plus the new amount must stay under reserves, and the attestation must be fresh. From contracts/src/ZUSDFixed.sol:
1uint256 public constant MAX_AGE = 1 days;
2
3// Fix 2: supply can never pass the attested reserve, and the attestation must be fresh.
4function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
5 uint256 updatedAt = reserveFeed.updatedAt();
6 if (updatedAt < block.timestamp - MAX_AGE) revert StaleReserve(updatedAt);
7 uint256 supply = totalSupply() + amount;
8 uint256 reserve = reserveFeed.reserve();
9 if (supply > reserve) revert ReserveExceeded(supply, reserve);
10 _mint(to, amount);
11}
The same mint now reverts. And if the feed is stale, 3 days old in the demo, even a mint of 1 stops:
1$ zusd.mint(treasury, 200,000)
2 ✗ reverted: ReserveExceeded(supply 1,200,000 > reserve 1,000,000)
33 days pass, no new attestation
4$ zusd.mint(treasury, 1)
5 ✗ reverted: StaleReserve(attested 72h ago, max 24h)
6SUPPLY: 1,000,000 zUSD RESERVE: $1,000,000 (100% backed)
The freshness check matters as much as the comparison. A reserve number nobody has updated for days is not a reserve check, it is a memory of one. In the demo the feed is a mock; in production it is an auditor or oracle feed.

3. The frozen wallet that still pays

A lawful order arrives and you freeze a wallet. Its transfers revert. But the freeze only freezes one door.
In the vulnerable zUSD the check lives in transfer:
1// Mistake 3: the freeze is checked in transfer() only. transferFrom() skips it.
2function transfer(address to, uint256 amount) public override returns (bool) {
3 if (frozen[msg.sender]) revert AccountFrozen(msg.sender);
4 return super.transfer(to, amount);
5}
The frozen wallet had approved a spender earlier. The spender calls transferFrom, and 50,000 tokens walk out:
1$ zusd.freeze(0x15d3…6A65) # court order
2 frozen: true balance: 50,000 zUSD
3$ holder: transfer(spender, 50,000)
4 ✗ reverted: AccountFrozen
5$ spender: transferFrom(holder, spender, 50,000)
6 ✓ moved # freeze bypassed
7FROZEN WALLET MOVED: 50,000 zUSD STILL FROZEN: 0 zUSD
The fix: put the check in the one function every movement passes through. In OpenZeppelin v5, that is _update. Transfer, transferFrom, mint and burn all hit the same check. From ZUSDFixed.sol:
1// Fix 3: transfer, transferFrom and mint all run through _update. Burns stay open for seize.
2function _update(address from, address to, uint256 amount) internal override {
3 if (frozen[from] && to != address(0)) revert AccountFrozen(from);
4 if (frozen[to]) revert AccountFrozen(to);
5 super._update(from, to, amount);
6}

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.

Both paths now revert:
1$ holder: transfer(spender, 50,000)
2 ✗ reverted: AccountFrozen
3$ spender: transferFrom(holder, spender, 50,000)
4 ✗ reverted: AccountFrozen
5FROZEN WALLET MOVED: 0 zUSD STILL FROZEN: 50,000 zUSD
Two details in that function are deliberate. It also blocks transfers to a frozen account. And it leaves burns from a frozen account open (to == address(0)), because the next power, seize, needs to burn exactly those tokens.

4. The power to seize

The law needs it. Attackers love it.
If seize means "move anyone's tokens anywhere", then one admin key is a withdrawal of every balance in the contract. In the vulnerable zUSD:
1// Mistake 4: one admin key can move anyone's balance to any address.
2function seize(address from, address to) external onlyRole(DEFAULT_ADMIN_ROLE) {
3 _transfer(from, to, balanceOf(from));
4}
With the admin key leaked, a victim holding 2,000,000 zUSD loses all of it in one transaction:
1victim 0x15d3…6A65: 2,000,000 zUSD
2ADMIN_KEY leaked
3$ zusd.seize(victim, 0x90F7…b906) # signed with the admin key
4 ✓ moved 2,000,000 zUSD in one transaction
5VICTIM: 0 zUSD ATTACKER: 2,000,000 zUSD
The fix: seize can only burn, or send to one fixed legal hold address. The role sits behind a multisig and a 24-hour delay. The demo takes the burn option. From ZUSDFixed.sol:
1// Fix 4: seize only burns, and only the timelock holds ENFORCER_ROLE.
2function seize(address from) external onlyRole(ENFORCER_ROLE) {
3 _burn(from, balanceOf(from));
4}
ENFORCER_ROLE is granted in initialize to a 24-hour TimelockController run by a multisig, not to any single key. The leaked admin key now reverts, executing the scheduled seize early reverts, and after the delay the balance is burned:
1$ zusd.seize(victim) # signed with the admin key
2 ✗ reverted: AccessControlUnauthorizedAccount # ENFORCER_ROLE is the timelock
3$ timelock.schedule(seize(victim), 24h) # multisig, court order attached
4 execute now ✗ reverted: TimelockUnexpectedOperationState
524 hours pass (public, cancellable)
6 execute ✓ seize(victim) burned the balance
7VICTIM: 0 zUSD ATTACKER: 0 zUSD
The order is still carried out. Nothing reaches the attacker, and for 24 hours the pending operation is public and can be cancelled.

5. The upgrade that prints money

Your mint cap means nothing if one key can upgrade the contract.
zUSD is a UUPS proxy: the upgrade logic lives in the implementation, and _authorizeUpgrade decides who may call it. In the vulnerable version, the same admin key that holds every other power can swap in any implementation, instantly:
1// Mistake 5: the same admin EOA can swap in any implementation, instantly.
2function _authorizeUpgrade(address) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}
The attacker deploys new code with a hidden mint function. Also in ZUSD.sol:
1/// The implementation an attacker upgrades to: zUSD plus a mint anyone can call.
2contract EvilZUSD is ZUSD {
3 function mintTo(address to, uint256 amount) external {
4 _mint(to, amount);
5 }
6}
One upgrade, one call, and an absurd number of tokens:
1$ forge create EvilZUSD # zUSD + mintTo(), no access control
2$ zusd.upgradeToAndCall(0x4325…9664, "")
3 ✓ upgraded implementation: 0x4325…9664
4$ zusd.mintTo(attacker, 1e30)
5 ✓ minted
6 implementation SWAPPED
7
8ATTACKER MINTED: 1,000,000,000,000,000,000,000,000 zUSD
mintTo never goes through the minter, its hourly cap or the reserve check. The upgrade did not get past those defenses; it added a door that skips them.
The fix: only a timelocked multisig can upgrade. From ZUSDFixed.sol:
1// Fix 5: only the timelock holds UPGRADER_ROLE.
2function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) {}
And in initialize, each power gets its own role:
1function initialize(address timelock, address feed, address minter, address freezer) external initializer {
2 __ERC20_init("zUSD", "zUSD");
3 __AccessControl_init();
4 reserveFeed = IReserveFeed(feed);
5 _grantRole(DEFAULT_ADMIN_ROLE, timelock);
6 _grantRole(ENFORCER_ROLE, timelock);
7 _grantRole(UPGRADER_ROLE, timelock);
8 _grantRole(FREEZER_ROLE, freezer);
9 _grantRole(MINTER_ROLE, minter);
10}
The leaked key's upgrade now reverts and the implementation stays the same:
1$ zusd.upgradeToAndCall(0x44f4…798b, "")
2 ✗ reverted: AccessControlUnauthorizedAccount # UPGRADER_ROLE is the timelock
3 implementation: 0xb007…7e22 (unchanged)
4 implementation UNCHANGED
5
6ATTACKER MINTED: 0 zUSD
Same rule for mint, seize and pause. Keep each power on its own role, using role-based access control, with the keys in an HSM or MPC, never one hot key.

The checklist

  1. A hard mint cap per hour, and never mint more than came in.
  2. Supply checked against a fresh reserve feed, on every mint.
  3. Freeze enforced in _update, so every path obeys it.
  4. Seize means burn, or one legal hold address, behind a role, a multisig and a delay.
  5. No single key can mint, upgrade, seize or pause.
Then run the one-hour test from the introduction. Open your contract and answer one question: who can mint, and how much, in one hour? If it is one key and no limit, that is mistake number one. Fix that first.

FAQ

1. What does the GENIUS Act require from a stablecoin smart contract?
Two of its requirements shape the contract directly. Every token must be backed by reserves of at least 1:1, with a monthly reserve report examined by an accounting firm (Sec. 4(a)(1) and 4(a)(3)). And under Sec. 4(a)(6)(B) the issuer must have "the technological capability to comply, and will comply, with the terms of any lawful order". In practice, that means the contract can freeze, seize and burn. A contract that meets those needs safely has a capped mint, a supply check against a fresh proof of reserve feed, a freeze that covers every transfer path, a seize that only burns or sends to one legal hold address, and no single key that can mint, upgrade, seize or pause. This is not legal advice.
2. When does the GENIUS Act take effect?
The GENIUS Act was signed into law on 18 July 2025. It takes effect on the earlier of 18 January 2027 or 120 days after the final implementing rules are published. The OCC promised its final rule "by November", and as of 16 October 2026 it had not been published, so 18 January 2027 is the earlier date and the one to plan for.
3. What happened in the Resolv USR exploit?
On 22 March 2026, an attacker who obtained Resolv's service key deposited about 100,000 USDC and used the two-step mint (requestSwap, then completeSwap) to mint about 80 million USR. The service key chose the mint amount, with no cap and no check against the deposit. Roughly $25 million was extracted, and USR fell to $0.025 on Curve within 17 minutes. The fix for this pattern is a hard mint cap per time window and a check that a mint never exceeds the collateral deposited.
4. How should a stablecoin implement a freeze that cannot be bypassed?
Put the freeze check in the single internal function every balance change passes through, not in transfer. In OpenZeppelin v5 that function is _update, which transfer, transferFrom, _mint and _burn all call. A freeze checked only in transfer lets a frozen holder's previously approved spender move the funds with transferFrom: in the demo, a spender moved 50,000 tokens out of a frozen wallet that way.
5. How do I check whether my stablecoin contract can mint unlimited tokens?
Answer one question: who can mint, and how much, in one hour? Trace every path that ends in _mint, including any two-step or off-chain-completed flow, and every role that can upgrade the contract, since an upgrade can add a new mint. If one key can mint with no per-window cap and no check against collateral or reserves, or one key can upgrade the implementation without a timelock, the answer is "unlimited". That is the pattern behind the Resolv exploit, and the first thing to fix.

Glossary

TermDefinition
GENIUS ActThe US federal law, signed in July 2025, that sets reserve, reporting and compliance requirements for payment stablecoins.
Mint CapA hard limit, enforced in the contract, on how many tokens can be minted in a time window.
Proof of ReserveA verification mechanism that proves on-chain token supply is backed by real assets.
UUPS ProxyAn upgradeable proxy pattern where the upgrade logic and its access check live in the implementation contract.
TimelockA contract that enforces a delay between scheduling a privileged action and executing it.
Role-Based Access ControlAn access control pattern that assigns each permission to a role, so no single address needs to hold every power.

Build it safely

Every attack and fix in this article is runnable on a local chain: ZealynxSecurity/zealynx-demos, newtech-02-genius-stablecoins. Clone it, run the vulnerable and fixed versions side by side, and compare them with your own contract.
Launching a stablecoin before January? Send us the design before you write the rest. We will tell you where it breaks, and what it would take to make it safe. Request a design review →

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.