proposeWithSandbox is the only permissionless path to a tier-2 target. The payload’s targets are never allowlisted and never certified. Four facts make that path sound:
- Tier 2 is permissionlessly reachable.
- Isolation, not reputation, bounds the loss.
- Funding is the structural maximum.
- No owner transaction exists anywhere in the flow.
TierRegistry entry and is not a StrategyFactory template. Blast radius is set by the funding, not by the callee’s reputation. The review that remains — guardians, against slashable stake, on a payload frozen at propose — is the one that was pricing the risk.
Why the batch cannot reach tier 2
TierRegistry.tierOf reports (2, 10_000) — arbitrary calldata at full notional — for any uncertified (target, selector). That is the default, not a grant.
A governor batch cannot use it. SyndicateVault._guardBatchCalls refuses any callee the registry owner has not allowlisted, and that gate is tier-blind. An uncertified target is not expensive; it is unreachable. Every new venue named in a batch used to cost an owner transaction.
The gate cannot simply be dropped. A batch runs under delegatecall, so a sub-call arrives as the vault: it can spend standing allowances, move vault-held position tokens, and satisfy any msg.sender == vault gate. Authorization-shaped calldata (approve(attacker, max), ERC-777 authorizeOperator, ERC-721 setApprovalForAll, any unenumerated router’s approval shape) moves zero assets, meters zero, prices zero coverage, and drains in a later transaction. No per-call cap can bound that, because the loss does not land in the metered transaction.
Isolation, not reputation
Executing one hop further out removes the premise.CallSandbox is an ERC-1167 clone the vault mints at execute, salted on the proposal id so the address is derivable before anyone votes. It holds nothing but the vault asset it was funded with.
A target called from the clone sees msg.sender == address(sandbox): no vault allowance to spend, no vault-held position token to move, no msg.sender == vault gate satisfied anywhere. Isolation, not reputation, bounds the loss.
Funding is the structural maximum. The most a hostile call set can cost is the balance this contract was handed — which is exactly the figure full-notional tier-2 coverage already charged for. That is why no owner attests a sandbox target.
No owner transaction exists anywhere in the flow. There is no setAdapterAllowed, setClassAllowed, certify, or setTemplateApproval step for a sandbox target. _guardBatchCalls is not consulted and is not modified: the sandbox path is a separate onlyGovernor entry point, not an exemption inside the batch guard.
Permissionless targets, not permissionless proposing.
registerAgent stays onlyOwner; only a registered agent can call proposeWithSandbox. What becomes permissionless is the target, not who may propose.Payload
proposeWithSandbox takes the same arguments as propose, plus a SandboxPayload. Every other gate — agent registration, the open-proposal lock, the envelope, the batch caps, the proposer bond — is the shared _propose body. The two entry points cannot diverge on what a valid proposal is.
Call count is a separate ≤32 bound (
MAX_SANDBOX_CALLS). The token list is a separate ≤16 dedup bound (MAX_SANDBOX_TOKENS). Neither is the batch’s MAX_CALLS_PER_PROPOSAL (64). Validating against the batch figure would accept a payload that reverts InvalidCallSet at execute, with the bond locked and no way to amend.
sandboxPayload(proposalId) returns the stored payload for the whole review period. Guardians underwrite this call set; a mutable payload would let a proposal be covered against one call set and executed against another.
Propose-time refusals that would otherwise only fail at execute (with the bond locked and no way to amend):
- Empty call set, more than
MAX_SANDBOX_CALLS(32) calls, more thanMAX_SANDBOX_TOKENS(16) declared tokens, a zero target, a duplicate declared token, or zero funding. - The vault’s
sandboxImplementation()is zero. Binding is factory-only and set-once, so a vault created without one can never be given one. funding > envelope.maxCapital.
_propose mints the id, because coverage pricing inside _snapshotTierAndGate reads _sandboxFunding[id]. A write afterwards would price the sandbox at zero.
Coverage
A sandbox proposal is unconditionally tier 2.funding is added to requiredCoverage at full notional:
quorumTierThreshold. A payload that rode along at tier 0 would be arbitrary calldata reaching an arbitrary target with no identified underwriter on the hook.
The proposer bond therefore quotes against the combined figure — batch extractable plus sandbox funding. See Coverage and underwriting for how a shortfall then scales effectiveMaxCapital.
Execute
The execute batch runs undereffectiveMaxCapital − scaledFunding. executeProposal dispatches the sandbox before the execute batch, then hands the batch that remainder:
totalAssets(), which during the Executed window counts only idle float. After the batch has deployed capital that reading is near zero, so a sandbox dispatched afterwards would revert for every non-trivial funding.
scaledFunding is funding scaled by the same raised-over-required ratio that produced effectiveMaxCapital. Subtracting it is what stops the envelope being spent twice. Without it a proposal could fund a sandbox to its full maxCapital and then deploy that same envelope again through the batch. A payload whose coverage floors to nothing runs nothing.
SyndicateVault.runSandbox is onlyGovernor, nonReentrant, and pause-gated:
- Reverts if the implementation is unset, or if this proposal already minted a sandbox (one per proposal: a second mint would orphan the first from
collectResidue). - Reverts if
funding > totalAssets() * tier2CallCapBps / 10_000. The ceiling is read live so a bound tightened after propose still binds. The parameter defaults to10_000(100% of TVL); what actually bounds a payload then ismaxCapital, coverage scaling, and the vault’s net-outflow / queue-reserve / buffer checks. - Clones the implementation deterministically (
Clones.cloneDeterministic(impl, bytes32(pid))). inits the clone with the frozen call set and declared tokens.- Pushes
fundingof the vault asset. Never approve-and-pull: an allowance would be a standing authorization whose size proposer calldata could choose — the “authorization meters zero” failure, reproduced at the funding step. Nothing here ever grants the sandbox an allowance against the vault. - Calls
run(). One-shot. A denied target or any reverting call reverts the whole run — skipping a call would execute a subset of what guardians approved. - Applies the same three custody checks as
executeGovernorBatch: net outflow cannot exceedfunding, queue reserve, min buffer.
Accounting denylist
Before dispatch,CallSandbox.run refuses stored targets equal to the vault, withdrawal queue, governor, tier registry, exposure ledger, sWOOD, or WOOD. Addresses are resolved live because any of them can be re-pointed after the clone was minted.
This is defense in depth, not the load-bearing guard. It screens stored targets only; a payload can still name a proposer-deployed forwarder. What actually holds is upstream: runSandbox is nonReentrant, so no route back into the vault survives while a run is in flight, and every function on those contracts is gated to its own privileged caller.
The adversary is protocol accounting, not fund theft — the funded balance already bounds theft. A sandbox holding vault capital could otherwise deposit it back to mint shares while deposits are locked, or touch the queue’s stamp and reserve counters.
Residue
The clone implementsIStrategyDelivery so the vault’s residue machinery reaches it the same way it reaches a settled strategy. At onProposalSettled the vault records the sandbox against the settling pid, bounded by the same envelope funding was carved from.
sweep() is vault-only. A bare EOA driving it would land the whole balance in the vault outside the measurement collectResidue splits with the exiting cohort. The permissionless property is not lost, only routed: SyndicateVault.collectResidue is itself permissionless and reaches sweep through the vault, so capital comes home with no registry standing and no owner action.
A sandbox holds no registry entry, so there is nothing to demote and nothing to wedge. After a class conviction that would revert every batch-routed settlement path DisallowedBatchCallee, collectResidue still works.
Declared-token recovery is best-effort, per token, on a fairly divided gas budget. A hostile token cannot brick the mint path by reverting or by burning the probe’s gas. A token whose transfer keeps failing is abandoned only after ABANDON_DELAY (2 days) from the first observed failure, and any later success clears the mark. Writing a token off on a single failure would let anyone pick the moment on a permissionless function.
Declared-token discipline
MAX_SANDBOX_TOKENS is a ≤16 dedup bound: more than 16 declared tokens reverts, and duplicates revert at propose. Call count is a separate ≤32 bound (MAX_SANDBOX_CALLS). The two ceilings are not interchangeable.
Both directions of a wrong declaration cost the proposer.
Under-declare and the leftover is stranded permanently in the clone. hasUnvaluedResidue() only probes the declared set, and sweep() only recovers those tokens (plus the vault asset). An undeclared balance is never counted as vault value and never comes home — the proposer’s own capital.
Over-declare and a declared token that still sits in the clone keeps hasUnvaluedResidue() true. The vault then holds deposits shut until residue collection clears the flag, for up to UNVALUED_MAX_LOCK (7 days). After that window depositsLocked() releases even if the unvalued mark remains — a bounded lock is better than a permanent brick.
Under-declare strands the proposer’s own capital. Over-declare locks the vault’s mint side.
What it is not
- Not a relaxation of the batch guard. Naming an uncertified target in
executeCallsstill revertsDisallowedBatchCallee. The sandbox is a different door. - Not a way to get a bounded tier for proposer-authored calldata. A sandbox payload forces tier 2 permanently.
- Not native-ETH capable.
ICallSandbox.Callhas novaluefield. - Not owner-repairable per venue. Replacing the sandbox implementation is a protocol upgrade (the vault-side binding is set-once). That is the correct weight for the code that runs arbitrary calldata against LP capital, and it is not a gate on any proposal or venue.