Specification · v1
Policy Interface
Multi-Hook is a meta-hook. The MetaHook program doesn't decide whether a transfer is allowed — it delegates to N child policy programs and aggregates their verdicts. Anyone can ship a child policy. This is the public spec for that interface.
This is what makes Multi-Hook a public good vs a one-off demo: you can fork a child policy template, swap the rule, deploy, and have an existing meta-hook compose it without touching the hook code.
TL;DR
Your child policy is a Solana program with a single
check_transfer instruction. MetaHook calls it via CPI for every
transfer. Returning Ok means "approve from my perspective";
returning Err(YourError) with the revert string
"policy.<your_name>.fail: <human_reason>"
means "block".
The MetaHook AND-aggregates verdicts in V1. V2 adds OR / weighted modes.
Required instruction: check_transfer
Signature
pub fn check_transfer(
ctx: Context<CheckTransfer>,
amount: u64,
) -> Result<()> {
// Your rule here. Read source/dest/amount, decide, return Ok or Err.
}
Account context
The MetaHook hands your program these accounts in this order via CPI:
| Index | Account | Meaning | Mut/Sign |
|---|---|---|---|
| 0 | source | Source ATA (TokenAccount, Token-2022 program) | read |
| 1 | mint | The Token-2022 mint being transferred | read |
| 2 | destination | Destination ATA (TokenAccount, Token-2022 program) | read |
| 3 | owner | Source ATA's owner (who initiated the transfer) | read |
| 4 | your_pda | YOUR policy state PDA | read or read-write |
You are free to add MORE accounts after index 4 — but you must register them in your policy's published "extra accounts" list so MetaHook integrators know to forward them when they wire your policy into their meta-hook config.
Returning a verdict
Approve: Ok(()). The MetaHook proceeds to the next policy in the chain.
Block: Return an AnchorError whose msg follows the format:
policy.<your_policy_name>.fail: <human-readable reason>
Examples used by the V1 reference policies:
policy.allowlist.fail: destination not on allowlistpolicy.sanctions.fail: destination is sanctionedpolicy.sns_allowlist.fail: domain not on allowlist
The policy.<name>.fail: prefix is the load-bearing part. MetaHook clients
parse this to surface the failure reason in UI without needing to decode
your program's error enum. Pick a stable <name> and never change it.
PDA convention
Every child policy's state PDA SHOULD use this seed pattern so MetaHook UIs can derive it without per-policy config:
seeds = [b"<your-policy-name>", authority.key().as_ref()],
Examples:
[b"allowlist", authority.key().as_ref()][b"ofac-list", authority.key().as_ref()][b"sns_allowlist", authority.key().as_ref()]
Authority instructions
Most policies need admin instructions to mutate state (add/remove allowed addresses, etc.). Convention:
pub fn initialize(ctx: Context<Init>) -> Result<()> { ... }
pub fn add_<thing>(ctx: Context<Authed>, item: Pubkey) -> Result<()> { ... }
pub fn remove_<thing>(ctx: Context<Authed>, item: Pubkey) -> Result<()> { ... }
Gate them with Anchor's has_one = authority constraint so only the policy
authority can mutate state.
Wiring into a MetaHook deployment
Once your policy is deployed, an issuer wires it into their meta-hook by:
- Including your program ID in the MetaHook's
ExtraAccountMetaListfor their mint - Including the derived PDA for their authority+your-program in the same list
- Marking accounts as writable if your policy needs to mutate state per-transfer (most don't — only the reentrancy guard is writable in V1)
Reference policy implementations
Three child policies live in the repo as worked examples:
| Path | Program ID (devnet) | What it does |
|---|---|---|
programs/policy-allowlist/ |
GJHx…ehyn ↗ | Approve only if destination owner is in allowlist set |
programs/policy-sanctions-ofac/ |
5iz6…JkWt ↗ | Reject if destination owner is in sanctioned set |
programs/policy-sns-allowlist/ |
4J57…FuTo ↗ | Approve only if destination owner controls one of the authorised .sol domains via Solana Name Service |
Compute budget
Each child policy CPI costs ~5-20K CU. The current 2-policy V1 demo lands at 33,346 CU (16% of the 200K Token-2022 transfer budget). You have headroom for ~6 more policies before bumping into limits. Beyond that, the right move is per-policy gating (skip non-applicable policies) rather than optimizing the policies themselves.
Composability gotchas
- CPI depth ≤ 4. Token-2022 invokes MetaHook (depth 2) which CPIs into your policy (depth 3). External CPIs from your policy have 1 level of headroom.
- Reentrancy. MetaHook holds a reentrancy-guard PDA marked writable. You don't need your own.
- Confidential transfers. Token-2022's confidential-transfer extension is incompatible with transfer hooks upstream. You can't compose a confidential-aware policy until that lands.
Submission checklist
- Program ID is stable (don't redeploy after issuers depend on it)
- Error string follows
policy.<name>.fail: <reason>format - PDA seeds documented and follow
[b"<name>", authority]convention - Anchor IDL JSON published (so MetaHook UIs can decode + display)
- Compute budget measured (reported in your README)
- CPI depth disclosed if your policy itself makes CPIs
- Test against a MetaHook fork wiring your policy as the third position
Policy ideas worth shipping
These are real RWA compliance needs that don't yet have child policies:
- policy-balance-cap — cap per-recipient amount in a rolling time window
- policy-jurisdiction — allow/deny based on geo + KYC level attestation
- policy-time-window — only allow transfers during issuer-defined hours
- policy-velocity — block if total transferred-out in last N minutes exceeds threshold
- policy-fee-skim — require a separate fee transfer to a recipient as part of the same tx
- policy-attestation-vrfd — require a Verigate / EAS / Sismo attestation be presented in the transaction
If you ship one, open a PR adding it to this doc's reference table.