MetaHookdevnet Open the demo →

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:

IndexAccountMeaningMut/Sign
0sourceSource ATA (TokenAccount, Token-2022 program)read
1mintThe Token-2022 mint being transferredread
2destinationDestination ATA (TokenAccount, Token-2022 program)read
3ownerSource ATA's owner (who initiated the transfer)read
4your_pdaYOUR policy state PDAread 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:

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:

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:

  1. Including your program ID in the MetaHook's ExtraAccountMetaList for their mint
  2. Including the derived PDA for their authority+your-program in the same list
  3. 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:

PathProgram 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

  1. 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.
  2. Reentrancy. MetaHook holds a reentrancy-guard PDA marked writable. You don't need your own.
  3. 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

Policy ideas worth shipping

These are real RWA compliance needs that don't yet have child policies:

If you ship one, open a PR adding it to this doc's reference table.