Summary
DinEmission.fundGI(uint256 gi, address taskAuditor) is external, callable by anyone, and never validates that taskAuditor is a real, registry-registered task-auditor contract. Since taskAuditor is both the recipient of a fresh ERC-20 allowance over newly-minted DIN and the target of an external call, an attacker can supply their own contract address and drain the mint in a single transaction — repeatably, bypassing the emission decay schedule entirely.
Where
foundry/src/DinEmission.sol (verified against develop @ 740a613), fundGI (~L174-201):
function fundGI(uint256 gi, address taskAuditor) external nonReentrant returns (uint256 amount) {
if (taskAuditor == address(0)) revert InvalidAddress();
if (giEmissionFunded[taskAuditor][gi]) revert GIAlreadyFunded(gi);
EmissionState storage state = emissionState[taskAuditor];
if (!state.started) { state.currentEmissionPerGI = initialEmissionPerGI; state.started = true; }
if (state.currentEpoch >= maxEpochs) revert EmissionExhausted();
amount = state.currentEmissionPerGI;
...
coordinator.mintEmission(address(this), amount); // mints to DinEmission itself
dinToken.safeIncreaseAllowance(taskAuditor, amount); // approves arbitrary `taskAuditor`
IDINTaskAuditor(taskAuditor).depositRewards(gi, amount); // external call to arbitrary address
...
}
Neither DinCoordinator.mintEmission (only checks the caller is DinEmission itself) nor the real DINTaskAuditor.depositRewards (just does dinToken.safeTransferFrom(msg.sender, address(this), amount), trusting whoever calls it) constrains taskAuditor. emissionState/giEmissionFunded are keyed by this same attacker-chosen address, so a fresh fake contract each time also resets the decay schedule — there's no protection beyond DinCoordinator.mintCap, which defaults to 0 (uncapped, confirmed in DinCoordinator.initialize()).
The function's own doc comment rationalizes the open caller ("the amount is deterministic from the epoch schedule so there is no benefit to restricting the caller") — but that reasoning only covers the amount, not the recipient. It misses that taskAuditor is the allowance grantee and call target, which is the actual exploitable surface. Reads as a genuine oversight, not an accepted risk — no mention of this in Developer/BACK_LOG.md or Documentation/technical/audits/foundry-src-security-review.md.
Exploit
- Attacker deploys a trivial contract whose
depositRewards(uint256, uint256 amount) just does dinToken.transferFrom(msg.sender, attacker, amount).
- Attacker calls
DinEmission.fundGI(gi, address(thatContract)).
- In one transaction:
DinEmission mints amount to itself, approves the attacker's contract for amount, then calls into it — which immediately pulls the full amount via the allowance.
- Repeatable indefinitely with fresh fake contracts, each getting the full undecayed
initialEmissionPerGI (the decay schedule is keyed per-taskAuditor, so a new address always starts fresh).
Verified with a from-scratch Foundry PoC against develop @ 740a613 (not reusing any other draft): test_fundGI_arbitraryTaskAuditor_drainsMintedDIN (one-call full drain — attacker balance +amount, DinEmission balance returns to 0, totalMinted genuinely inflated) and test_fundGI_repeatedWithFreshFakeAuditors_eachGetsUndecayedAmount (3 fresh fake contracts each independently get the full undecayed amount) both pass. The existing EmissionTests.t.sol suite (21 tests) also still passes unmodified — nothing in it exercises an unregistered taskAuditor, confirming this isn't an already-tested/accepted boundary.
Impact
Unbounded DIN token theft and inflation, gated only by attacker gas cost — mintCap is 0 (uncapped) by default on every network including the current devnet.
Recommendation
Validate taskAuditor before minting/approving — e.g. DINModelRegistry.getModelIdByTaskAuditor(taskAuditor) != 0 or DinValidatorStake.isSlasherContract(taskAuditor), mirroring the check DINModelRegistry.requestModelRegistration already does for coordinator/auditor pairs elsewhere in the codebase.
Summary
DinEmission.fundGI(uint256 gi, address taskAuditor)isexternal, callable by anyone, and never validates thattaskAuditoris a real, registry-registered task-auditor contract. SincetaskAuditoris both the recipient of a fresh ERC-20 allowance over newly-minted DIN and the target of an external call, an attacker can supply their own contract address and drain the mint in a single transaction — repeatably, bypassing the emission decay schedule entirely.Where
foundry/src/DinEmission.sol(verified againstdevelop@740a613),fundGI(~L174-201):Neither
DinCoordinator.mintEmission(only checks the caller isDinEmissionitself) nor the realDINTaskAuditor.depositRewards(just doesdinToken.safeTransferFrom(msg.sender, address(this), amount), trusting whoever calls it) constrainstaskAuditor.emissionState/giEmissionFundedare keyed by this same attacker-chosen address, so a fresh fake contract each time also resets the decay schedule — there's no protection beyondDinCoordinator.mintCap, which defaults to0(uncapped, confirmed inDinCoordinator.initialize()).The function's own doc comment rationalizes the open caller ("the amount is deterministic from the epoch schedule so there is no benefit to restricting the caller") — but that reasoning only covers the amount, not the recipient. It misses that
taskAuditoris the allowance grantee and call target, which is the actual exploitable surface. Reads as a genuine oversight, not an accepted risk — no mention of this inDeveloper/BACK_LOG.mdorDocumentation/technical/audits/foundry-src-security-review.md.Exploit
depositRewards(uint256, uint256 amount)just doesdinToken.transferFrom(msg.sender, attacker, amount).DinEmission.fundGI(gi, address(thatContract)).DinEmissionmintsamountto itself, approves the attacker's contract foramount, then calls into it — which immediately pulls the full amount via the allowance.initialEmissionPerGI(the decay schedule is keyed per-taskAuditor, so a new address always starts fresh).Verified with a from-scratch Foundry PoC against
develop@740a613(not reusing any other draft):test_fundGI_arbitraryTaskAuditor_drainsMintedDIN(one-call full drain — attacker balance +amount,DinEmissionbalance returns to 0,totalMintedgenuinely inflated) andtest_fundGI_repeatedWithFreshFakeAuditors_eachGetsUndecayedAmount(3 fresh fake contracts each independently get the full undecayed amount) both pass. The existingEmissionTests.t.solsuite (21 tests) also still passes unmodified — nothing in it exercises an unregisteredtaskAuditor, confirming this isn't an already-tested/accepted boundary.Impact
Unbounded DIN token theft and inflation, gated only by attacker gas cost —
mintCapis 0 (uncapped) by default on every network including the current devnet.Recommendation
Validate
taskAuditorbefore minting/approving — e.g.DINModelRegistry.getModelIdByTaskAuditor(taskAuditor) != 0orDinValidatorStake.isSlasherContract(taskAuditor), mirroring the checkDINModelRegistry.requestModelRegistrationalready does for coordinator/auditor pairs elsewhere in the codebase.