StrategyFactory, and propose it like any other strategy. Guardians judge a hand-written strategy by what sherwood guardian review finds in its code and in every contract it reaches, not by where it came from. This guide walks through the whole path. Its example is a strategy that buys an NFT on OpenSea’s Seaport with the vault’s USDG and sells it back before settlement.
This needs CLI 0.102.0 or later: that release clears a hand-written strategy that passes the review (
STRATEGY_CUSTOM). Older releases Block every strategy that is not a template clone. Nothing here is investment advice. An NFT position can lose all its value, and the vault books whatever the strategy fails to sell as a loss.sherwoodagent/sherwood-protocol at the v1-deploy branch (commit a91fae2f), as file:line.
What the protocol requires of a strategy
A strategy is any contract with theIStrategy shape (src/interfaces/IStrategy.sol:23-53): initialize(vault, proposer, data), execute(), settle(), updateParams(bytes), vault(), proposer(), executed() and name(). The vault calls it only through a governor batch, and the protocol enforces four things around it:
- Every batch target is the vault’s asset or a registered strategy.
SyndicateVault._guardBatchCalls(src/SyndicateVault.sol:454-465) revertsNotARegisteredStrategyfor anything else, andproposemirrors the rule (src/SyndicateGovernor.sol:1136-1143). Your strategy can call any contract it likes, but the vault’s own calls only reach the asset and your strategy. proposeneeds a registered strategy and non-empty batches. It revertsStrategyNotRegistered(src/SyndicateGovernor.sol:341),EmptyExecuteCallsorEmptySettlementCalls(:347-348).- Settlement waits for
executed()to turn false.settleProposalruns the settle batch, then revertsStrategyNotSettledif the strategy still answersexecuted() == true(src/SyndicateGovernor.sol:535-538). - Only the deposit asset counts. The vault’s
totalAssets()is its balance of the asset (src/SyndicateVault.sol:722-728), and settlement measures P&L as that balance minus the snapshot taken at execute (src/SyndicateGovernor.sol:1525-1530). An NFT, or any other token, left anywhere at settlement is worth zero to the vault.
settleProposal reverts SettlePriceBelowFloor when the vault’s price per share has fallen more than maxDrawdownBps since execute (src/SyndicateGovernor.sol:545-565, called at :540). An NFT that does not sell makes the vault’s price per share fall by what it cost. If that cost is larger than the drawdown you declared, the proposal cannot settle, and the owner has to unwind it through an emergency settle. Size the position so that losing all of it stays inside --max-drawdown-bps.
Inherit BaseStrategy
src/strategies/BaseStrategy.sol implements the lifecycle so you only write the hooks:
execute() and settle() take no arguments (IStrategy.sol:32,36). Every order your strategy fills has to be fixed before the call: in initialize data (before the proposal exists), or, after execute, by the proposer through updateParams (only while Executed, BaseStrategy.sol:167-168). Also note that rescueTo moves ERC-20 balances only. It cannot return an NFT, and a vault holding an NFT would not count it anyway.
What settle must do: get back to the deposit asset
For a token strategy, settle sells the position back into the asset. For an NFT strategy, the NFT has to be sold for the deposit asset inside the strategy before or during settle(), because nothing values it for the vault. There are two ways to do this:
- Accept an offer at settle. After execute, find a live Seaport offer (a bid in USDG for your token or its collection), and store it with
updateParams._settle()then fulfills that offer, which sells the NFT to the bidder for USDG, and pushes every USDG it holds to the vault. - List it while the proposal runs. The strategy lists the NFT for USDG (as a Seaport contract offerer, or by validating an order on-chain), and a buyer fills it before the strategy’s duration ends.
_settle()pushes the proceeds.
_settle() must not revert when there is no sale. If it reverts, the proposal cannot settle. It should push whatever USDG the strategy holds and return. The NFT then stays in the strategy and the vault books the loss, inside the drawdown you sized for.
Step 1: Write the strategy
The sketch below shows the shape. It is not audited code. Build it against Seaport’s own types (theseaport-types package) and test it on a fork before you deploy.
- Bind the asset.
_initializerefuses a vault whoseasset()is not the strategy’s settlement token, so_pushAllToVault(asset)returns what the vault counts. - Make every venue visible. Hold Seaport, the collection and the asset as public immutables or zero-argument getters. The guardian review finds the contracts a strategy talks to through its embedded addresses and its getters. A contract it can only discover from calldata or a mapping at call time is invisible to it (the walk’s limits are described in the CLI’s
lib/guardian-hardening.tsheader). - Keep
updateParamsnarrow. It is the proposer’s lever while the position is open. Let it set a sell order and nothing that redirects funds: guardians read it for exactly that. - No upgradeability.
isRegisteredStrategycompares the code hash recorded at registration (src/StrategyFactory.sol:107-111), so changing a plain contract’s code de-registers it. A proxy keeps its code hash while its implementation changes, and the review Blocks a proxy whose upgrade authority is an account with no timelock (UPGRADEABLE_EOA_ADMIN). Deploy plain, immutable code. - The listing must stay fillable.
execute()fills the listing you fixed at initialize, after the shareholder vote and the guardian review (see Governance timing). ItsendTimehas to outlast the execution window. Guardians simulate execute during review, so a listing that has been filled or has expired by then fails simulation (SIMULATION_FAILED), and the review Blocks.
Seaport and OpenSea on Robinhood Chain
Neither Seaport nor OpenSea is in Sherwood’s address book. On 2026-11-03 Blockscout showed Seaport 1.6 at its canonical address on Robinhood Chain, with verified source (partial match, compiler 0.8.24):ConduitController (0x00000000F9490004C11Cca813f2Aa1C9bE6f4dDb) has no code on 4663, so conduits are not available: use conduit key zero and approve Seaport itself, as the sketch does. Re-check both addresses on Blockscout before you deploy. Whether OpenSea’s marketplace lists Robinhood Chain orders, and which collection you trade, is yours to verify. The collection contract, and any fee recipient the orders pay that is a contract, must be verified on Blockscout too, or the review Blocks (below).
Step 2: Deploy and initialize it
Deploy the contract with Foundry (forge create, or a forge script), then call initialize(vault, proposer, data):
vaultis your vault.proposeris the agent that will callpropose. WhileownerOnlyProposalsis on, that is the vault owner registered as an agent of its own vault (see Launch posture).datais your_initializepayload.
initialize is open until someone calls it (BaseStrategy.sol:100-101). Deploy and initialize in one transaction (a small deployer contract, or a CREATE2 factory that calls initialize right after deploying), or read vault() and proposer() back before you go on. A strategy someone else initialized is bound to their vault. It cannot settle into yours, and the guardian review Blocks it (STRATEGY_SETTLE_NO_ASSET). Redeploy if that happens.
Step 3: Verify the source on Blockscout
Guardians Block any contract without verified source (UNVERIFIED_CONTRACT), and the Arena only proposes verified strategies. Verify the strategy, with the exact compiler settings and constructor arguments you deployed it with, through the explorer’s Verify & publish page on robinhoodchain.blockscout.com, or with forge verify-contract --verifier blockscout. A partial match counts as verified. Check that the explorer shows the source and ABI before you register.
Step 4: Register it with the StrategyFactory
StrategyFactory.registerStrategy(strategy) is permissionless (src/StrategyFactory.sol:97-105). It refuses a codeless address, or one that does not answer vault(), proposer() and executed() with exactly one word (NotAStrategy). It records the code hash, so a later code change de-registers the strategy (:107-111). The CLI has no command for this call, so send it from your wallet. The factory address comes from chains/4663.json (STRATEGY_FACTORY):
sherwood proposal show labels such a strategy Not cloned from any template: this strategy is hand-written code …, because the factory’s cloneTemplate(strategy) is zero for it.
Step 5: Propose it
A custom proposal has two batches:- Execute: the asset’s
approveto your strategy, thenstrategy.execute(). - Settle:
strategy.settle().
src/SyndicateVault.sol:454-456).
0x61461954 is execute() and 0x11da60b4 is settle() (cast sig "execute()"). Build the approve with cast calldata "approve(address,uint256)" <strategy> <maxPrice>.
With the CLI
sherwood strategy propose only deploys template clones; proposal create takes any registered strategy. The proposer bond is pulled in WOOD.
Each batch call is priced by TierRegistry.tierOf(target, selector) (src/SyndicateGovernor.sol:1289). A hand-written strategy is not certified there, so its execute() call is tier 2 (full notional) for both the bond and the guardian coverage the proposal needs. A tier-2 call’s cap may not exceed the governor’s tier2CallCapBps() share of NAV, or propose reverts Tier2CallCapExceedsCeiling (:1249-1251, :1292). Read your governor’s value with cast call <governor> "tier2CallCapBps()(uint256)" before you pick --max-capital. See Proposer bond and Tiers and coverage.
From the Arena tool layer
The Arena catalog listscustom as an encodable template (sherwood-arena packages/sherwood/src/catalog.ts:67-77). Its params are {"template":"custom","legs":[{"asset":"<deployed strategy address>","weight_bps":10000}],"size_bps":…,"duration_hours":…,"max_drawdown_bps":…} (:30-32). The preflight (packages/sherwood/src/preflight.ts:471-507) refuses a strategy that is not registered (STRATEGY_NOT_REGISTERED), not verified on Blockscout (STRATEGY_NOT_VERIFIED), or bound to another vault (STRATEGY_VAULT_MISMATCH).
Step 6: What sherwood guardian review checks
Run it yourself before guardians do; it is read-only:
SHERWOOD_EXPLORER_API_KEY (a Blockscout API key) first: the public explorer refuses scripts, and without the key every contract outside the address book reads as EXPLORER_UNAVAILABLE. List Arena vaults in SHERWOOD_ARENA_VAULTS.
The review starts from your strategy, every batch target, the approve spender, and what the execute simulation moved value through. For the example, the simulation moves USDG from the vault to the strategy and on to the seller and any fee recipients, and the NFT moves to the strategy. From each of those contracts it follows proxy slots, embedded addresses and the verified ABI’s address getters, up to 5 levels and 64 contracts. Book addresses (USDG, the vault, the governor) are trusted and not descended into.
Code by code, for the NFT strategy:
Any critical is a Block, warnings alone an abstain, and a clean walk an Approve. A cleared hand-written strategy is approved with the code
STRATEGY_CUSTOM, so the vote’s code and reason, and the Arena site, show it as hand-written.
The ERC-20 checks (
TOKEN_MINT_POWER, TOKEN_BLACKLIST_POWER, FRESH_DEPLOY_NO_HISTORY) look for the ERC-20 shape (transfer, balanceOf, totalSupply). An ERC-721 collection does not match it, so they do not fire for the NFT. The review checks the collection’s source and upgrade authority, not its liquidity or who can mint more of it. Guardians read those themselves. A collection with no bids is a position you may not be able to sell.settle() returns USDG, because settle cannot run before execute. Guardians read _settle() and _updateParams() in the verified source for that.
A clean run looks like this (addresses shortened; the counts depend on what the walk reaches):
proposal status shows guardians’ reasons and what to change before you re-propose. Then fix the contract or the batches, redeploy if the code changed (a code change de-registers it), register, verify and propose again.
Checklist
- Inherits
BaseStrategy;_initializechecks the vault’sasset(). - Seaport, the collection and the asset are public immutables or getters.
-
_settle()sells into the deposit asset when it can, never reverts for lack of a sale, and pushes all of the asset to the vault. -
updateParamscan set a sell order and nothing that moves funds elsewhere. - Plain, non-upgradeable code; deployed and initialized for your vault and proposer.
- Source verified on Blockscout; collection and contract fee recipients verified too.
-
isRegisteredStrategyreturns true. - Execute batch:
USDG.approve(strategy, maxPrice),strategy.execute(). Settle batch:strategy.settle(). -
--max-drawdown-bpscovers losing the whole position; on an Arena vault, size at most 5,000 bps of NAV. -
sherwood guardian reviewreturnsAPPROVE STRATEGY_CUSTOM.