← David Ryan's builder page

DAI security audit

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.

Audit summary

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.

Findings at a glance

IDSeverityFinding
M-1Mediumpermit grants an unlimited allowance, never the amount signed
M-2MediumA permit signature with expiry == 0 never expires
L-1Lowrely and deny emit only an anonymous event
L-2LowNo zero-address guard; 4,456,207.90 DAI is already stranded
L-3LowAn approved spender can burn the owner's tokens
L-4LowThe EIP-712 domain separator is fixed at deployment
L-5Lowapprove() carries the classic ERC-20 race
L-6LowPermit nonces run in strict sequence, and a signature is costly to cancel
I-1InfoThe mint authority is frozen at one address and cannot change
I-2Infopermit is not EIP-2612 and answers a different selector
I-3InfoUnknown selectors revert
I-4InfoThree non-standard transfer aliases sit alongside the ERC-20 surface

Counts: 0 critical, 0 high, 2 medium, 6 low, 4 informational.

Verified onchain facts

Address0x6B175474E89094C44Da98b954EedeAC495271d0F
DeployedBlock 8,928,158, 2019-11-13 19:17:57 UTC, 6.78 years ago
Creation tx0x495402df7d45fe36329b0bd94487f49baee62026d50f654600f6771bd2a596ab
Transaction sender0xdDb108893104dE4E1C6d0E47c42237dB4E617ACc, an externally owned account
Actual deployer0xB5B06a16621616875a6C2637948bF98eA57c58fA, the DaiFab factory
Compilersolc 0.5.12+commit.7709ece9, optimiser disabled, EVM target petersburg
Constructor argumentchainId_ = 1
Runtime code7,904 bytes: 7,852 executable plus a 52-byte metadata trailer
Instructions3,488 decoded, zero unknown opcodes
Dispatch table22 selectors, matching the verified source exactly
Mint authorityone address, 0x9759A6Ac90977b93B58547b4A71c78317f391A28
Total supply4,574,232,786.108553 DAI
Stranded at dead addresses4,456,207.900636 DAI, 0.0974% of supply
ETH held0. No function is payable
Account nonce1. The contract has never deployed anything
Storage slots0 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.

Method

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.

The mint authority

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.

Who can mint today

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.

Whether that set is complete

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:

BlockCallerActionTarget
8,928,158DaiFab 0xb5b0…c58farelyDssDeploy 0xbaa6…d3f4
8,928,158DaiFab 0xb5b0…c58fadenyitself
8,928,158DssDeploy 0xbaa6…d3f4relyMCD_JOIN_DAI 0x9759…1A28
8,928,244DssDeploy 0xbaa6…d3f4denyitself

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.

Whether the set can change again

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.

Whether minting can be switched off

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.

What this means

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.

Findings

M-1 · permit grants an unlimited allowance, never the amount signed Medium

Affects 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.

M-2 · A permit signature with expiry == 0 never expires Medium

Affects 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.

L-1 · rely and deny emit only an anonymous event Low

Affects 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.

L-2 · No zero-address guard, and 4,456,207.90 DAI is already stranded Low

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:

AddressBalance
Zero address12,947.2114 DAI
The DAI contract itself4,429,774.2499 DAI
The ecrecover precompile, 0x…00015,778.8169 DAI
Burn address 0x…dEaD7,707.6226 DAI
Total4,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.

L-3 · An approved spender can burn the owner's tokens Low

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.

L-4 · The EIP-712 domain separator is fixed at deployment Low

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.

L-5 · approve() carries the classic ERC-20 race Low

Affects 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.

L-6 · Permit nonces run in strict sequence, and a signature is costly to cancel Low

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.

I-1 · The mint authority is frozen at one address and cannot change Info

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.

I-2 · permit is not EIP-2612 and answers a different selector Info

DAI's signed approval differs from the standard in four ways.

DAIEIP-2612
Selector0x8fcbaf0c0xd505accf
Amountbool allowed, giving 0 or unlimiteduint256 value
Noncepassed as an argumentread from storage
Deadline of zeronever expiresalways 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.

I-3 · Unknown selectors revert Info

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.

I-4 · Three non-standard transfer aliases sit alongside the ERC-20 surface Info

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.

What the contract does correctly

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.

What the contract cannot do

Each item below is confirmed against the disassembled runtime code.

Compiler risk

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.

Integration checklist

  1. Treat a DAI permit as an unlimited approval. It cannot be scoped to an amount.
  2. Never send 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".
  3. Call DAI's own permit selector 0x8fcbaf0c with the (holder, spender, nonce, expiry, allowed, v, r, s) argument list. The EIP-2612 selector reverts.
  4. Read nonces(holder) immediately before signing, and keep one permit outstanding at a time. Nonces must be redeemed in order.
  5. Set an allowance to zero before changing it to a new non-zero value.
  6. Size allowances deliberately. A DAI spender with an allowance can burn the tokens, not only move them.
  7. Guard destination addresses yourself. DAI will send tokens to the zero address, and to its own address, without complaint, and 4.46 million DAI is already stranded that way.
  8. Do not read totalSupply() as circulating supply. It counts the stranded balances above.
  9. Monitor authority by filtering logs on topic0 0x65fae35e000… and 0x9c52a7f1000…. Decoded event streams will not show a change of minter.
  10. Do not assume a DAI permit signed for one chain is confined to it. The domain separator is fixed at deployment and does not track a chain split.

Limits of this review

State these alongside the findings whenever this audit is quoted.

Links