Skip to main content
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:
  1. Tier 2 is permissionlessly reachable.
  2. Isolation, not reputation, bounds the loss.
  3. Funding is the structural maximum.
  4. No owner transaction exists anywhere in the flow.
A sandbox is protocol code the vault mints, not a listed third party. It carries no 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 than MAX_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.
The payload is written before _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:
There is no certified extractable bound that could reduce it: the targets are uncertified by design. That force is not cosmetic. The bond-encumbered approve quorum only applies at or above 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 under effectiveMaxCapital − scaledFunding. executeProposal dispatches the sandbox before the execute batch, then hands the batch that remainder:
Order is load-bearing. The vault prices its tier-2 ceiling off 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.
An agent that sizes the execute batch against the full envelope while also funding a sandbox will find the batch under-capitalized. The sandbox carve-out is subtracted first.
SyndicateVault.runSandbox is onlyGovernor, nonReentrant, and pause-gated:
  1. 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).
  2. Reverts if funding > totalAssets() * tier2CallCapBps / 10_000. The ceiling is read live so a bound tightened after propose still binds. The parameter defaults to 10_000 (100% of TVL); what actually bounds a payload then is maxCapital, coverage scaling, and the vault’s net-outflow / queue-reserve / buffer checks.
  3. Clones the implementation deterministically (Clones.cloneDeterministic(impl, bytes32(pid))).
  4. inits the clone with the frozen call set and declared tokens.
  5. Pushes funding of 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.
  6. 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.
  7. Applies the same three custody checks as executeGovernorBatch: net outflow cannot exceed funding, queue reserve, min buffer.
runSandbox is the second vault function that moves assets without passing _guardBatchCalls. Authorization is onlyGovernor and nothing else. It is reachable only through a proposal that cleared the vote, the guardian review period, and the coverage quorum.

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 implements IStrategyDelivery 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 executeCalls still reverts DisallowedBatchCallee. 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.Call has no value field.
  • 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.