Account Permissioning: OZ Policy Builder
The OZ Policy Builder is an off-chain TypeScript toolkit that produces a policy installable on an OpenZeppelin Stellar smart account. Given a recorded Soroban transaction, it derives the narrowest set of OZ primitive policies that permit exactly the observed behaviour — nothing more.
The record → synthesise → install pipeline
1. Record 2. Synthesise 3. Install
───────── ────────── ────────
Capture tx Analyse trace Submit to
from network Infer minimal scope OZ smart
or simulation Flag Path-B gaps account
Step 1 — Record
The recorder captures a Soroban transaction and produces a normalised RecordedTransaction. This can come from:
- Live network — the recorder observes real on-chain events and XDR.
- Simulation —
simulateTransactionreturns a result without signing. Note: simulation mode has no raw events, so the recorder cannot perform its events cross-check and reducesparseConfidence. It fails closed at the default gate; callers must explicitly provide aconfidenceOverride.
The recorder produces a parseConfidence score: 1 - (unknownContracts + opaqueScVals) / total. A recording with any unknown contract or opaque ScVal does not synthesize.
Step 2 — Synthesise
Two synthesis front-ends feed one composition core:
| Front-end | Input | Deterministic? |
|---|---|---|
| Declaration | Stated spec (PolicyDeclaration, via declarePredicate) | Yes — pure mapping, no network access |
| Recording | RecordedTransaction from step 1 | Yes — same input always produces byte-identical output |
Both emit a predicate document: data, not a generated contract. One immutable on-chain interpreter evaluates it, so a new combination of constraints needs no deployment and no fresh audit.
What the grammar expresses (version 4). Two boolean nodes, and and or;
five comparisons, eq, lt, lte, gt, gte, plus set membership in; and
leaves that bind the call itself:
| Leaf | Binds |
|---|---|
call_contract | which contract is called |
call_fn | which method — per-method scoping |
call_arg | a positional argument, with eq or in for allowlists |
call_arg_field | a value nested inside a struct argument |
call_arg_len | the length of a list argument |
call_arg_scaled | one argument against another, for ratio floors such as slippage |
Amount caps and approval thresholds are delegated to the OpenZeppelin built-in
policies (spending_limit, simple_threshold, weighted_threshold) attached
alongside. Multiple policies on one context rule compose as ALL-OF: every
attached policy must permit.
What is still not covered: invocation-count windows, that is, capping how
often a key acts rather than how much. A spend cap bounds amount, so a key
capped at 100 per period can still make many small calls. These are surfaced as
uncovered[] warnings — the policy is never silently weakened. Oracle price
bounds were removed from the grammar rather than left uncovered.
The synthesizer is fail-closed everywhere: no bound is invented, no ambiguity is resolved by assumption. A slippage floor is never inferred from a recording, because a recorded rate is a price at one moment and freezing it as policy would deny ordinary trades later; you state the ratio.
Step 3 — Install
The output is a ProposedPolicy carrying the ContextRuleDraft and OZ policy primitive references. Installing it on an OZ smart account calls add_context_rule with the compiled context rule and a map of PolicyRef → policy instance addresses.
The builder produces an unsigned transaction and hands it to a wallet. It
holds no key, requests no key, and submits nothing; the wallet is where the user
reviews and approves. The envelope is an ordinary Soroban InvokeHostFunction
with the resource footprint already attached and no custom fields, so any wallet
that can sign a contract invocation can sign it.
A context rule with no policy requires the full signer set to approve. Attach any policy and signer validation is deferred to that policy, so any single signer on the rule can act alone.
If you have a rule with two signers and attach a policy expecting "both must approve", you get the opposite. To require a threshold you must attach one, as a policy that counts signatures. This was confirmed by experiment on testnet and mainnet, not read from documentation.
Packages
Three packages, published on npm and versioned together, in the public
untangledfinance/oz-policy-builder:
@crediolabs/policy-synth (the TypeScript synthesis core), with
@crediolabs/policy-builder-mcp and @crediolabs/policy-builder-cli as
front-ends over it. The on-chain interpreter is a Rust contract in the same
repository, under contracts/policy-interpreter.
They are needed to author a policy. Using an installed policy requires no package at all, because the account enforces it - see Integration.
Deployed contract instances
The deployed addresses are listed under What is actually running in the repository README, covering the policy interpreter on testnet and mainnet and the three OpenZeppelin built-in policy instances on both networks.
The interpreter address and the wasm hash of every deployed instance are also
pinned in code, in packages/policy-synth/src/run/schemas.ts, so consumers
import the pin rather than copying an address. Contract IDs are network-scoped:
querying a testnet ID against mainnet returns Error(Storage, MissingValue),
which reads exactly like "nothing is deployed there".
The built-in policy instances are OpenZeppelin example contracts, built by us
from OpenZeppelin/stellar-contracts at tag v0.7.2 and deployed by us. We have
not audited them, and upstream ships an "experimental software, as is"
disclaimer. An independent audit of our own synthesizer and interpreter is in
progress; findings will be published in the repository.
Further reading
- User Guide — delegating a key under a policy, and what a policy does not protect.
- Developer Guide — the MCP server, its tools, and integrating the synthesizer.
docs/architecture.mdanddocs/stride-threat-model.mdin the repository.