Ethereum mainnet: 0x6B175474E89094C44Da98b954EedeAC495271d0F
Reviewed 2026‑08‑26 against block 25,837,079
DAI is a decentralised stablecoin, targeted at one US dollar. Unlike USDC or USDT, no company mints it. A user locks collateral, such as ETH, into a vault contract, and draws DAI against it. Repaying the loan burns the DAI. The system that runs those vaults was called MakerDAO and now calls itself Sky, but the DAI token contract audited here has not changed since it was deployed.
The contract itself is small: 122 lines of Solidity, plus a 29-line base
contract it inherits from. The main question about DAI is "who is allowed to call
mint?". A permissioned function that can create dollars from
nothing is only as safe as the address holding the permission, so this audit
spends most of its effort tracing that authority through the chain rather
than reading the token logic itself.
DAI is immutable. It has no owner in the conventional sense, no upgrade
slot, and no SELFDESTRUCT. Whatever the mint authority turns
out to be, it cannot be patched later, and it applies to a contract holding
4.57 billion DAI in circulation as of the review block.
This review found no exploitable vulnerability. The mint authority is proven
closed: one address has held it alone since 2019, and the chain shows that
set can never change again. The findings that remain sit in
permit, DAI's signed-approval function, which predates the
EIP-2612 standard most integrators now assume.
DAI holds no exploitable vulnerability. The contract is 122 lines of Solidity on a 29-line base, with no proxy, no upgrade path and no way to destroy it. Its 22 functions match its verified source exactly, and its arithmetic is guarded throughout.
The authority question dominates any review of a token that can be minted from nothing. This audit settles it from the chain rather than from documentation. DAI has exactly one address that can mint, it has had only that one since 2019-11-13, and the set can never change again. The section The mint authority gives the proof.
Both findings rated Medium sit in permit. DAI's signed
approvals predate EIP-2612 and differ from it in ways that punish an
integrator who assumes the later standard. A signature grants an unlimited
allowance rather than the amount signed, and a signature with
expiry set to zero never expires. Neither can be fixed, because
the contract is immutable, so the
integration checklist is the part of
this document most worth acting on.
| ID | Severity | Finding |
|---|---|---|
| M-1 | Medium | permit grants an unlimited allowance, never the amount signed |
| M-2 | Medium | A permit signature with expiry == 0 never expires |
| L-1 | Low | rely and deny emit only an anonymous event |
| L-2 | Low | No zero-address guard; 4,456,207.90 DAI is already stranded |
| L-3 | Low | An approved spender can burn the owner's tokens |
| L-4 | Low | The EIP-712 domain separator is fixed at deployment |
| L-5 | Low | approve() carries the classic ERC-20 race |
| L-6 | Low | Permit nonces run in strict sequence, and a signature is costly to cancel |
| I-1 | Info | The mint authority is frozen at one address and cannot change |
| I-2 | Info | permit is not EIP-2612 and answers a different selector |
| I-3 | Info | Unknown selectors revert |
| I-4 | Info | Three non-standard transfer aliases sit alongside the ERC-20 surface |
Counts: 0 critical, 0 high, 2 medium, 6 low, 4 informational.
| Address | 0x6B175474E89094C44Da98b954EedeAC495271d0F |
|---|---|
| Deployed | Block 8,928,158, 2019-11-13 19:17:57 UTC, 6.78 years ago |
| Creation tx | 0x495402df7d45fe36329b0bd94487f49baee62026d50f654600f6771bd2a596ab |
| Transaction sender | 0xdDb108893104dE4E1C6d0E47c42237dB4E617ACc, an externally owned account |
| Actual deployer | 0xB5B06a16621616875a6C2637948bF98eA57c58fA, the DaiFab factory |
| Compiler | solc 0.5.12+commit.7709ece9, optimiser disabled, EVM target petersburg |
| Constructor argument | chainId_ = 1 |
| Runtime code | 7,904 bytes: 7,852 executable plus a 52-byte metadata trailer |
| Instructions | 3,488 decoded, zero unknown opcodes |
| Dispatch table | 22 selectors, matching the verified source exactly |
| Mint authority | one address, 0x9759A6Ac90977b93B58547b4A71c78317f391A28 |
| Total supply | 4,574,232,786.108553 DAI |
| Stranded at dead addresses | 4,456,207.900636 DAI, 0.0974% of supply |
| ETH held | 0. No function is payable |
| Account nonce | 1. The contract has never deployed anything |
| Storage slots | 0 wards, 1 totalSupply, 2 balanceOf, 3 allowance, 4 nonces, 5 DOMAIN_SEPARATOR |
name, symbol, version,
decimals and PERMIT_TYPEHASH are declared
constant, so they occupy no storage and live in the code.
I read the runtime bytecode from mainnet over JSON-RPC and disassembled it
with an opcode walker that skips PUSH immediates. Claims about
what the contract cannot do rest on that disassembly rather than on reading
the source. Every figure quoted above was read in a single batched request
pinned to block 25,837,079, so the numbers reconcile with each other.
Three method notes changed the result and are worth recording.
The authority history is enumerable, and that was not obvious.
DAI has no Rely or Deny event. Both functions carry
a note modifier that emits an anonymous event through
log4, and its first topic is msg.sig shifted into
the high four bytes. An anonymous event has no signature topic, so ordinary
decoders skip it. Filtering the logs on topic0
0x65fae35e00000000000000000000000000000000000000000000000000000000
recovers every successful rely call regardless. That turned a
question a candidate sweep can only guess at into a complete history.
The log endpoint truncates in silence. The explorer API used for log history returns at most 1,000 entries per query and ignores paging parameters, so a query that returns exactly 1,000 has dropped an unknown number more. The tooling now halves any window that fills the cap and recurses. Without that, a completeness claim about the ward set would have rested on a truncated reply.
A linear selector scan drifts into embedded creation code.
Scanning the runtime of DaiJoinFab for PUSH4
immediates reports ten selectors, including cage(),
rely(address) and wards(address). The contract has
one function. The other selectors are bytes inside the child contract's
creation code, which the factory embeds as data. Reading them as a
dispatcher would have inverted the conclusion in
I-1. Direct eth_call against each supposed
function confirmed that only newDaiJoin(address,address)
exists.
mint adds to any balance and to totalSupply with no
cap, and it is guarded by a single check:
mapping (address => uint) public wards;
modifier auth { require(wards[msg.sender] == 1, "Dai/not-authorized"); _; }
function mint(address usr, uint wad) external auth { … }
Who holds that permission is therefore the whole risk story for supply. Four questions settle it, and each is answered from chain state below.
One address. A sweep of wards(address) over all 503 addresses in
the Maker chainlog, plus the deployer, the deployment factories, the
governance pause, the pause proxy and the emergency shutdown module, returns
a single holder at block 25,837,079:
0x9759A6Ac90977b93B58547b4A71c78317f391A28 wards = 1 MCD_JOIN_DAI
Every other candidate returns 0. Notably the governance pause proxy,
0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB,
returns 0. MakerDAO governance cannot mint DAI directly.
Yes, and this rests on the full event history rather than on the sweep.
Every successful rely and deny since deployment
appears below, recovered by filtering on the anonymous LogNote
topic described in the method:
| Block | Caller | Action | Target |
|---|---|---|---|
| 8,928,158 | DaiFab 0xb5b0…c58fa | rely | DssDeploy 0xbaa6…d3f4 |
| 8,928,158 | DaiFab 0xb5b0…c58fa | deny | itself |
| 8,928,158 | DssDeploy 0xbaa6…d3f4 | rely | MCD_JOIN_DAI 0x9759…1A28 |
| 8,928,244 | DssDeploy 0xbaa6…d3f4 | deny | itself |
Four events, and no others in 16.9 million blocks. The constructor set
wards[DaiFab] = 1; the deployment then handed the permission to
DssDeploy, passed it to the DAI adapter, and revoked both
deployment contracts. Since block 8,928,244 the ward set has been exactly
{MCD_JOIN_DAI}. Only rely, deny and
the constructor ever write wards, so the four events plus the
constructor are the complete history.
No. rely is itself gated by auth, so only a
current ward can grant the permission, and the only ward is
MCD_JOIN_DAI. That contract holds three outbound call selectors
in its entire runtime:
pc 816 0xbb35783b vat.move(address,address,uint256)
pc 1082 0x9dc29fac dai.burn(address,uint256)
pc 2591 0x40c10f19 dai.mint(address,uint256)
One caveat belongs here, because it looks at first like a contradiction. The
selector 0x65fae35e does appear in the adapter's runtime, at
program counter 126. That is its own dispatcher, which ends at 137:
MCD_JOIN_DAI runs a wards mapping of its own and
answers rely on itself. It is not an outbound call, and it
grants nothing on DAI. Past the dispatcher the only selectors the adapter
ever puts on the stack for a call are the three above.
MCD_JOIN_DAI therefore has no path to call rely or
deny on DAI, and no general call forwarding. The DAI ward set is
closed permanently. No governance action, and no compromise of governance,
can add a DAI minter or remove the existing one.
No, and this cuts the other way. MCD_JOIN_DAI mints inside
exit, which is gated on its own live flag, and
cage() clears that flag irreversibly. Caging it would freeze DAI
supply forever. That cannot happen either. MCD_JOIN_DAI has
emitted no rely, deny or cage event
since deployment, so its ward set is still whatever its constructor wrote.
That is its creator, DaiJoinFab at
0x64a84e558192dd025f3a96775fee8fb530f27177,
confirmed by direct read:
MCD_JOIN_DAI.wards(0x64a84e55…f27177) = 1 every other candidate = 0
MCD_JOIN_DAI.live() = 1
DaiJoinFab exposes exactly one function,
newDaiJoin(address,address), which deploys a fresh adapter.
Calls to cage(), rely(address),
wards(address) and live() against it all revert. A
newly deployed adapter is not a DAI ward, so it cannot mint. The factory has
no way to act on the live adapter.
DAI's own permission layer is inert. Minting authority passed to one
contract during deployment in 2019 and was sealed there. The residual risk
sits one level up, in the Vat at
0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B,
which decides who holds the internal balance that exit converts
into DAI. The Vat had 129 wards at block 25,837,079, among them the
governance pause proxy and the collateral adapters. Anyone assessing how
much DAI can be created is looking at the wrong contract if they are looking
at this one. That analysis belongs to the Vat and is outside the scope of
this review.
permit grants an unlimited allowance, never the amount signed MediumAffects any integrator who ports EIP-2612 permit code onto DAI.
DAI's signed approvals predate EIP-2612 and carry a boolean where the standard carries a value:
uint wad = allowed ? uint(-1) : 0;
allowance[holder][spender] = wad;
A holder signing a DAI permit is signing an unlimited approval. There is no
way to sign for 100 DAI. An integrator porting code from an EIP-2612 token
will pass a value that DAI has no field for, and the resulting
approval is unbounded rather than capped.
The consequence is that the blast radius of a leaked or misdirected permit is the holder's whole balance, now and in future, rather than the amount they believed they were authorising. Combined with M-2, a single careless signature can expose a balance permanently.
Mitigation. Treat every DAI permit as an unlimited approval
to that spender. Send allowed = false in a follow-up permit
once the spender has finished, or set the allowance to zero with
approve. Never present a DAI permit to a user as a bounded,
single-use authorisation, because it is neither.
permit signature with expiry == 0 never expires MediumAffects any wallet or relayer that passes a default or missing expiry through unchanged.
The expiry check carries an explicit carve-out:
require(expiry == 0 || now <= expiry, "Dai/permit-expired");
The bytecode shows the short circuit plainly. TIMESTAMP appears
exactly once in the whole contract, and a zero expiry jumps
past it:
5524 PUSH1 0x00
5526 DUP7 <- expiry
5527 EQ
5528 DUP1
5529 PUSH2 0x15a2 <- 5538, past the timestamp comparison
5532 JUMPI
5533 POP
5534 DUP6
5535 TIMESTAMP <- the only TIMESTAMP in the contract
5536 GT
5537 ISZERO
5538 JUMPDEST
5539 PUSH2 0x1614 <- 5652, continue
5542 JUMPI
5543 … <- revert, "Dai/permit-expired"
EIP-2612 reads the same field in the opposite direction. Its check is
require(block.timestamp <= deadline), under which a deadline
of zero always fails and the signature is dead on arrival. DAI inverts that:
zero means the signature is valid forever.
This matters because zero is what a caller ends up with by accident. An uninitialised struct field, a missing form value, a default in a serialiser and a truncated JSON number all produce zero. On an EIP-2612 token every one of those fails safely. On DAI each one mints a permanent bearer authorisation over the holder's entire balance, valid to any party who later obtains the signature.
Mitigation. Reject expiry == 0 in your own code
before signing or relaying, and require a real deadline. If you operate a
relayer or a wallet, treat an inbound DAI permit with zero expiry as
malformed rather than passing it through. A holder who has already signed
one can invalidate it only by consuming that nonce, described in
L-6.
rely and deny emit only an anonymous event LowAffects anyone monitoring DAI's mint authority through decoded event streams.
Neither function emits a named event. Both carry a note
modifier that logs through log4 with the event declared
anonymous, so the first topic is the function selector rather
than an event signature hash. Two LOG4 sites exist in the
contract, at program counters 4747 and 6493, one for each function.
An anonymous event has no signature topic to match, so ABI-driven indexers, subgraphs and wallet activity feeds pass over it. A change to who can mint DAI would produce no entry in any conventional log pipeline. The information is on chain, but only a reader who already knows the encoding will find it.
Recovering the history means filtering on the padded selector directly, for
example topic0 0x65fae35e000…000 for rely. The log
data field is a fixed 224 bytes of calldata regardless of the real calldata
length, zero-filled past the end, which is why the payload looks larger than
the 36-byte call.
Mitigation. If you monitor DAI, add explicit topic0 filters
for rely(address) and deny(address) rather than
relying on decoded event streams. An alert on either is warranted, because
either would be the first change to DAI's mint authority since 2019.
Affects holders who mistype a destination or paste the token's own address.
transfer and transferFrom accept any destination.
The contract has no rescue function, so tokens sent to an address that
cannot spend them are gone. Balances at block 25,837,079:
| Address | Balance |
|---|---|
| Zero address | 12,947.2114 DAI |
| The DAI contract itself | 4,429,774.2499 DAI |
The ecrecover precompile, 0x…0001 | 5,778.8169 DAI |
Burn address 0x…dEaD | 7,707.6226 DAI |
| Total | 4,456,207.9006 DAI |
That is 0.0974% of supply. The bulk of it sits at the token's own address, which is the destination a user reaches by pasting the contract address into a wallet.
This is not equivalent to burning. burn reduces
totalSupply; a transfer to a dead address does not. Every one
of the tokens above is still counted as outstanding supply while being
permanently unspendable, so totalSupply overstates circulating
DAI by that amount.
The balance at the ecrecover precompile is worth separating
out. A precompile has no code to spend a token balance, so those 5,778.82
DAI are as lost as the rest. The audit did not establish how they arrived
there.
Affects holders who approve a contract under ordinary ERC-20 expectations.
burn is not part of ERC-20, and it is reachable by a spender
rather than only by the owner:
function burn(address usr, uint wad) external {
require(balanceOf[usr] >= wad, "Dai/insufficient-balance");
if (usr != msg.sender && allowance[usr][msg.sender] != uint(-1)) {
require(allowance[usr][msg.sender] >= wad, "Dai/insufficient-allowance");
allowance[usr][msg.sender] = sub(allowance[usr][msg.sender], wad);
}
…
}
Granting a DAI allowance therefore authorises destruction as well as transfer. The economic loss to the holder is the same either way, which keeps this at Low, but the accounting differs and the assumption differs. A holder who approves a contract under ordinary ERC-20 expectations does not expect their tokens to be removed from supply.
Mitigation. Size DAI allowances to what the spender needs,
and revoke them when it is done. Accounting systems should treat a
Transfer to the zero address from a spender-initiated
burn as a supply reduction, not a transfer.
Affects holders with an outstanding permit signature if Ethereum ever splits.
DOMAIN_SEPARATOR is computed once in the constructor from a
chain id supplied as an argument, and stored in slot 5. The value read at
block 25,837,079 is
0xdbb8cf42e1ecb028be3f3dbc922e1d878b963f411dc388ced501601c60f7c6f7,
which matches a recomputation for chain id 1 exactly.
The contract never recomputes it. The CHAINID opcode does not
appear in the runtime at all, which is expected: DAI targets the petersburg
EVM, and EIP-1344 introduced CHAINID in the later Istanbul
fork.
After a contentious chain split, both chains carry the same stored separator while having different chain ids. Every unused DAI permit signature would then be valid on both. The condition is a hard fork rather than anything an attacker can arrange, which is why this is Low, but holders with outstanding permits are exposed on the forked chain and cannot revoke there.
approve() carries the classic ERC-20 race LowAffects the whole ERC-20 generation, not DAI specifically.
approve overwrites the allowance with no guard, and the
contract offers neither increaseAllowance nor
decreaseAllowance. A spender watching the mempool can spend the
old allowance and then the new one.
This affects the whole ERC-20 generation rather than DAI specifically. Setting the allowance to zero before setting a new non-zero value avoids it.
Affects anyone signing more than one DAI permit before the first is used.
DAI takes the nonce as a call argument and checks it against storage:
require(nonce == nonces[holder]++, "Dai/invalid-nonce");
Two consequences follow. Signatures must be redeemed in the order they were signed, so a holder who signs nonces 5 and 6 and never submits 5 has silently invalidated 6 as well. And a signature already in the wild can be cancelled only by landing some other permit at that same nonce, which costs a transaction, a fresh signature and gas.
Because a DAI permit is unlimited and, with a zero expiry, permanent, the absence of a cheap revocation is what turns M-2 from an inconvenience into a standing exposure.
Mitigation. Read nonces(holder) immediately
before signing, and issue one permit at a time. To retire a signature you
regret, submit a permit at that nonce with allowed = false and
a spender you control.
Recorded as a finding because it is the answer to the question a reader of this audit most wants answered, and because it is not visible from the ABI, from any decoded event stream, or from the source alone. The evidence is in The mint authority.
The property is favourable. A token whose minter set was fixed by its deployment transaction and is provably closed carries less governance risk than one whose minter list a DAO can edit. It is also permanent in the unfavourable direction: no mistake in that arrangement can ever be corrected.
permit is not EIP-2612 and answers a different selector InfoDAI's signed approval differs from the standard in four ways.
| DAI | EIP-2612 | |
|---|---|---|
| Selector | 0x8fcbaf0c | 0xd505accf |
| Amount | bool allowed, giving 0 or unlimited | uint256 value |
| Nonce | passed as an argument | read from storage |
| Deadline of zero | never expires | always rejected |
The type hash is
0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb,
over
Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed),
confirmed against the value returned by PERMIT_TYPEHASH() at
block 25,837,079.
The failure mode is safe. A router calling the EIP-2612 selector against DAI reverts, verified live at the pinned block, so a mismatch surfaces immediately rather than silently doing nothing. This is why routers carry a DAI-specific permit path.
DAI declares no fallback function, so the dispatcher reverts when no selector matches. Confirmed at block 25,837,079:
eth_call permit (EIP-2612) 0xd505accf -> REVERT
eth_call owner() 0x8da5cb5b -> REVERT
eth_call garbage 0xdeadbeef -> REVERT
eth_call empty calldata 0x -> REVERT
eth_call mint(addr,1) 0x40c10f19 -> REVERT, "Dai/not-authorized"
Feature detection by call success therefore works correctly against DAI, which is not true of every token of this era. The contract is also not payable, holds no ETH, and reverts on a plain ETH send.
push(usr, wad), pull(usr, wad) and
move(src, dst, wad) each forward to transferFrom,
and each returns nothing where transfer and
transferFrom return bool. They are safe, since
transferFrom reverts rather than returning false, so a failure
cannot be swallowed. They matter only for tools that enumerate the ABI and
assume every transfer-shaped function reports a result.
Each item below was checked and produced no finding. They are recorded because an audit that lists only problems does not tell a reader what was examined.
add, which requires (z = x + y) >= x, or
through sub, which requires (z = x - y) <= x.
mint, burn, transferFrom and the
allowance decrement all use them. The runtime holds no MUL,
DIV or MOD instruction at all, so no token
amount is ever scaled. The single unchecked operation is
nonces[holder]++ in permit, and wrapping it
would take 2^256 permits from one holder, so it cannot be reached.permit rejects the zero address before
comparing. ecrecover returns the zero address on a
malformed signature. require(holder != address(0)) runs
first, so a signature that fails to recover cannot be made to match a
holder of zero.(v, s) pair recovers the same address and therefore passes
the same check. The nonce is consumed either way, so the permit applies
once. Offchain systems that deduplicate by signature hash rather than by
nonce should still normalise, since two distinct signature bytes
authorise the same permit.STATICCALL to the ecrecover precompile at
program counter 5336. There is no reentrancy surface, because there is no
callback into untrusted code anywhere.LOG3 sites match the five places the source emits, one
Transfer each in transferFrom, mint
and burn, and one Approval each in
approve and permit. Mints and burns emit
Transfer against the zero address as ERC-20 indexers expect,
so a balance tracker built by replaying Transfer logs is
correct.Each item below is confirmed against the disassembled runtime code.
DELEGATECALL and no CALLCODE, so the
contract is not a proxy and has no upgrade path. All three standard proxy
storage slots read zero, and the block explorer independently reports no
implementation and no proxy type.SELFDESTRUCT, so it cannot be destroyed.CREATE or CREATE2, and the account nonce is
1, so it has never deployed anything.CALL, so it can never move ETH or invoke another
contract's state changing code. The single STATICCALL
reaches a precompile.ORIGIN, so there is no tx.origin
authentication.NUMBER and one TIMESTAMP, used only for the
permit expiry.wards mapping is the entire authority surface,
and I-1 shows it is closed.The Solidity project lists 13 known bugs affecting 0.5.12. I checked each against this contract. None applies.
The optimiser was disabled at compile time, which clears
KeccakCaching and
YulOptimizerRedundantAssignmentBreakContinue0.5 outright, since
both are optimiser defects.
Seven more need language features DAI never uses. It declares no arrays of
any kind, which clears LostStorageArrayWriteOnSlotOverflow,
DynamicArrayCleanup, MemoryArrayCreationOverflow,
AbiReencodingHeadOverflowWithStaticArrayCleanup,
NestedCalldataArrayAbiReencodingSizeValidation and
ABIDecodeTwoDimensionalArrayMemory. It performs no tuple
assignment, which clears
TupleAssignmentMultiStackSlotComponents.
Two concern copying byte arrays into storage.
DirtyBytesArrayToStorage and EmptyByteArrayCopy
both require a bytes or string state variable. DAI
has none: name, symbol and version
are constant and live in the code. Slots 0 through 4 hold
mapping bases and totalSupply, and slot 5 holds the domain
separator, so no string storage exists to corrupt.
Two need a specific answer.
ImplicitConstructorCallvalueCheck applies to contracts with no
explicit constructor; DAI declares
constructor(uint256 chainId_) public, which is non-payable, so
the callvalue check is emitted. privateCanBeOverridden needs a
private function in a base contract; the only base is LibNote,
which declares an event and a modifier and no functions at all.
expiry = 0. Reject it in your own code before
signing or relaying, because DAI reads zero as "never expires" where
EIP-2612 reads it as "already expired".0x8fcbaf0c with the
(holder, spender, nonce, expiry, allowed, v, r, s) argument
list. The EIP-2612 selector reverts.nonces(holder) immediately before signing, and keep
one permit outstanding at a time. Nonces must be redeemed in order.totalSupply() as circulating supply. It counts
the stranded balances above.0x65fae35e000… and 0x9c52a7f1000…. Decoded event
streams will not show a change of minter.State these alongside the findings whenever this audit is quoted.
Vat, the collateral adapters, the governance pause and the
wider Maker system were read only far enough to answer the authority
question, and are not reviewed here. The capacity to create DAI is
decided in the Vat, and that is where an assessment of supply
risk belongs.Vat and in the collateral held against it, not in this
contract.eth_getLogs. Windows that filled the
result cap were bisected until none did, so the set is complete with
respect to what that explorer holds.