@crediolabs/policy-synth
v0.1.16
Published
Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.
Maintainers
Readme
@crediolabs/policy-synth
Off-chain TypeScript synthesis core for the OpenZeppelin Accounts Policy Builder.
It records a Soroban transaction (from an on-chain hash or a raw envelope XDR),
synthesises the minimal policy that permits exactly that flow, and compiles it
through the OZ Accounts adapter into a proposed policy. When the policy needs
constraint shapes OZ built-ins cannot express (exact ordered swap paths, oracle
price bounds, per-method scoping, recipient allowlists), the interpreter
adapter is opted in to emit a parallel predicate-shaped PolicyDocument that
installs alongside the OZ primitives. The synthesis is self-verified end-to-end
via simulatePolicy / verifyPolicy and the runner of the deny-case harness
before any bytes are emitted.
The package is pure ESM, node-compatible, and has a single runtime dependency
(@stellar/stellar-sdk). MIT-licensed.
Install
npm install @crediolabs/policy-synthUsage
There are two front-ends, both co-equal:
- Recording — decode a real transaction, then infer the minimal policy.
- Mandate — a declarative spec that lowers deterministically (no inference).
import {
recordTransaction,
synthesizeFromRecording,
synthesizeFromMandate,
placeholderOzConfig,
type MandateSpec,
} from '@crediolabs/policy-synth'
const oz = placeholderOzConfig('mainnet')
// --- Recording front-end -------------------------------------------------
const recorded = await recordTransaction({ network: 'mainnet', hash: '<tx-hash>' })
if (!recorded.ok) throw new Error(recorded.error.message)
const inferred = synthesizeFromRecording(recorded.data, { network: 'mainnet' }, oz)
if (inferred.ok) console.log(inferred.data)
// --- Mandate front-end (deterministic) -----------------------------------
const spec: MandateSpec = {
chain: 'stellar',
contract: 'CTOKEN',
method: 'transfer',
spendingLimit: { token: 'CTOKEN', limit: '5000000', windowSeconds: 2592000 },
}
const deterministic = synthesizeFromMandate(spec, oz)
if (deterministic.ok) console.log(deterministic.data)Every entry point returns a discriminated ToolResponse<T>:
type ToolResponse<T> = { ok: true; data: T } | { ok: false; error: ToolError }ToolError carries a machine-readable code, a message, a severity, and a
retryable flag, so callers (and agents) can branch without parsing prose.
Recording modes and the confidence gate
- On-chain (
hashset): fetched via the injected RPC fetcher (default: the public Soroban RPC for the requestednetwork). Pass your ownfetcherto use a custom endpoint or to test offline. - Simulation / XDR (
xdrset): the envelope XDR is decoded directly.
XDR/simulation mode has no raw on-chain events, so the recorder cannot run its
events cross-check and lowers parseConfidence. It therefore fails closed at
the default threshold; to accept a simulation-only recording, pass an explicit
confidenceOverride that clears the gate.
Interpreter predicate emission (the interpreter opt-in)
OZ built-ins express spending limits, simple thresholds, and weighted
thresholds. They cannot express an exact ordered swap path, an oracle
price bound, per-method scoping, a recipient allowlist, or an
invocation-count window. The recording path surfaces those gaps as warnings
by default ("Not covered by OZ built-in primitives: ..."). When the caller
opts in to the interpreter adapter, the same constraint set is routed to
the parallel interpreter IR; the adapter compiles it into a canonical
predicate PolicyDocument and merges it with the OZ refs. The byte blob on
the wire is the canonical XDR the on-chain interpreter will consume.
The opt-in is purely additive — every ToolResponse shape is unchanged; the
two new fields are policyDocuments (the predicate-shaped interpreter doc)
and one policyRef of kind: 'interpreter'.
import { Address } from '@stellar/stellar-sdk'
import {
synthesizeFromRecording,
placeholderOzConfig,
} from '@crediolabs/policy-synth'
const oz = placeholderOzConfig('mainnet')
const smartAccount = Address.contract(Buffer.alloc(32, 0xee)).toString()
const result = synthesizeFromRecording(
recordedTx,
{
network: 'mainnet',
userResponses: {
windowSeconds: 2592000, // 30 days
limitAmount: '1000000000', // supplied cap
validUntilLedger: 200000000, // future ledger
oraclePriceBound: [ // optional oracle bound
{ asset: 'CEURC', operator: 'lt', value: '1000000000' },
],
swapRecipientAllowlist: ['GOWNER'], // optional allowlist
},
interpreter: {
smartAccountAddress: smartAccount, // MUST be a C... contract address
installNonce: 1, // first install -> 1
// oracleParams: { maxStalenessSeconds: 60, maxDeviationBps: 100 }
// (tighten-only vs the wasm defaults; widening is rejected)
},
},
oz
)
if (result.ok) {
console.log(result.data.policyDocuments.length) // >= 1 when constraints are routable
const interpreterRef = result.data.policyRefs.find((r) => r.kind === 'interpreter')
console.log(interpreterRef?.predicateBlobBase64) // canonical XDR, base64
console.log(result.data.contextRule.validUntilLedger)
}The interpreter compile path is fail-closed:
SCOPE_SELF_CALL— the call's recipient equals the smart account.ORACLE_LEAF_INVALID_POSITION— an oracle leaf is wrongly nested.ORACLE_PARAMS_OUT_OF_RANGE—oracleParamswidening vs the wasm defaults.SYNTHESIS_ERROR— the interpreter IR is not fully covered.DENY_CASE_FAILURE— the emitted predicate fails the deny-case battery;details.failureslists the flipped dimension(s).
A recorded swap that compiles to a permissive policy under OZ alone therefore stays permissive unless the interpreter opt-in is supplied AND the predicate self-verifies end-to-end.
Self-verify + minimise (always-on with the opt-in)
Opting in to the interpreter also turns on the self-verify pipeline:
- The adapter emits the candidate predicate.
- The synth builds a permit
EvalContextfrom the recorded transaction (the only call the user actually performed). - The candidate is minimised — load-bearing-free top-level conjuncts are
dropped (
andpredicates only; other shapes are returned unchanged). - The minimised predicate is run through the deny-case battery — a structural
fingerprint across
contract,function,args,amount,window,oracle,recipient,frequency. Each case must deny. - The intended recorded call is evaluated against the predicate; it must permit.
- The (possibly minimised) predicate is re-encoded; the canonical bytes + the
SHA-256 hash are stamped back onto the
PolicyDocumentand theinterpreterpolicyRef.
A successful ok: true is the proof that the emitted document is minimal AND
self-verified. A failure surfaces the matching gate code (see above).
Simulate and verify (the verify/ surface)
The same self-verify pipeline is exposed as a public API for callers that want to re-run a check on a proposed predicate without re-synthesising:
import { simulatePolicy, verifyPolicy } from '@crediolabs/policy-synth'
// Runtime check: re-evaluate the predicate against the recorded call.
const runtime = simulatePolicy(predicate, recordedTx, {
validUntilLedger: 200000000,
oraclePricesByAsset: { CEURC: { price: '999999999', timestampSeconds: now } },
})
// Static minimality check: prove no top-level conjunct is load-bearing-free.
const staticCheck = verifyPolicy(predicate, recordedTx)The boundary is pinned:
SIMULATION_ERROR— runtime evaluation failed (malformed fixture, missing oracle price, uncontrolled throw). The policy may still be minimal.VERIFICATION_FAILED— the static minimality check failed. The policy is structurally over-broad regardless of how any concrete call evaluates.
Both are deterministic: same (predicate, recordedTx, opts) → byte-identical
envelope.
Review-card (the human-audit surface)
The package emits a deterministic review-card summary so a human auditor can sanity-check the inferred policy without re-running the synthesis:
import {
buildReviewCardSummary,
classifyConflict,
summaryCrossCheck,
} from '@crediolabs/policy-synth'
const summary = buildReviewCardSummary(proposedPolicy, recordedTx)
const conflict = classifyConflict(proposedPolicy, recordedTx)
const crossCheck = summaryCrossCheck(proposedPolicy, recordedTx)The summary is the canonical human-readable digest of the proposed policy;
classifyConflict flags refs that contradict the recording; summaryCrossCheck
re-derives the summary from the raw refs and the recording, and reports
discrepancies. All three are pure and deterministic.
Codegen escape hatch (the Rust interpreter)
When the canonical verifier is unavailable, the recorder can emit a Rust
source file that performs the same predicate evaluation off-chain via a
cargo build. The escape hatch is OUT of the audited happy path: the
synthesiser never calls generateRust itself; the CLI subcommand is the only
entry point.
import { generateRust, compileCheck, hasRustToolchain } from '@crediolabs/policy-synth'
if (await hasRustToolchain()) {
const { source, path } = generateRust(predicate, { out: 'policy.rs' })
const gate = await compileCheck({ crateDir: '.', predicate })
if (!gate.ok) console.error('compile gate failed:', gate.error)
}The escape hatch is toolchain-gated: hasRustToolchain() returns false
when cargo is not installed, and compileCheck() refuses to run on a
machine without one. The generated source is not the on-chain interpreter
— it is a deterministic off-chain reference that re-evaluates the same
predicate for parity testing.
Composition rules
The merged policyRefs on a ProposedPolicy are ordered
[interpreterRef?, ...oz_builtinRefs] and bounded by
OZ_LIMITS.maxPoliciesPerRule (5). The orchestrator refuses to install a
policy that exceeds this cap (POLICY_CAP_EXCEEDED). The OZ-side uncovered
warnings that the interpreter actually lowered (per-method scoping, recipient
allowlists, exact ordered sequences, oracle price bounds, invocation-count
windows, token-mismatch spending limits) are dropped from the user-facing
warnings when the interpreter succeeds — the warning list reflects what is
still UN-enforced, not what OZ alone could not do.
Status
Implemented and unit-test covered: the recorder, both synthesizer front-ends, the OZ Accounts adapter, the interpreter adapter, the predicate encoder, the evaluator, the deny-case battery, the minimiser, the self-verify pipeline, the simulate / verify surface, the review-card builder, the cross-check, and the Rust codegen escape hatch.
The on-chain Rust interpreter, install-transaction assembly, and live RPC integration are wired in the @crediolabs/policy-builder-cli layer (separate package).
License
MIT.
