Skip to content

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: MITSolanaBuilt with AnchorRustTypeScriptTestsPRs Welcome

Devnet Program · How It Works · Quick Start


solanaanchorrusttypescriptescrowvaultdefipolicy-enginefirewallallowlistrule-engineweb3securitygovernance


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

ScenarioWithout RuleWith 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 CaseWhat Rule Provides
Smart Programmable WalletThe 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 limitsEach 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 flowsTrusted 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 CaseWhat Rule Provides
Treasury with raisable limitsAdmin updates the cap in RuleConfig. No redeploy, no code review, no audit cycle for routine policy changes.
DAO-controlled vaultAuthority is the DAO multisig. Governance proposals change what the vault can do — the program itself stays neutral.
Per-user escrow policiesOne generic escrow program serves users with different rules. Each user owns their own RuleConfig.
CPI allowlistsWhitelist exactly which programs and instructions a contract is permitted to call. Catches integration drift and untrusted CPIs.
Immutable savings vaultDefine the rules, transfer authority to SystemProgram.programId. Locked forever — no signer can ever change the policy.
Time-boxed permissionsSchedule a cron job (or DAO action) that updates policy on a timeline — e.g., disable withdrawals after a launch window.
Compliance gatingEnforce recipient allowlists, transfer caps, and SPL mint/owner constraints declaratively across an entire protocol.

Features

FeatureDescription
Declarative policyExpress rules as data — programs, instructions, and constraints — not as Rust code.
Boolean compositionCombine constraints with AND / OR / nested groups. Real policies aren't flat AND-lists.
Two-tier defaultsdefault_allow at the config and per-program level for clean opt-out semantics.
Six constraint kindsPubkey equality, pubkey set membership, program ownership, raw data fields, SPL mint and SPL owner.
Atomic updatesupdate_config validates the new tree before writing — partial-invalid configs cannot be committed.
Exact-fit allocationAccounts size to their exact payload. Reallocs refund or charge rent automatically.
Burnable authorityTransfer to SystemProgram.programId to permanently freeze the policy.
108-case test suiteEvery 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.rsdeclare_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 TypeBytesOperators
U8, U16, U32, U64, U128, I641 – 16Eq, NotEq, Lt, Lte, Gt, Gte
Bool1Eq, NotEq
Pubkey32Eq, NotEq

All numeric values are read little-endian.

Account Constraints — checks on transaction accounts

KindWhat it checksNeeds 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)"constpolicy=predAll([predParam(0,Op.Lte,VType.U64,u64ToExpected(1_000_000_000)),predAny([predAccount(acPubkeyEquals(1,ALICE)),predAccount(acPubkeyEquals(1,BOB)),]),]);construle=makeProgramRule(SystemProgram.programId,[makeInstructionRule(SOL_TRANSFER_DISC,4,policy),]);const{ ruleConfigPubkey }=awaitinitConfig(program,payer,[rule]);

Wrapping an Existing Transaction

import{getRuleVerificationTx}from"./tests/helpers";constnormalTx=newTransaction().add(SystemProgram.transfer({...}));construleTx=awaitgetRuleVerificationTx(normalTx,ruleConfigPubkey,program);awaitprovider.sendAndConfirm(ruleTx,[]);

getRuleVerificationTx extracts the original instruction and wraps it in a verify call — drop-in for any escrow flow.


Security Model

LayerProtection
Read-only verifyrule_config is declared non-mutable in the Verify context. Cannot be written, reallocated, or closed.
Atomic validationupdate_config validates the entire new tree before any state mutation. Partial-invalid configs cannot land.
Bounded evaluationTree depth ≤ 4, ≤ 32 nodes, config ≤ 10 KB. Verify-time deserialise + eval is hard-capped.
Safe-fail constraintsConstraints requiring remaining_accounts evaluate to false (not error, not pass) when the account is absent. Cannot be bypassed by omission.
Burnable authorityTransferring authority to SystemProgram.programId makes the config permanently immutable — no signer for the all-zeros pubkey can ever exist.
Exact-fit allocationAccounts size to their exact payload. No padding or unused buffer for oversized writes to exploit.
Error CodeVariantCondition
6000ProgramNotAllowedTarget program not in whitelist and config default_allow = false
6001InstructionNotAllowedDiscriminator unmatched, or predicate evaluated to false
6002InvalidConfigSchemaTree 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

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages