Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Daimon.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity ^0.8.28;
// Contracts
import { Initializable } from "solady/utils/Initializable.sol";
import { UUPSUpgradeable } from "solady/utils/UUPSUpgradeable.sol";
import { ReentrancyGuardTransient } from "solady/utils/ReentrancyGuardTransient.sol";
import { Receiver } from "solady/accounts/Receiver.sol";
import { ERC1271 } from "solady/accounts/ERC1271.sol";
import { DaimonERC7739 } from "@core/DaimonERC7739.sol";
Expand Down Expand Up @@ -50,6 +51,7 @@ import { Mandate, MandateId } from "@types/MandateTypes.sol";
contract Daimon is
Initializable,
UUPSUpgradeable,
ReentrancyGuardTransient,
Receiver,
DaimonERC7739,
RootRegistry,
Expand Down Expand Up @@ -183,6 +185,11 @@ contract Daimon is
/// path enforces the mandate's sigils over *every* call. The signed digest commits to
/// `(mode, executionData, nonce, deadline)`. Reverts on failure; the single-use nonce is burned first
/// (CEI), but only AFTER the `deadline` check, so an expired payload cannot burn its nonce.
/// `nonReentrant` (solady transient guard): a nested `executeWithSig` would clobber an outer execution's
/// SpendSigil pre-execution balance snapshot — kept in transient storage keyed by `(account, token)`, not
/// per-execution — and zero out its balance-delta backstop, so nested entry is forbidden. The ROOT
/// self-call to `bindMandates`/`installRoot`/`upgrade` is a DIFFERENT function (not a nested
/// `executeWithSig`), so it is not blocked.
function executeWithSig(
bytes32 mode,
bytes calldata executionData,
Expand All @@ -193,6 +200,7 @@ contract Daimon is
external
payable
virtual
nonReentrant
returns (bytes[] memory results)
{
if (sig.length == 0) revert InvalidSignatureMode(0);
Expand Down
56 changes: 46 additions & 10 deletions src/core/MandateEngine.sol
Original file line number Diff line number Diff line change
Expand Up @@ -300,16 +300,23 @@ abstract contract MandateEngine is IMandateEngine {
revert UnsupportedSigil(sigil);
}
$.actionSigils[aid][pid].add(sigil);
// Record the sigil in the mandate's reverse index so the MANDATE path can default-deny any agent
// call to it — it holds this account's policy config keyed by the account
// (see {MandateStorageLib.mandateSigils}).
_recordSigil($, pid, sigil);
IActionSigil(sigil)
.initializeWithMultiplexer(address(this), cid, a.sigils[j].initData);
}
}

// Register + initialize the per-execution outcome sigils and the per-mandate signature (ERC-1271)
// sigils, mirroring action-sigil wiring. Both share the per-mandate ConfigId (keyed by the mandate
// alone, not per-(target, selector)) — {IdLib.toMandateConfigId} is domain-separated from the
// per-action id, so the two categories never collide even when one address serves both roles.
ConfigId mcid = IdLib.toMandateConfigId(pid);
// sigils, mirroring action-sigil wiring. Each tier keys config by the mandate alone (not per-(target,
// selector)), but the OUTCOME tier and the SIGNATURE tier use DISTINCT, domain-separated ConfigIds
// ({IdLib.toOutcomeConfigId} vs {IdLib.toSignatureConfigId}) — and both are separated from the
// per-action id — so the three categories never collide, even for a single address that serves more
// than one tier (e.g. a future sigil implementing both {IOutcomeSigil} and {I1271Sigil}).
ConfigId ocid = IdLib.toOutcomeConfigId(pid);
ConfigId scid = IdLib.toSignatureConfigId(pid);
for (uint256 i; i < s.outcomeSigils.length; ++i) {
OutcomeSigilData memory o = s.outcomeSigils[i];
// Fail-closed at bind: an outcome sigil must advertise {IOutcomeSigil} via ERC-165. This rejects
Expand All @@ -320,7 +327,8 @@ abstract contract MandateEngine is IMandateEngine {
revert UnsupportedSigil(o.sigil);
}
$.outcomeSigils[pid].add(o.sigil);
IOutcomeSigil(o.sigil).initializeWithMultiplexer(address(this), mcid, o.initData);
_recordSigil($, pid, o.sigil);
IOutcomeSigil(o.sigil).initializeWithMultiplexer(address(this), ocid, o.initData);
}
for (uint256 i; i < s.signatureSigils.length; ++i) {
SignatureSigilData memory sg = s.signatureSigils[i];
Expand All @@ -333,7 +341,8 @@ abstract contract MandateEngine is IMandateEngine {
revert UnsupportedSigil(sg.sigil);
}
$.signatureSigils[pid].add(sg.sigil);
I1271Sigil(sg.sigil).initializeWithMultiplexer(address(this), mcid, sg.initData);
_recordSigil($, pid, sg.sigil);
I1271Sigil(sg.sigil).initializeWithMultiplexer(address(this), scid, sg.initData);
}

// CEI: enable LAST, after every sigil is registered + initialized. A sigil whose
Expand All @@ -343,11 +352,33 @@ abstract contract MandateEngine is IMandateEngine {
emit MandateBound(pid);
}

/// @dev Record `sigil` in the mandate's reverse index ({MandateStorageLib.mandateSigils}) so
/// {EnforcementLib.enforceAction} (which has the executing `pid` in hand) can default-deny any agent call
/// whose `to` is one of this mandate's OWN policy sigils — the confused-deputy guard: a sigil's config is
/// keyed by the account, and the baked-in engine makes the account its own multiplexer, so a
/// mandate-permitted call to a sigil could rewrite the caps that bound the agent. The set is the union of
/// the mandate's action / outcome / signature sigils; it is cleared on revoke/re-bind by
/// {_clearMandateSets}.
/// @param $ The mandate storage pointer.
/// @param pid The mandate registering the sigil.
/// @param sigil The sigil address to record.
function _recordSigil(
MandateStorageLib.MandateStorage storage $,
MandateId pid,
address sigil
)
private
{
$.mandateSigils[pid].add(sigil);
}

/// @dev Clear EVERY per-mandate enumerable set for `pid`: each action id and its sigil set, the outcome
/// sigils, and the signature (ERC-1271) sigils. Shared by {_revokeMandate} (kill) and
/// {_registerMandate} (re-bind replace), so neither can leave a stale sigil — in particular a stale
/// signature sigil that would keep a 1271 signing capability the new/empty config dropped. Does NOT
/// touch `enabled`, `signerConf`, or `enableNonce`; the callers manage those.
/// sigils, the signature (ERC-1271) sigils, and the {MandateStorageLib.mandateSigils} reverse index.
/// Shared by {_revokeMandate} (kill) and {_registerMandate} (re-bind replace), so neither can leave a
/// stale sigil — in particular a stale signature sigil that would keep a 1271 signing capability the
/// new/empty config dropped, or a stale `mandateSigils` entry that would keep default-denying a target a
/// re-bind no longer treats as a policy sigil. Does NOT touch `enabled`, `signerConf`, or `enableNonce`;
/// the callers manage those.
/// @param $ The mandate storage pointer.
/// @param pid The mandate whose sets to clear.
function _clearMandateSets(MandateStorageLib.MandateStorage storage $, MandateId pid) private {
Expand All @@ -371,6 +402,11 @@ abstract contract MandateEngine is IMandateEngine {
for (uint256 i; i < sgs.length; ++i) {
ss.remove(sgs[i]);
}
EnumerableSetLib.AddressSet storage ms = $.mandateSigils[pid];
address[] memory regd = ms.values();
for (uint256 i; i < regd.length; ++i) {
ms.remove(regd[i]);
}
}

/// @dev SAFE ERC-165 interface probe used to gate sigil registration per tier. Returns false (never
Expand Down
19 changes: 16 additions & 3 deletions src/lib/EnforcementLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IOutcomeSigil } from "@interfaces/IOutcomeSigil.sol";
// Libraries
import { IdLib } from "@lib/IdLib.sol";
import { MandateStorageLib } from "@lib/MandateStorageLib.sol";
import { RootStorageLib } from "@lib/RootStorageLib.sol";

// Types
import {
Expand Down Expand Up @@ -65,6 +66,18 @@ library EnforcementLib {
address to = to_ == address(0) ? address(this) : to_;
if (to == address(this)) return false; // no session self-calls (nested-exec bypass guard)
if (to == FALLBACK_TARGET_FLAG) return false; // the fallback sentinel is never a real call target
// The account's auth/policy state is SHARDED across external singletons — its ROOT validators
// ({RootStorageLib}) and its policy sigils — each keyed by the account address and mutated on
// `msg.sender == account`. During a MANDATE execution the account IS `msg.sender` to any non-self target,
// so a call to one of these (e.g. `ECDSAValidator.onInstall` to overwrite the ROOT signer, or a sigil's
// `initializeWithMultiplexer` to raise its own cap) is a self-call in disguise. Default-deny them, the
// sharded-state analogue of the `to == address(this)` guard above (smart-sessions gets this for free — its
// policy module is a SEPARATE address from the account, so the config key never collides; Daimon bakes the
// engine into the account, so it must deny explicitly). Validators are a small account-wide set (full ROOT-
// takeover coverage); sigils are checked against THIS mandate's own set (`pid` is in hand here) — closing
// the headline self-cap-raise. See {MandateStorageLib.mandateSigils} for the cross-mandate residual.
if (RootStorageLib.load().validators.contains(to)) return false; // an installed ROOT validator
if ($.mandateSigils[pid].contains(to)) return false; // one of THIS mandate's own policy sigils
ActionId aid = IdLib.toActionId(to, data.length >= 4 ? bytes4(data[0:4]) : bytes4(0));
address[] memory sigils = $.actionSigils[aid][pid].values();
if (sigils.length == 0) {
Expand Down Expand Up @@ -100,7 +113,7 @@ library EnforcementLib {
function runPreChecks(MandateStorageLib.MandateStorage storage $, MandateId pid) internal {
address[] memory sigils = $.outcomeSigils[pid].values();
if (sigils.length == 0) return;
ConfigId cid = IdLib.toMandateConfigId(pid);
ConfigId cid = IdLib.toOutcomeConfigId(pid);
for (uint256 i; i < sigils.length; ++i) {
IOutcomeSigil(sigils[i]).preCheck(cid, address(this));
}
Expand All @@ -124,7 +137,7 @@ library EnforcementLib {
{
address[] memory sigils = $.outcomeSigils[pid].values();
if (sigils.length == 0) return;
ConfigId cid = IdLib.toMandateConfigId(pid);
ConfigId cid = IdLib.toOutcomeConfigId(pid);
for (uint256 i; i < sigils.length; ++i) {
IOutcomeSigil(sigils[i]).postCheck(cid, address(this), mode, executionData);
}
Expand All @@ -150,7 +163,7 @@ library EnforcementLib {
returns (bool)
{
address[] memory sigils = $.signatureSigils[pid].values();
ConfigId cid = IdLib.toMandateConfigId(pid);
ConfigId cid = IdLib.toSignatureConfigId(pid);
for (uint256 i; i < sigils.length; ++i) {
if (I1271Sigil(sigils[i]).check1271(cid, address(this), gated) != VALIDATION_SUCCESS) {
return false;
Expand Down
19 changes: 18 additions & 1 deletion src/lib/IdLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ library IdLib {
}

/// @notice Derive the `ConfigId` a sigil stores/reads config under, for a (mandate, action).
/// @dev Cap and usage state is keyed per-mandate (`pid`): two mandates sharing the same session key
/// (same validator + initData but different `salt` → different `MandateId`) carry INDEPENDENT cap
/// state — their caps do NOT aggregate. A mandate builder issuing multiple mandates to one session
/// key must account for this: each mandate's budget is isolated, and the session key can spend up
/// to each mandate's cap independently.
/// @param pid The mandate id.
/// @param aid The action id.
/// @return The sigil config id.
Expand All @@ -44,8 +49,20 @@ library IdLib {
/// never collide with a per-action config id.
/// @param pid The mandate id.
/// @return The outcome-sigil config id.
function toMandateConfigId(MandateId pid) internal pure returns (ConfigId) {
function toOutcomeConfigId(MandateId pid) internal pure returns (ConfigId) {
bytes32 id = keccak256(abi.encodePacked("daimon.outcome", MandateId.unwrap(pid)));
return ConfigId.wrap(id);
}

/// @notice Derive the `ConfigId` a per-mandate SIGNATURE (ERC-1271) sigil stores/reads config under, for a
/// mandate. Domain-separated from {toOutcomeConfigId} (outcome) and {toConfigId} (per-action) by a
/// distinct constant tag, so the signature tier can never collide with the outcome tier — even for a
/// single address that implements both {IOutcomeSigil} and {I1271Sigil} and is placed in both slots
/// of one mandate.
/// @param pid The mandate id.
/// @return The signature-sigil config id.
function toSignatureConfigId(MandateId pid) internal pure returns (ConfigId) {
bytes32 id = keccak256(abi.encodePacked("daimon.signature", MandateId.unwrap(pid)));
return ConfigId.wrap(id);
}
}
26 changes: 22 additions & 4 deletions src/lib/MandateStorageLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,27 @@ library MandateStorageLib {
/// @param signatureSigils The set of per-mandate ERC-1271 (attestation) {I1271Sigil}s gating the mandate's
/// 1271 signing path — what typed data / content the agent may sign, and for which requesting
/// dApp. Empty => the mandate cannot 1271-sign (default-deny). Tracked per mandate so revoke can
/// fully clear them, and their config is keyed by the mandate alone ({IdLib.toMandateConfigId}),
/// like outcome sigils. APPENDED at the end of the struct (after `execNonceUsed`) per the ERC-7201
/// append-only rule — a new category must never be inserted mid-struct (that would shift every
/// following field's slot and corrupt an in-place upgrade).
/// fully clear them, and their config is keyed by the mandate alone ({IdLib.toSignatureConfigId}) —
/// domain-separated from the outcome tier's {IdLib.toOutcomeConfigId} so the two never collide.
/// APPENDED after `execNonceUsed` per the ERC-7201 append-only rule — a new category must never be
/// inserted mid-struct (that would shift every following field's slot and corrupt an in-place upgrade).
/// @param mandateSigils Per-mandate reverse index: the UNION of every sigil address (action / outcome /
/// signature) the mandate registered, keyed by {MandateId}. A sigil holds this account's policy config
/// keyed by `(configId, msg.sender == account)` — and because the engine is BAKED INTO the account, the
/// configuring "multiplexer" IS the account, so a mandate-permitted call to a sigil (also
/// `msg.sender == account`) hits the IDENTICAL config key and can rewrite the very caps/policy that
/// bound the agent (raise its own SpendSigil cap, etc.). This is the confused-deputy surface
/// smart-sessions avoids for free by keeping its module a SEPARATE address from the account
/// (multiplexer != account); Daimon cannot, so the MANDATE path ({EnforcementLib.enforceAction}) — which
/// already has the executing `pid` in hand — default-denies any call whose `to` is one of the mandate's
/// OWN sigils, the sigil-side analogue of the `to == address(this)` self-call guard (config state is
/// sharded across external sigil singletons, so denying `address(this)` alone is insufficient). RESIDUAL:
/// this blocks a mandate from rewriting ITS OWN caps (the headline confused-deputy path); it does NOT
/// block a broad mandate from rewriting a DIFFERENT mandate's sigil config — only reachable when one
/// compromised key holds both mandates (see the SpendSigil composition residual). ROOT takeover/brick is
/// covered separately and fully by the {RootStorageLib} validator-set check. Cleared on revoke/re-bind
/// alongside the other per-mandate sets. APPENDED at the end of the struct per the ERC-7201 append-only
/// rule.
/// @dev The Mandate's `validUntil` is the BIND-AUTHORIZATION DEADLINE — committed to the MANDATE_BIND digest
/// (so the ROOT signer authenticates it) and enforced ONCE at bind time by {MandateEngine}; it is NOT
/// persisted in this struct and NOT consulted at runtime. RUNTIME time bounds live in a {TimeFrameSigil}
Expand All @@ -67,6 +84,7 @@ library MandateStorageLib {
mapping(MandateId => EnumerableSetLib.AddressSet) outcomeSigils;
mapping(uint256 => bool) execNonceUsed;
mapping(MandateId => EnumerableSetLib.AddressSet) signatureSigils;
mapping(MandateId => EnumerableSetLib.AddressSet) mandateSigils;
}

/// @dev ERC-7201 namespaced storage slot for `daimon.storage.mandate.v1`. Derived as
Expand Down
5 changes: 4 additions & 1 deletion src/sigils/AttestationSigil/AttestationSigil.sol
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ import {
///
/// 2. STATELESS + VIEW. The 1271 path runs under solady's STATICCALL, so {check1271} is `view` and
/// writes no state — the gate is purely an allowlist membership test (no usage accrual; the digest
/// + session-key binding live in the engine).
/// + session-key binding live in the engine). Like {Eip3009Sigil}, there is intentionally NO
/// cumulative or frequency cap on this signing path: a compromised session key can produce unlimited
/// fresh valid attestations up to whatever the consuming protocol or funding level allows. Mitigate
/// by minimising per-mandate funding and using short bind windows — revoke promptly on compromise.
///
/// Configured per account by the {MandateEngine} at bind time via {initializeWithMultiplexer}; keyed by
/// `(configId, msg.sender, account)`. The engine is baked into the account, so `msg.sender == account`
Expand Down
6 changes: 5 additions & 1 deletion src/sigils/NativeValueLimitSigil/NativeValueLimitSigil.sol
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ contract NativeValueLimitSigil is IActionSigil {
/*·:⛧:·──────── CHECK ────────:⛧:·*/

/// @inheritdoc IActionSigil
/// @dev Permits iff `value <= limit`. Reads no `data` (no argument logic) — native value is the only constraint.
/// @dev Permits iff `value <= limit`. Reads no `data` (no argument logic) — native value is the only
/// constraint. This bound is PER-CALL, not per-execution: in a K-call batch each individual call
/// may carry up to `limit` wei, so the total native outflow in one execution can reach K·limit.
/// For a cumulative per-execution or per-period native ceiling, compose with a NATIVE-budget
/// {SpendSigil} (set `token` to the NATIVE sentinel `0xEeee…EEeE`).
function checkAction(
ConfigId id,
address account,
Expand Down
Loading
Loading