Smart Facilitator scripts for the Grunt Protocol. The facilitator contracts in src/ are permissioned automation scripts that hold FACILITATOR_ROLE on a Grunt Facility and execute phases of an intent's deposit lifecycle atomically: either every step succeeds, or the whole call reverts and can be retried.
Two facilitators exist today, covering the two ends of a fund DEPOSIT order's life:
| Contract | Phase | Sequence |
|---|---|---|
CommitDeposit |
Order creation | pull bridge funds → create DEPOSIT order → commit |
MorphoAllocator |
Order maturation | unlock → rebalance Morpho Vault V2 → deposit collateral & borrow |
src/checks/ additionally holds permissionless, read-only guard contracts meant to be called as the first step of a batch transaction to make the rest of the batch revert on stale state; they hold no roles and no storage.
run(intentId, pullAmount, commitAmount, minSharesOut):
Facility.pull(intentId, pullAmount)— pullpullAmountof the bridge-loan asset from the Request.Facility.create(intentId, commitAmount, minSharesOut, Mode.DEPOSIT)— create the fund order.commitAmountmay intentionally differ frompullAmount(the executor controls the split, e.g. for fees or amounts already held).Facility.commit(intentId)— advance the order, then assert the fund reports it asPROCESSING(revert withUnexpectedOrderStateotherwise).
minSharesOut is the slippage guard on the fund side: the minimum shares the DEPOSIT order must mint. Success emits DepositCommitted(intentId, pullAmount, commitAmount, order) with the full order struct.
run(intentId, deallocations, allocateAdapter, allocateMarket, depositAmount, borrowAmount, useTarget, minSharesUnlocked):
-
Select the PositionManager.
useTargetpicks the intent's target asset (true) or deposit asset (false); it must be a position manager (TargetNotPositionManagerotherwise). Itsassets()gives the collateral asset used for the balance audit. -
Unlock. Snapshot the intent's collateral balance, call
Facility.unlock(intentId), assert the order isENDED. Balances are read fromFacility.intentBalancesper intent — neverbalanceOf(facility), which aggregates across all intents. The balance must not decrease (UnlockBalanceDecreased), and the credited delta must satisfyminSharesUnlocked(SlippageExceeded). -
Rebalance the Morpho Vault V2. Each
Deallocationentry contributesamountto a gathered total:adapter == address(0)— the amount comes from the vault's idle liquidity; no call, no checks.- otherwise —
Vault.deallocate(adapter, marketParams, amount)withdraws from that Morpho Blue market, then the market's post-withdrawal utilisation (totalBorrow / totalSupply, WAD) must be<= maxUtilisation(MaxUtilisationExceeded). The check runs after the withdrawal so it reflects the new totals plus accrued interest. An empty market counts as 0 utilisation with no borrows, infinite with any borrow.
The gathered total is then allocated into the single destination market via
Vault.allocate(allocateAdapter, allocateMarket, total)— skipped whenallocateAdapter == address(0)or the total is zero, in which case the gathered liquidity stays idle in the vault. -
Deposit & borrow.
Facility.depositManager(intentId, depositAmount, borrowAmount, useTarget)locks collateral and takes on debt through the selected PositionManager.depositAmountis the executor's choice and is independent of the measured unlocked amount (it may be less, leaving collateral idle on the intent).
Success emits Allocated(intentId, unlocked, allocateAdapter, gatheredTotal, borrowAmount).
runInKind(intentId, deallocations, allocateAdapter, allocateMarket, depositAmount, borrowAmount, useTarget) serves in-kind deposit intents, where the depositor supplied the PositionManager's collateral directly to the intent and there is no fund order to unlock. It runs steps 1, 3 and 4 above (PositionManager check, vault rebalance, depositManager with the given depositAmount) and skips the unlock, order-state and slippage checks. It emits the same Allocated event with unlocked = depositAmount.
requireState(facility, checks) — a stateless, read-only guard that reverts unless every intent in checks matches the caller's expectations:
- Deposit cap must equal
expectedCapexactly (StaleCapotherwise) — cap changes are precisely the state transitions the guard exists to catch. - Total supply must lie within the inclusive
[minSupply, maxSupply]range (SupplyOutOfRangeotherwise), so benign concurrent deposits/withdrawals inside the tolerated band do not fail the batch. PassminSupply == maxSupplyfor an exact check; an inverted range reverts withInvalidSupplyRange.
Usage: this contract must be called as the FIRST transaction of a batch (e.g. an EIP-7702 batch) executed in the mode where any failing call reverts the whole batch. The batch calls this read-only function first; if the on-chain cap/supply state no longer matches what was expected when the batch was signed, the guard reverts and the rest of the batch reverts with it, so no state-changing call executes against stale state.
The guard is unpermissioned (anyone may call it with any arguments), holds no storage, and takes the Facility as a call parameter, so one deployment serves any Facility.
script/DeployDepositCapStateGuard.s.sol deploys the guard through the canonical CREATE2 factory (0x4e59b44847b379578588920cA78FbF26c0B4956C) with a salt ground for a 0x3fa77e57 ("3F ATTEST") vanity prefix, giving the same address on every chain:
DepositCapStateGuard: 0x3Fa77E57baC28940621d1aaE1a136058c4064623
The script restores the metadata digest used by the already deployed init code, verifies its full hash, and checks that code exists at this address after deployment. This keeps the address stable when only compiler metadata changes. Executable-code changes require a new deployment address and updated integrations.
- Owner / executor. Both contracts use Solady
OwnableRoles. The owner (set at initialization) grants and revokesEXECUTOR_ROLE; only executors can callrun(andMorphoAllocator.runInKind). Executors are trusted operators: parameters like the pull/commit split,depositAmount, and the rebalance shape are their judgment calls — the contracts enforce invariants (order states, slippage minima, utilisation caps, balance audits), not strategy. - External grants. The Facility owner must grant each script
FACILITATOR_ROLE; the Vault V2 curator must additionally whitelistMorphoAllocatoras an allocator. - Atomicity. Every
runis all-or-nothing: any failed assertion reverts the entire sequence, leaving the protocol in its prior state so the executor can adjust parameters and retry. - Deployment. Both contracts are proxy-ready: Solady
Initializablewith locked implementations and ERC-7201 namespaced storage. Initialization validates the owner is non-zero and that the Facility (and Morpho Vault) addresses have code.