Skip to content

mirrorfi/rule

Repository files navigation

Rule

Programmable On-Chain Policy Engine for Solana

Define what your Wallet/Escrow can do as data, not code. Update the policy without redeploying.

License: MIT Solana Built with Anchor Rust TypeScript Tests PRs Welcome

Devnet Program · How It Works · Quick Start


solana anchor rust typescript escrow vault defi policy-engine firewall allowlist rule-engine web3 security governance


Table of Contents


The Problem

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.

The Solution

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 action

Your 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.

Without Rule vs With Rule

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

Use Cases

Rule is built for any Solana program that needs a policy layer, plus the wallets that sit in front of them.

For Wallets

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.

For Programs (Escrow, Vault, Treasury, DEX)

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.

Features

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.

Architecture

┌──────────────────────────────────────────────────────────────────────────────┐
│                              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
Loading

How It Works

Defining a Policy

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

Verifying a Transaction

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
Loading

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.


Quick Start

Prerequisites

  • Node.js 18+
  • Rust 1.75+
  • Solana CLI 1.18+
  • Anchor 0.31
  • A funded devnet keypair at ~/.config/solana/id.json

Installation

git clone https://github.com/mirrorfi/rule.git
cd rule
yarn install

Build and Test

# 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]"

Deploy to Devnet

anchor build
anchor deploy --provider.cluster devnet

If 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`

Project Structure

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

Program Instructions

init_config

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.

update_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.

transfer_authority

Hands admin rights to a new pubkey.

Accounts: rule_config (writable, has_one = authority), authority (signer)

Arguments:

  • new_authority: Pubkey — set to SystemProgram.programId to permanently lock the config

verify

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 invoke
  • ix_data: Vec<u8> — full instruction data including discriminator
  • account_metas: Vec<SerializedAccountMeta> — accounts the instruction will receive

Constraint Reference

Param Constraints — checks on instruction data

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.

Account Constraints — checks on transaction accounts

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

Composing Policies (TypeScript)

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]);

Wrapping an Existing Transaction

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.


Security Model

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.

Tech Stack

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


Built by MirrorFi · MIT License · Devnet Program


⚠️ Solana Program Has Not Been Audited.

About

Programmable On-Chain Policy Engine for Solana

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors