Programmable On-Chain Policy Engine for Solana
Define what your Wallet/Escrow can do as data, not code. Update the policy without redeploying.
Devnet Program · How It Works · Quick Start
solana anchor rust typescript escrow vault defi policy-engine firewall allowlist rule-engine web3 security governance
- The Problem
- The Solution
- Use Cases
- Features
- Architecture
- How It Works
- Quick Start
- Project Structure
- Program Instructions
- Constraint Reference
- Security Model
- Tech Stack
Solana has no native policy layer between "a transaction is signed" and "the action executes." This shows up two ways:
Wallets are unconstrained. A normal keypair wallet has zero gatekeeping — whatever you sign just goes through. Phishing transaction? Sent. Malicious dApp draining your stables? Sent. Wrong recipient at 3 AM? Sent. The seed phrase is the only line of defense, and it is a dangerously sharp tool.
Programs hardcode their policy. Escrows, vaults, treasuries, and smart wallets try to enforce rules — but they bake them directly into program code:
require!(amount <= 1_000_000_000, MyError::TooMuch);
require!(recipient == ALLOWED_RECIPIENT, MyError::NotAllowed);Every rule change becomes a program upgrade. Every upgrade demands upgrade authority. Programs that should be neutral infrastructure end up acting as policy administrators. And the same program cannot serve users with different policies — a vault that wants per-user withdrawal limits has to invent its own ad-hoc config system inside its own state, duplicating effort across every contract with the same need.
Rule lifts policy out of the program and into an on-chain account you own.
// in your wallet / escrow / vault:
rule::cpi::verify(ctx, target_program, ix_data, accounts)?; // the firewall
system_program::transfer(...)?; // the actionYour program calls verify() before any CPI. Rule reads a RuleConfig account and confirms the call is allowed. Updating policy is just an account update — the program itself never changes. One config can govern one wallet or thousands. Authority over a config can be a key, a multisig, a DAO, or no one (burn it for permanent immutability).
Change policy without redeploying. Per-user policies with no extra plumbing. Burn the authority for unbreakable rules.
| Scenario | Without Rule | With Rule |
|---|---|---|
| Phishing tx signed by user | ✗ Funds gone | ✓ Blocked — recipient not whitelisted |
| Wallet drained by malicious approval | ✗ Funds gone | ✓ Blocked — token program not in allowlist |
| Accidental oversized transfer | ✗ Funds gone | ✓ Blocked — exceeds configured cap |
| Raise a vault's transfer limit | ✗ Program upgrade required | ✓ One account update, no redeploy |
| Per-user policy in a shared escrow | ✗ Custom config logic baked into the program | ✓ Each user owns their own RuleConfig |
| DAO governance over a treasury | ✗ Multisig holds upgrade authority over the program | ✓ Multisig owns the RuleConfig only |
| Freeze policy permanently | ✗ Not a thing | ✓ Burn authority to SystemProgram.programId |
Rule is built for any Solana program that needs a policy layer, plus the wallets that sit in front of them.
| Use Case | What Rule Provides |
|---|---|
| Smart Programmable Wallet | The wallet program calls verify before any action. Phishing transactions, malicious approvals, and oversized transfers are blocked even if signed — the wallet refuses to execute what its policy disallows. |
| Per-user spending limits | Each user's wallet has its own RuleConfig. Daily caps, recipient allowlists, and dApp restrictions are configured per account, not hardcoded into a shared program. |
| Recovery & guardian flows | Trusted guardians hold authority over the RuleConfig. They can rotate policies (e.g., emergency lockdown) without touching the wallet's code. |
| Use Case | What Rule Provides |
|---|---|
| Treasury with raisable limits | Admin updates the cap in RuleConfig. No redeploy, no code review, no audit cycle for routine policy changes. |
| DAO-controlled vault | Authority is the DAO multisig. Governance proposals change what the vault can do — the program itself stays neutral. |
| Per-user escrow policies | One generic escrow program serves users with different rules. Each user owns their own RuleConfig. |
| CPI allowlists | Whitelist exactly which programs and instructions a contract is permitted to call. Catches integration drift and untrusted CPIs. |
| Immutable savings vault | Define the rules, transfer authority to SystemProgram.programId. Locked forever — no signer can ever change the policy. |
| Time-boxed permissions | Schedule a cron job (or DAO action) that updates policy on a timeline — e.g., disable withdrawals after a launch window. |
| Compliance gating | Enforce recipient allowlists, transfer caps, and SPL mint/owner constraints declaratively across an entire protocol. |
| Feature | Description |
|---|---|
| Declarative policy | Express rules as data — programs, instructions, and constraints — not as Rust code. |
| Boolean composition | Combine constraints with AND / OR / nested groups. Real policies aren't flat AND-lists. |
| Two-tier defaults | default_allow at the config and per-program level for clean opt-out semantics. |
| Six constraint kinds | Pubkey equality, pubkey set membership, program ownership, raw data fields, SPL mint and SPL owner. |
| Atomic updates | update_config validates the new tree before writing — partial-invalid configs cannot be committed. |
| Exact-fit allocation | Accounts size to their exact payload. Reallocs refund or charge rent automatically. |
| Burnable authority | Transfer to SystemProgram.programId to permanently freeze the policy. |
| 108-case test suite | Every constraint, operator, edge case, and security boundary covered against devnet. |
┌──────────────────────────────────────────────────────────────────────────────┐
│ USER / DAPP │
│ builds & signs transaction │
└──────────────────────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ YOUR PROGRAMMABLE WALLET / ESCROW / VAULT │
│ │
│ 1. Construct target instruction (transfer / swap / withdraw / ...) │
│ 2. CPI ──▶ rule::verify(target_program, ix_data, account_metas) │
│ 3. If verify returns OK ──▶ CPI ──▶ target program │
└──────────────────────┬─────────────────────────────────┬─────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────┐ ┌──────────────────────────────────┐
│ RULE PROGRAM │ │ TARGET PROGRAM │
│ │ │ │
│ • Reads RuleConfig (read-only) │ │ System / Token / DEX / ... │
│ • Walks the predicate tree │ │ │
│ • Returns Ok(()) or aborts the tx │ │ │
└──────────────────────────────────────┘ └──────────────────────────────────┘
Tree shape. A RuleConfig is a list of ProgramRules, each holding a list of InstructionRules, each holding a flat predicate tree of conditions. Internal nodes are All (AND) and Any (OR); leaves are constraint checks. Trees nest up to 4 levels deep.
graph TD
Root["AND"] --> A["amount ≤ 1 SOL"]
Root --> Or["OR"]
Or --> B["recipient = Alice"]
Or --> C["recipient = Bob"]
style Root fill:#2d3748,stroke:#4a5568,color:#fff
style Or fill:#2d3748,stroke:#4a5568,color:#fff
style A fill:#1a365d,stroke:#2c5282,color:#fff
style B fill:#1a365d,stroke:#2c5282,color:#fff
style C fill:#1a365d,stroke:#2c5282,color:#fff
Author writes the policy
↓
SDK builders compose a flat predicate tree
↓
init_config submits the tree on-chain
↓
RuleConfig account allocated and validated atomically
↓
Authority defaults to the rule keypair (transfer or burn next)
↓
✓ Policy is live and ready to gate verify() calls
flowchart TD
Start([escrow calls verify]) --> P{is the target<br/>program whitelisted?}
P -- no --> PD{config<br/>default_allow?}
PD -- no --> R1[reject: ProgramNotAllowed]
PD -- yes --> Pass([OK])
P -- yes --> I{does the instruction<br/>discriminator match?}
I -- no --> ID{program<br/>default_allow?}
ID -- no --> R2[reject: InstructionNotAllowed]
ID -- yes --> Pass
I -- yes --> Eval{predicate tree<br/>evaluates to true?}
Eval -- no --> R3[reject: InstructionNotAllowed]
Eval -- yes --> Pass
style Pass fill:#22543d,stroke:#2f855a,color:#fff
style R1 fill:#742a2a,stroke:#c53030,color:#fff
style R2 fill:#742a2a,stroke:#c53030,color:#fff
style R3 fill:#742a2a,stroke:#c53030,color:#fff
verify holds RuleConfig as read-only — it cannot mutate, reallocate, or close the account. First discriminator match wins; alternatives within a single instruction live inside the predicate tree (Any groups), not as duplicate rules.
- Node.js 18+
- Rust 1.75+
- Solana CLI 1.18+
- Anchor 0.31
- A funded devnet keypair at
~/.config/solana/id.json
git clone https://github.com/mirrorfi/rule.git
cd rule
yarn install# Compile the on-chain program
anchor build
# Run the full test suite (108 tests against devnet)
make test
# Run a single test group
make test-sol # SOL transfer rules
make test-ac # account constraints
make test-ops # all comparison operators
# Run by test ID
make test-grep GREP="SOL-0[1-4]"anchor build
anchor deploy --provider.cluster devnetIf you fork and redeploy under a new program ID, update it in three places:
// programs/rule/src/lib.rs
declare_id!("YOUR_PROGRAM_ID");# Anchor.toml
[programs.localnet]
rule = "YOUR_PROGRAM_ID"// tests/helpers/program.ts → already reads from target/types/rule
// no manual update required after `anchor build`rule/
├── programs/rule/ # Solana program (Anchor 0.31)
│ └── src/
│ ├── lib.rs # program entrypoint
│ ├── state.rs # RuleConfig, ProgramRule, PredicateNode
│ ├── constraints.rs # tree validation + evaluation
│ ├── errors.rs # custom errors
│ └── instructions/ # init_config, update_config,
│ # transfer_authority, verify
├── tests/ # 108-case test suite (devnet)
│ ├── helpers/ # SDK-shaped builders
│ │ ├── builders.ts # predAll, predAny, predParam, ...
│ │ ├── encoding.ts # u64ToExpected, pubkeyToExpected, ...
│ │ ├── parse.ts # RuleConfig → human-readable JSON
│ │ ├── program.ts # initConfig, getRuleVerificationTx
│ │ ├── space.ts # exact-byte size calculation
│ │ └── token.ts # SPL test setup
│ ├── init_config.ts # 15 tests
│ ├── update_config.ts # 7 tests
│ ├── transfer_authority.ts # 6 tests
│ ├── verify_*.ts # 80 verify tests across 7 files
│ └── TEST_REPORT.md # full test catalog
├── Anchor.toml
├── Makefile # test + deploy shortcuts
└── README.md
Creates a fresh RuleConfig at the rule keypair's address.
Accounts: rule_config (writable signer — keypair), payer (writable signer), system_program
Arguments:
program_rules: Vec<ProgramRule>— the policy tree
Authority pattern: authority is set to rule_config.key(). Discard the rule keypair's private key after init for an immutable config.
Replaces the entire policy. Reallocates the account and adjusts rent automatically.
Accounts: rule_config (writable, has_one = authority), authority (signer), payer (writable signer), system_program
Arguments:
program_rules: Vec<ProgramRule>— the new policy
Validation runs before any state is written. A failed update leaves existing rules intact.
Hands admin rights to a new pubkey.
Accounts: rule_config (writable, has_one = authority), authority (signer)
Arguments:
new_authority: Pubkey— set toSystemProgram.programIdto permanently lock the config
The core gate. Read-only — never mutates rule_config.
Accounts: rule_config (read-only), plus remaining_accounts for any constraint that inspects on-chain account data
Arguments:
target_program: Pubkey— the program the escrow intends to invokeix_data: Vec<u8>— full instruction data including discriminatoraccount_metas: Vec<SerializedAccountMeta>— accounts the instruction will receive
| Value Type | Bytes | Operators |
|---|---|---|
U8, U16, U32, U64, U128, I64 |
1 – 16 | Eq, NotEq, Lt, Lte, Gt, Gte |
Bool |
1 | Eq, NotEq |
Pubkey |
32 | Eq, NotEq |
All numeric values are read little-endian.
| Kind | What it checks | Needs remaining_accounts |
|---|---|---|
PubkeyEquals(Pubkey) |
account at index = pubkey | — |
PubkeyInSet(Vec<Pubkey>) |
account at index ∈ set | — |
OwnedByProgram(Pubkey) |
on-chain owner of account = program | ✓ |
DataFieldEquals { offset, len, expected } |
raw bytes of account data match | ✓ |
TokenAccountMintEquals(Pubkey) |
SPL token account's mint matches | ✓ |
TokenAccountOwnerEquals(Pubkey) |
SPL token account's owner matches | ✓ |
import {
predAll, predAny, predParam, predAccount,
acPubkeyEquals, makeInstructionRule, makeProgramRule,
Op, VType, u64ToExpected, SOL_TRANSFER_DISC, initConfig,
} from "./tests/helpers";
// "amount ≤ 1 SOL AND (recipient = Alice OR recipient = Bob)"
const policy = predAll([
predParam(0, Op.Lte, VType.U64, u64ToExpected(1_000_000_000)),
predAny([
predAccount(acPubkeyEquals(1, ALICE)),
predAccount(acPubkeyEquals(1, BOB)),
]),
]);
const rule = makeProgramRule(SystemProgram.programId, [
makeInstructionRule(SOL_TRANSFER_DISC, 4, policy),
]);
const { ruleConfigPubkey } = await initConfig(program, payer, [rule]);import { getRuleVerificationTx } from "./tests/helpers";
const normalTx = new Transaction().add(SystemProgram.transfer({...}));
const ruleTx = await getRuleVerificationTx(normalTx, ruleConfigPubkey, program);
await provider.sendAndConfirm(ruleTx, []);getRuleVerificationTx extracts the original instruction and wraps it in a verify call — drop-in for any escrow flow.
| Layer | Protection |
|---|---|
| Read-only verify | rule_config is declared non-mutable in the Verify context. Cannot be written, reallocated, or closed. |
| Atomic validation | update_config validates the entire new tree before any state mutation. Partial-invalid configs cannot land. |
| Bounded evaluation | Tree depth ≤ 4, ≤ 32 nodes, config ≤ 10 KB. Verify-time deserialise + eval is hard-capped. |
| Safe-fail constraints | Constraints requiring remaining_accounts evaluate to false (not error, not pass) when the account is absent. Cannot be bypassed by omission. |
| Burnable authority | Transferring authority to SystemProgram.programId makes the config permanently immutable — no signer for the all-zeros pubkey can ever exist. |
| Exact-fit allocation | Accounts size to their exact payload. No padding or unused buffer for oversized writes to exploit. |
| Error Code | Variant | Condition |
|---|---|---|
| 6000 | ProgramNotAllowed |
Target program not in whitelist and config default_allow = false |
| 6001 | InstructionNotAllowed |
Discriminator unmatched, or predicate evaluated to false |
| 6002 | InvalidConfigSchema |
Tree malformed: depth too deep, empty group, oversized config, etc. |
On-Chain: Rust · Anchor 0.31 · Solana 1.18 · anchor-spl (token)
SDK / Tests: TypeScript · @coral-xyz/anchor · @solana/web3.js · @solana/spl-token · Mocha · Chai
Tooling: Yarn · ts-mocha · Make