Recently, USDG came up as one of the topmost stablecoins in the Solana ecosystem, and it is equally gaining gradual adoption in the EVM.
🚨📰 ICYMI: @RobinhoodApp-backed $USDG stablecoin continues to grow on Solana, w/ supply up ~160% in the past month.
— Token Terminal 📊 (@tokenterminal) September 7, 2025
USDG is now bigger on @solana than on @ethereum. pic.twitter.com/sZspZniStz
If you are a developer or security researcher, there are quite a lot of exciting things you will want to know about USDG.
For instance, even though it’s an abstraction of legacy ERC20, it has a couple of modifications, it’s denylisting bit calculation is also one for the books.
Do you want to build a similar product and wants to understand the nitty-gritty? Or you want to find criticals in the codebase and are looking for technical chops to start? Keep reading.
The project has 3 components:
- The ERC20 from v2
- The ERC20 Permit of v3
- The core v3 implementation
Let’s take it one after the other.
Diving into the ERC20Upgradeable_V2
The ERC20Upgradeable_V2 is a succession of the initial ERC20Upgradeable contract with a zero-day vulnerability; we will cover the details of this implementation flaw later on.
By the way, we will only cover the functions that are necessary and jump over the overly straightforward ones.
Imports and State Variables
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
error IsNotDenylisted(address denylistee);
error IsDenylisted(address denylistee);
error IsOverSupplyCap(uint256 supply);
contract ERC20UpgradeableV2 is
Initializable,
ContextUpgradeable,
IERC20Upgradeable,
IERC20MetadataUpgradeable
{
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
uint256 private constant BALANCE_DENY_FLAG_SET_MASK = uint256(1) << 255;
uint256 private constant MAX_ALLOWED_SUPPLY = (uint256(1) << 255) - 1;The contract uses some upgradeability imports from OpenZeppelin such as:
IERC20UpgradeableIERC20MetadataUpgradeableContextUpgradeableInitializable
You will get to know how these libraries are utilized in the contract, as in their specific purposes, as we move on.
Of course, the created usual ERC20 mappings [like balances and allowances] and variables like _totalSupply, _name, and _symbol all of which are made private as anyone would expect.
But this is where you need to pay attention: how the BALANCE_DENY_FLAG_SET_MASK and MAX_ALLOWED_SUPPLY are created. Put on your thinking caps.
Explaining the BALANCE_DENY_FLAG_SET_MASK Design and Implementation
uint256 private constant BALANCE_DENY_FLAG_SET_MASK = uint256(1) << 255;For you to fully understand what’s going on here, I have to refresh your mind on some basics of Computer Science, particularly bits.
Bits are the smallest unit of data processing and storage. Bit are always been 0 and 1.
On the other hand, a byte is a bunch of 8 bits. In line with that, a storage slot in the EVM stores 256 bits, which is an equivalent of 32 bytes. Hold that on one hand, we digressed, now, let’s go back to the denylisting variable.
Hope you understand it to this point. Moving forward, you definitely know that data fill storage slots linearly.
However, in cases where we need to manipulate slots and have more control, we can use masks – this should ring a bell if you remember bitwise operations well.
This is what the developers of USDG did here:
- they created a constant variable called
BALANCE_DENY_FLAG_SET_MASKand made it private - they used
uint256for explicit storage and indicated that the binary should start from 1 - then shifted storage to the uttermost left of the slots, which is 255
In plain language, denylisted objects will be pushed to the last bit of the binary for easier identification.
By the way, have you wondered why we shift to 255 instead of 256? The reason is simple: 0 is an actual value in binary, so our bit started from 0. If you count 0 to 255, we have 256 numbers.
Hope you get it. Well, I’ll now explain the entire design with a story:
Imagine you are in a barrack with 255 rooms, with a guardroom at the 255th room.
So anytime a soldier messes up, they switch their room from say R6 to R255 where they will be locked there for a long time.
Why this design decision?
Left for me, I would have simply created a boolean variable where I can simply denylist by saying denylisted = true or something similar.
This is quite what any dev would have done, it’s the easiest and most straightforward technique you can think of.
However, the USDG smart contract architect noticed a huge problem circle was facing – denylisting with other methods are quite costly, unnecessarily so.
According to Alex Kroeger’s research, the blacklist functionality of USDC eventually cost approximately $4 million in 2022. This was absurd and equally unsustainable.
Hence, from the onset, USDG took a different approach with regards to implementing denylisting.
In short, the reason they used bitmask was to make the contract more efficient.
Explaining the MAX_ALLOWED_SUPPLY Design and Implementation
uint256 private constant MAX_ALLOWED_SUPPLY = (uint256(1) << 255) - 1;The same bitmask technique used above was used here, albeit with slight modifications.
Here is a breakdown of what the devs did here:
- created a constant variable to store maximum supply that is allowed
- Stored it with uint256 and started shifting the bits from slot 1 to 255
- then backshift the slot by 1, so it will rest at 254
- then denylist can perpetually sit at 255 as explained above
You might want to ask, “why not shift the bit to 254, so there will be no need to reduce it by 1 slot?” That’s really a brilliant observation.
Just that Bitwise operations don’t work that way, especially in line with the intended design.
If we quickly pull it up to 255, that will be the arbitrary highest bit in the slot, and that will backstab our intended design of keeping denylisted objects at 255.
ERC20 Initializations
function __ERC20_init(string memory name_, string memory symbol_)
internal
onlyInitializing
{
__ERC20_init_unchained(name_, symbol_);
}
function __ERC20_init_unchained(string memory name_, string memory symbol_)
internal
onlyInitializing
{
_name = name_;
_symbol = symbol_;
}This is an upgradeable contract, meaning we can initialize it without or with constructors. Thus, the name and symbol variables above were initialized.
In each cases, they both have the onlyInitializing modifier which will prevent reset from blackhats.
You might wonder, what’s the different between these two initialization techniques. The full gist is on OpenZeppelin docs, but I’ll do a quick abstraction.
The latter can be overridden in child contracts, while the former is specifically for this contract itself.
Native OZ ERC20 Functions
function name() public view virtual override returns (string memory) {
return _name;
}
/**
* @dev Returns the symbol of the token, usually a shorter version of the
* name.
*/
function symbol() public view virtual override returns (string memory) {
return _symbol;
}
function decimals() public view virtual override returns (uint8) {
return 18;
}
/**
* @dev See {IERC20-totalSupply}.
*/
function totalSupply() public view virtual override returns (uint256) {
return _totalSupply;
}
/**
* @dev See {IERC20-balanceOf}.
*/
function balanceOf(address account)
public
view
virtual
override
returns (uint256)
{
return _balances[account] & ~BALANCE_DENY_FLAG_SET_MASK;
}
function transfer(address to, uint256 amount)
public
virtual
override
returns (bool)
{
address owner = _msgSender();
_transfer(owner, to, amount);
return true;
}
/**
* @dev See {IERC20-allowance}.
*/
function allowance(address owner, address spender)
public
view
virtual
override
returns (uint256)
{
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount)
public
virtual
override
returns (bool)
{
address owner = _msgSender();
_approve(owner, spender, amount);
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) public virtual override whenNotDenylisted(_msgSender()) returns (bool) {
address spender = _msgSender();
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
function increaseAllowance(address spender, uint256 addedValue)
public
virtual
returns (bool)
{
address owner = _msgSender();
_approve(owner, spender, allowance(owner, spender) + addedValue);
return true;
}For proper customization, some functions must be modified in the contract. This includes name and symbol.
Let’s zoom in a bit on how the balanceOf was implemented: return _balances[account] & ~BALANCE_DENY_FLAG_SET_MASK;
The instruction in this function is the balances of accounts along with their status of denylisting, whether they are or not, should be returned.
This line is quite a marriage of bits, let’s take some time to break it down:
- On one hand, we have the bits storing the balances of accounts
- On another hand, we have the inversion of the BALANCE_DENY_FLAG_SET_MASK with the ~ operator.
- Both sides are conjoined by the & operator
The transfer, allowance, transferFrom and approve functions work normally in accordance with usual ERC20 implementation of OZ.
Allowance Incrementation and Decrementation
function increaseAllowance(address spender, uint256 addedValue)
public
virtual
returns (bool)
{
address owner = _msgSender();
_approve(owner, spender, allowance(owner, spender) + addedValue);
return true;
}
function decreaseAllowance(address spender, uint256 subtractedValue)
public
virtual
returns (bool)
{
address owner = _msgSender();
uint256 currentAllowance = allowance(owner, spender);
require(
currentAllowance >= subtractedValue,
"ERC20: decreased allowance below zero"
);
unchecked {
_approve(owner, spender, currentAllowance - subtractedValue);
}
return true;
}One of the banes of approvals is that things can go south and you wouldn’t be able to decrease or increase your approved tokens as you wish.
More particularly, if you give a maximum allowance or you were compromised, you cannot reduce your allowance.
In the increaseAllowance function, the first control implemented is that only the sender can give it. Secondly, the logic adds new value to the existing given allowance.
Moving to the decreaseAllowance function, it checks, first of all, that the current allowance is not lower than the value to be subtracted.
Then it allows the spender to spend the difference between currentAllowance and subtractedValue.
The _Transfer Function
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
_requireBalanceIsNotDenylisted(fromBalance, from);
require(
fromBalance >= amount,
"ERC20: transfer amount exceeds balance"
);
unchecked {
_balances[from] = fromBalance - amount;
}
uint256 toBalance = _balances[to];
_requireBalanceIsNotDenylisted(toBalance, to);
unchecked {
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
toBalance = toBalance + amount;
}
_balances[to] = toBalance;
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}The _transfer function has 2 hooks: _beforeTokenTransfer and afterTokenTransfer, which we shall examine later. It creates the fromBalance variable and ensures it’s greater than the amount to be transferred first of all.
There is a new _balances[from] variable, which is simply set to be the difference between balance and amount during transfers.
Moving on, there is a toBalance, indicating the balance of the receiving address. Once transfers are made, toBalance is added to amount locally.
Globally, _balances[to] is set to toBalance. By the way, this new implementation is a fix to the bug in v1.
The _Transfer Bug That Could Have Drained the Contract in v1
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
uint256 fromBalance = _balances[from];
_requireBalanceIsNotDenylisted(fromBalance, from);
uint256 toBalance = _balances[to];
_requireBalanceIsNotDenylisted(toBalance, to);
_beforeTokenTransfer(from, to, amount);
require(
fromBalance >= amount,
"ERC20: transfer amount exceeds balance"
);
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] = toBalance + amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}This function looks safe on the outside. In fact, it slipped through a couple of audits undetected and was later found by the team later, supposedly.
The main bug is here:
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] = toBalance + amount;
}In the _balances[to], toBalance will always have amount added to it so far it passes the initial check.
But here is the twist, you can transfer the amount you have to yourself perpetually till you drain the protocol. Why is this the case?
The main condition is you must have up to the amount you want to send.
Now, if you had $500 USDG in your wallet, you could self-transfer another $500 USDG to yourself, and your balance will be $1000 USDG with $500 minted out of thin air!
Fortunately, no blackhat discovered this before the team; would have been quite disastrous. Hence, the reason there contract has to be upgraded to v2.
Destroy Denylisted Funds
This is the internal _destroyDenylistedFunds function, which is implemented in the v3. The first thing this function ensures is an address has been marked denylisted.
After that, it sucks out that balance from total supply and makes it unspendable, then shifts the address to the banned 255 bit of the denylist bitmask.
The only issue I have with this implementation is that it doesn’t have a corresponding function to restore an address that was mistakenly added to the list.
Keep in mind that this whole mechanism is in place for compliance.
For example, imagine an address was denylisted based on compliance reasons. Then later, regulators gave a go-ahead to undenylist.
In such a case, there is no way to undo the lost funds, except a corresponding amount is minted back to the address.
function _destroyDenylistedFunds(address denylistee) internal virtual {
uint256 userBalance = _balances[denylistee];
_requireBalanceIsDenylisted(userBalance, denylistee);
uint256 denylistedFunds = userBalance & ~BALANCE_DENY_FLAG_SET_MASK;
_balances[denylistee] = BALANCE_DENY_FLAG_SET_MASK;
_totalSupply -= denylistedFunds;
emit DestroyDenylistedFunds({
denylister: _msgSender(),
denylistee: denylistee,
amount: denylistedFunds
});
}Diving Into the ERC20Permit
Spending ERC20 tokens can be unnecessarily expensive at scale. Particularly when spender authorization requires excessive gas to make.
To make EVM tokens more realistic to spend, ERC2612 was created and that brought about the permit functionality to make everything more composable and cheaper.
The Permit Function
The permit function allows a user to grant offchain approval to a spender, or sometimes a DeFi protocol, without having to sign anything offchain.
When it is successfully signed, the approve function will be triggered.
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual override {
require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
bytes32 structHash = keccak256(
abi.encode(
_PERMIT_TYPEHASH,
owner,
spender,
value,
_useNonce(owner),
deadline
)
);
bytes32 hash = _hashTypedDataV4(structHash);
address signer = ECDSAUpgradeable.recover(hash, v, r, s);
require(signer == owner, "ERC20Permit: invalid signature");
_approve(owner, spender, value);
}A permit function essentially has two entities: owner and spender. There must be financial value and a deadline as parameters. Additionally, v, r, s are ECDSA components that must be encoded and hashed.
In the case above, structHash was created and is the parent object into which the parameters were encoded.
Then, hash was created and assigned to _hashTypedDataV4 into which the earlier structhash was put. Before anything else, the signer must recover the encoded parameters, otherwise, there will be no approval!
Explaining the Functions on Nonces
function nonces(address owner)
public
view
virtual
override
returns (uint256)
{
return _nonces[owner].current();
}
function _useNonce(address owner)
internal
virtual
returns (uint256 current)
{
CountersUpgradeable.Counter storage nonce = _nonces[owner];
current = nonce.current();
nonce.increment();
}The CountersUpgradeable library from OZ was heavily used here. By and large,the first function ensures nonces of permits cannot be maliciously replayed by consuming current nonces of the owner.
The _useNonce function on the other hand stores a nonce of the owner; sets current nonce; and immediately increments it after consumption so it is no longer available.
The USDG V3 Core
This is the core contract where everything in the ERC20 v2 is implemented.
Initializations
Since this is the core contract, there was a need to initialize a couple of things, first because of smooth performance, and secondly for a tight security.
The constructor was intelligently disabled so it won’t be reinitialized. Then all the imported libraries were also integrated smoothly into the contract.
The initializeV3 shifted the project from v2 to v3.
constructor() {
_disableInitializers();
}
function initialize(address admin) public initializer {
__ERC20_init("Glo Dollar", "USDGLO");
__Pausable_init();
__AccessControl_init();
__UUPSUpgradeable_init();
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function initializeV3() public reinitializer(2) {
__ERC20Permit_init("Glo Dollar");
}Pausability Functions
Instead of building pausability functions from scratch, the developers simply leveraged the OZ library for it, and it works fine. Their functions call the internal functions in the OZ library for both pause and unpause.
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(PAUSER_ROLE) {
_unpause();
}The Denylisting Functions
function denylist(address denylistee) external onlyRole(DENYLISTER_ROLE) {
_denylist(denylistee);
}
function undenylist(address denylistee) external onlyRole(DENYLISTER_ROLE) {
_undenylist(denylistee);
}
function destroyDenylistedFunds(address denylistee)
external
onlyRole(DENYLISTER_ROLE)
{There are 3 different functions to:
- denylist
- undenylist, or
- block denylisted funds
The Mint and Burn Function
These are quite straightforward functions to mint and burn tokens. Meanwhile, only the approved addresses can take this action.
By the way, can you see that there is no whenNotDenylisted modifier on the burn function. Does that mean a denylisted address with a minter role can burn tokens?
Well, no. The possibility of this is already blocked in the v2 function.
function mint(address to, uint256 amount)
external
onlyRole(MINTER_ROLE)
whenNotDenylisted(_msgSender())
{
_mint(to, amount);
emit Mint({minter: _msgSender(), to: to, amount: amount});
}
function burn(uint256 amount) external onlyRole(MINTER_ROLE) {
_burn(_msgSender(), amount);
emit Burn({burner: _msgSender(), amount: amount});
}The _beforeTokenTransfer Hook
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal override whenNotPaused {
super._beforeTokenTransfer(from, to, amount);
}This is an internal hook that overrides what is in v2. Now, bear in mind that there is this hook before every transfer in v2, and here it has a whenNotPaused modifier.
Thus, it now acts as a prevention mechanism of transfers in critical situations when the contract is paused.
The AuthorizeUpgrade Function
function _authorizeUpgrade(address newImplementation)
internal
override
onlyRole(UPGRADER_ROLE)
{}This is a critical admin function that allows the movement from one version to another. It has the address of the new implementation as its parameter.
For safety, only the address with UPGRADER_ROLE can call it.
Concluding Remarks
On the product side, USDG is a product the ecosystem is embracing. Even though it is not yet on the same front with USDC and USDT, it is running to march up.
By the way, since it was created by Tether, the creator of USDT, it is not a threat to USDT.
That said, in this blog, I have outlined a couple of things you must know to be familiar with how the USDG contract works; either you want to implement something similar or you want to break it.
Concerning breaking the contract, there are 50k USDG bug bounties on Remedy and Immunefi. You can sit on the codebase to dig a critical, report it responsibly, and get rewarded.