Skip to main content

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.
  • SimulationsimulateTransaction returns a result without signing. Note: simulation mode has no raw events, so the recorder cannot perform its events cross-check and reduces parseConfidence. It fails closed at the default gate; callers must explicitly provide a confidenceOverride.

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-endInputDeterministic?
DeclarationStated spec (PolicyDeclaration, via declarePredicate)Yes — pure mapping, no network access
RecordingRecordedTransaction from step 1Yes — 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:

LeafBinds
call_contractwhich contract is called
call_fnwhich method — per-method scoping
call_arga positional argument, with eq or in for allowlists
call_arg_fielda value nested inside a struct argument
call_arg_lenthe length of a list argument
call_arg_scaledone 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.

Attaching a policy changes who can act alone

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

Audit status

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