Skip to main content

Crate cratefield_module_guardrails

Crate cratefield_module_guardrails 

Source
Expand description

The policy engine every automated value-moving action must pass (issue #763). Default deny everywhere; fail closed on any port error. The README below is the crate documentation: the decision order, the ports, and the limits of what a policy alone can enforce.

§cratefield-module-guardrails

The policy engine every automated value-moving action must pass. A job that would move a venture’s or a user’s funds — send a transaction, sign a permit, approve a spender, swap — describes itself as a Request and gets one of two answers: an opaque Approval that only the engine can mint, or a typed Denied with every reason. Default deny everywhere, and fail closed on any port error: a simulator that cannot be reached, an oracle with no quote, a ledger that cannot be read, an audit sink that cannot record — each is a refusal, never a shrug.

use cratefield_module_guardrails::*;

// Placeholder addresses, built at runtime so no long hex literal sits in
// this file; 40 hex digits is a 20-byte address.
let from = format!("0x{}", "1".repeat(40));
let spender = format!("0x{}", "2".repeat(40));

let engine = Guardrails::builder()
    .policy(Policy::new()
        .chain(Chain::Evm { chain_id: 1 })
        .contract(&from, &[SEL_APPROVE])
        .spender(&spender)
        .subject("job-7", SubjectPolicy::new()
            .caps(Caps::uniform(
                &TokenKey::native(Chain::Evm { chain_id: 1 }),
                5_000_000,                     // $5 per action, in micro-USD
                20_000_000,                    // $20 per UTC day
                20_000_000,                    // $20 per rolling period
                time::Duration::hours(24),
                200,                           // 2% max slippage
            ))
            .auto_revoke(true)))
    .kill_switch(MemoryKillSwitch::new())
    .simulator(ScriptedSimulator::new().clean())
    .prices(FixedPrices::new())
    .ledger(MemoryLedger::new())
    .audit(MemoryAudit::new())
    .allowances(MemoryAllowances::new())
    .build()
    .expect("every port wired");

§The decision order

Guardrails::check runs one gate after another and stops at the first that refuses — a request refused statically never reaches the simulator:

  1. Kill switch — engaged at the global, venture or subject scope is a deny before anything else is consulted, and is checked again at the moment of signing, so a job holding an approval is still stopped;
  2. Hard denies — setApprovalForAll, unlimited approvals (approve, increaseAllowance, Permit2 approve, SPL Approve at u64::MAX), permits (EIP-2612, DAI-style, Permit2) to a spender the policy does not name, with an unlimited/unboundable amount, or with calldata too truncated to read, EIP-7702 authorizations with chain_id == 0, Solana owner reassignment (System Assign / AssignWithSeed, SPL SetAuthority) and AdvanceNonceAccount — from the request alone, whatever the allowlist says;
  3. Allowlists — the chain; the contract with its explicit selector set (or the Solana program); every destination the action moves funds to (native transfers, decoded ERC-20 transfer/transferFrom recipients, System Transfer and SPL Transfer/TransferChecked destinations); approval spenders; EIP-7702 delegates;
  4. Simulation — required, via the Simulator port (eth_simulateV1 / simulateTransaction in an adapter). Denied on: a revert, an unlimited approval, an approval to a non-allowlisted spender (an approval of exactly zero is a revoke, and exempt), a credit to a non-allowlisted destination, a delegatecall into a non-allowlisted contract;
  5. Caps and slippage — per action, per UTC day and per rolling period, in micro-USD per token, priced through the Prices port from the simulation’s outflows. A subject with no caps, a token with no cap entry (zero), no price, a swap over max_slippage_bps, or a swap-shaped action (the actor both pays and receives) with no quote at all is denied; a non-positive or out-of-range period denies the subject rather than silently disabling the rolling window;
  6. Ledger — the priced outflows are recorded, only on the way to an allow;
  7. Audit — one entry per decision, allow or deny, on check, on every sign and on every hygiene finding; a sink error is a deny.

An “unlimited” amount is one at or above 2^128 — MAX_UINT256, Permit2’s MAX_UINT160 and every practical infinite approval are all far above the line, and no honest bounded amount comes near it.

§The ports, and their fakes

PortAnswersFake
KillSwitchengaged / engage / release over Scope::{Global, Venture, Subject}MemoryKillSwitch
Simulatorthe SimulationReport: success, balance changes, approvals, call traceScriptedSimulator
Pricesmicro-USD for (chain, token, raw amount); None is no quoteFixedPrices
SpendLedgerspent(subject, token, since) / record(...)MemoryLedger
AuditSinkrecord(&AuditEntry)MemoryAudit, FailingAudit
AllowanceSourcea wallet’s open allowancesMemoryAllowances
WalletSignerthe actual signing (e.g. a Turnkey adapter)RecordingSigner

Around them: Guardrails::check mints Approvals; GuardedSigner::sign consumes one and re-checks all three kill-switch scopes first (in-flight jobs die at signing time); Guardrails::job_allowed lets the actor skip scheduling for a killed subject; turnkey_deny_policy renders a scope as a Turnkey EFFECT_DENY policy body so the remote signer refuses even if this process is bypassed (None for ids outside [A-Za-z0-9._:-]+, which could rewrite the condition); Guardrails::scan_allowances finds old allowances today’s policy would not issue and builds approve(spender, 0) revokes — sent through check itself when the subject sets auto_revoke, and reported unremediable when a malformed spender can name no revoke.

GuardrailsModule mounts the engine as a harness module: no tables, a status route, and a scheduled hook that runs the hygiene scan over configured watched wallets.

§Limits

  • No RPC or provider adapter ships here. The Simulator, AllowanceSource and WalletSigner ports need an adapter per provider; this crate is the policy, not the plumbing.
  • The ledger is not transactional. spent + record is a check-then-act pair; callers must serialize per subject (one actor per subject, jobs one at a time), which is the harness’s own contract. Concurrent checks for one subject can exceed a cap by the overlap.
  • The hygiene scan is EVM-only — an approve(spender, 0) revoke has no Solana twin here.
  • Permit2 batch permits are always denied as unboundable: their amounts live in a dynamic array no static check can bound.
  • Every port error denies, and an audit-sink error after the ledger recorded leaves the spend recorded — over-counting is the conservative direction.

Structs§

Allowance
One open allowance on a wallet.
AllowanceError
Why the allowance source could not answer.
Allowlist
One layer of the allowlist: the chains, contracts, programs, destinations and spenders named at this layer.
Approval
A token that exists only because Guardrails::check returned Ok. Opaque and deliberately not Clone: one approval is one audited decision. Constructible by nobody outside this crate — the private Seal field sees to that.
ApprovalChange
One approval the simulation observed being taken.
AuditEntry
One recorded decision.
AuditError
Why the audit sink could not record.
Authorization7702
An EIP-7702 authorization carried by an EVM transaction: a delegation of the signer’s code to address.
BalanceChange
One balance change the simulation observed: delta in the token’s smallest unit, negative for a debit.
BuildError
Why the builder refused to build: a required port was not wired.
CallFrame
One frame of the simulation’s call trace.
Caps
The spend windows, per subject. Every map is micro-USD keyed by token; a missing entry is a cap of zero. period is the rolling window the per_period map is measured over.
Denied
A refusal, with every reason the engine collected.
FailingAudit
An audit sink that always errors, for the fail-closed test.
FixedPrices
A price oracle with a fixed table: token key to micro-USD per smallest unit. A token missing from the table has no quote.
GuardedSigner
The signer hosts hand to code that moves value. sign consumes the approval Guardrails::check issued and re-checks all three kill-switch scopes — so engaging the switch after a check blocks the in-flight job at the moment of signing, and audits that decision too.
Guardrails
The engine. Built once per venture, from ports and a Policy; see Guardrails::builder.
GuardrailsBuilder
Builds a Guardrails. Every port is required except the allowance source; the clock and id generator default to core’s.
GuardrailsModule
The engine as a harness Module: no tables, no routes to speak of, and a scheduled hook that runs the hygiene scan over the watched wallets.
HygieneFinding
One finding: the allowance, why it is a finding, and the remedy.
KillSwitchError
Why the kill switch could not answer or act.
LedgerError
Why the ledger could not answer or record.
MemoryAllowances
An allowance source with a fixed list.
MemoryAudit
An audit sink in memory, inspectable by the test.
MemoryKillSwitch
A kill switch in memory. Engagements are a set of scope keys.
MemoryLedger
A spend ledger in memory: an append-only list of entries.
Policy
The whole policy: a global layer plus one SubjectPolicy per subject.
PricesError
Why valuation failed.
RecordedSpend
One recorded entry, with its subject and instant.
RecordingSigner
A wallet signer that records every request it is asked to sign and answers with the configured script.
Request
A value-moving action an actor wants to take, as the engine sees it.
ScriptedSimulator
A simulator that answers every call with the configured script and records the requests it was asked about.
SignError
Why the wallet signer refused.
SimulationReport
What the simulation observed. success == false is a revert: denied, not an error — a revert is an answer about the request, an unavailable simulator is a control-plane fault.
SolanaInstruction
One Solana instruction.
SpendEntry
One recorded outflow: what token, how much in micro-USD.
SubjectPolicy
One subject’s layer of the policy: an Allowlist, the subject’s caps, and whether the hygiene scan may revoke automatically.
SwapQuote
The swap context of an action, when it is a swap: the quoted output and the minimum the caller will accept. Slippage is (quoted_out - min_out) / quoted_out in basis points.
TokenKey
What token a balance change or a cap is about: the chain plus the token’s address (EVM, lowercase hex) or mint (Solana, base58), or "native" for the chain’s own coin.
WatchedWallet
One watched wallet for the module’s scheduled hygiene scan.

Enums§

Action
What the acting address wants to do.
CallKind
The kind of a call frame in the simulation trace.
CapExceeded
Which spend window a cap breach landed in.
Chain
Which chain an action runs on.
DenyReason
Why the engine refused, typed so sinks can group and alert. Display renders the compact text that goes in logs.
HardDeny
The statically-known dangerous shapes, refused before any allowlist is consulted.
HygieneReason
Why a finding is a finding.
NotAllowlisted
Which allowlist an action fell outside.
Remedy
What to do about a finding.
Scope
How wide a kill switch engagement reaches.
Script
What a ScriptedSimulator answers with.
Severity
How much a decision should interest an operator.
SignScript
What a RecordingSigner does when asked to sign.
SimError
Why the simulation could not be produced. A reverted execution is not an error — it is SimulationReport::reverted.
Verdict
What was decided.

Constants§

SEL_APPROVE
approve(address,uint256) — the classic ERC-20 approval.
SEL_INCREASE_ALLOWANCE
increaseAllowance(address,uint256).
SEL_PERMIT2_APPROVE
approve(address,address,uint160,uint48) — Permit2’s AllowanceTransfer approval: token, spender, amount, expiration.
SEL_PERMIT2_BATCH
permit(address,((address,uint160,uint48,uint48)[],address,uint256),bytes) — Permit2 AllowanceTransfer, batch. The amounts live in a dynamic array, so no static bound exists and the form is refused outright.
SEL_PERMIT2_SINGLE
permit(address,((address,uint160,uint48,uint48),address,uint256),bytes) — Permit2 AllowanceTransfer, single: owner, details (token, amount, expiration, nonce), spender, deadline, signature.
SEL_PERMIT_2612
permit(address,address,uint256,uint256,uint8,bytes32,bytes32) — EIP-2612: owner, spender, value, deadline, then the signature.
SEL_PERMIT_DAI
permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32) — DAI’s non-standard permit: owner, spender, value, deadline, allowed, then the signature.
SEL_SET_APPROVAL_FOR_ALL
setApprovalForAll(address,bool) — every token, now and in the future.
SEL_TRANSFER
transfer(address,uint256) — decoded for its recipient, not denied.
SEL_TRANSFER_FROM
transferFrom(address,address,uint256) — decoded for its recipient.
SPL_TOKEN_2022_PROGRAM
The Token-2022 program. Fragments, as above; the test pins the value.
SPL_TOKEN_PROGRAM
The SPL Token program. Fragments, as above; the test pins the value.
SYSTEM_PROGRAM
The Solana System program: base58 of 32 zero bytes. Built from short fragments so no long base58-shaped literal sits in this file; the test below pins the exact value.
SYS_ADVANCE_NONCE
System AdvanceNonceAccount: advances a durable nonce.
SYS_ASSIGN
System Assign (u32 little-endian discriminant): reassigns account owner.
SYS_ASSIGN_WITH_SEED
System AssignWithSeed: reassigns account owner, seeded form.
SYS_TRANSFER
System Transfer (decoded for its destination, not denied).
TOK_APPROVE
SPL Token Approve.
TOK_APPROVE_CHECKED
SPL Token ApproveChecked.
TOK_REVOKE
SPL Token Revoke.
TOK_SET_AUTHORITY
SPL Token SetAuthority.
TOK_TRANSFER
SPL Token Transfer (u8 discriminant).
TOK_TRANSFER_CHECKED
SPL Token TransferChecked.

Traits§

AllowanceSource
The port that reads a wallet’s open allowances. An adapter wraps the provider’s allowance(token, owner, spender) calls over the token list it watches.
AuditSink
Where decisions are recorded. Wired with a database-backed sink in production; every decision passes through it, and a sink error fails the decision closed.
KillSwitch
Where engagements live. Backed by durable state in production; the in-memory fake in crate::fakes::MemoryKillSwitch exists for tests.
Prices
The valuation port: raw amount to micro-USD.
Simulator
The port the engine requires an implementation of. Adapters wrap the provider’s simulation endpoints; the fakes in this crate script the reports tests need.
SpendLedger
The memory port: what a subject has already spent in a window, and the write side that accumulates it.
WalletSigner
The port that turns an approved request into a signed transaction or signature — the thing a Turnkey-style provider adapter implements. Reached only through GuardedSigner, which re-checks the kill switch first.

Functions§

allowlist_denies
The allowlist half of the engine: everything that is refused because the policy does not name it. Runs after the hard denies, which is why it may assume the request’s shape is not itself dangerous.
hard_denies
Every hard deny the request itself carries. Cheap: no port, no I/O — the engine runs this before anything that can fail, and a non-empty answer means the simulator is never called.
revoke_action
The revoke action for one allowance on an EVM chain, or None when the allowance’s spender cannot name a revoke target (see revoke_calldata).
revoke_calldata
The approve(spender, 0) calldata for a revoke, or None when spender is not exactly 0x plus 40 hex digits — a revoke naming the zero address would revoke nothing and report success.
solana_destination
The destination account a Solana instruction moves funds to, if it is a move-funds instruction the policy knows: System Transfer (to is account 1), SPL Transfer (account 1) and TransferChecked (account 2).
system_discriminant
The System program’s u32 little-endian instruction discriminant.
tok_discriminant
The SPL Token programs’ u8 instruction discriminant.
turnkey_deny_policy
The kill switch rendered as a Turnkey policy body, so the remote signer refuses even if this process is bypassed, crashed or compromised: two independent enforcement points, one intent.
u256_from_u128
v as a big-endian U256.
u256_is_unlimited
Whether v >= 2^128 — the crate’s “unlimited” line.
u256_to_u128
The low 128 bits of v, or None when the value needs more than 128 bits (and so cannot be a micro-USD figure or a counted token amount).
word
The 32-byte word at at (byte offset from the start of data), when present.
word_addr
The lowercase 0x-hex address in the 32-byte word at at (the word’s last 20 bytes), when present.

Type Aliases§

U256
A 256-bit unsigned integer, big-endian: the shape of an EVM uint256 word.