Policy Diff
Before deploying a policy change, you want to know its blast radius: which previously-allowed actions will start being rejected, which previously-rejected actions will start being allowed, and which rejections will switch to a different reason. diffPolicies is a pure function that answers that question deterministically — no chain calls, no async, no side effects.
For historical impact analysis (run a proposed policy against your audit log), see Replay & Backtesting. For forward-looking impact analysis against a generated test suite, use this guide.
diffPolicies
import { diffPolicies } from '@txfence/core'
const diff = diffPolicies({
policyA: currentPolicy,
policyB: proposedPolicy,
actions: [
{ action: smallTransfer },
{ action: largeSwap, simulationResult: swapSim },
{ action: contractCall },
],
})
console.log(diff.summary)
// {
// total: 3,
// changed: 1,
// newlyAllowed: 0,
// newlyRejected: 1,
// rejectionReasonChanged: 0,
// unchanged: 2,
// requiresSimulation: 1, // actions whose checks need a simulationResult to be evaluated
// }requiresSimulation counts actions where at least one check depended on simulation output but no simulationResult was supplied — those checks are skipped, so the diff for those actions may be incomplete.
ActionDiffDirection
Every changed action falls into exactly one of three buckets:
type ActionDiffDirection =
| 'newly_allowed' // rejected by policyA, allowed by policyB
| 'newly_rejected' // allowed by policyA, rejected by policyB
| 'rejection_reason_changed' // both reject, but for different reasonsThe third case is easy to miss without a diff tool — both policies reject the action, so a binary “did it pass?” comparison shows no change, but the why changed. That signals a policy interaction you may not have intended.
ChangedCheck
Each ActionDiffResult includes the per-check breakdown:
type ChangedCheck = {
checkName: string
inA: boolean
inB: boolean
rejectionReasonA?: PolicyRejectionReason
rejectionReasonB?: PolicyRejectionReason
}inA / inB are whether the check ran in each policy. A check might be skipped entirely if its preconditions weren’t met (e.g. gas buffer is only checked when simulation succeeded).
createTestActions
Generating a covering action set by hand is tedious. createTestActions builds a minimal set from a policy: an action at the spend limit, an action over the limit, a swap for each allowed contract, a swap targeting an unlisted contract, and a swap with zero slippage.
import { createTestActions, diffPolicies } from '@txfence/core'
const actions = createTestActions(currentPolicy)
const diff = diffPolicies({ policyA: currentPolicy, policyB: proposedPolicy, actions })This is what the --generate-actions CLI flag does internally.
CLI
# Compare against a hand-rolled action set
txfence diff \
--config-a ./current.config.ts \
--config-b ./proposed.config.ts \
--actions-file ./test-actions.json
# Or auto-generate the action set from policy A
txfence diff \
--config-a ./current.config.ts \
--config-b ./proposed.config.ts \
--generate-actionsThe command exits 1 if any actions are newly rejected — drop it into CI as a gate on every policy change PR.
MCP tool
txfence_diff_policies exposes the same logic to AI assistants:
- Auto-generates test actions from
policyAwhen none are supplied - Returns a compact JSON summary plus per-action direction and reason changes
- Useful for “explain the blast radius of this change” prompts during a code review
Workflow
A typical policy change goes:
- Edit the policy file.
txfence diff --config-a ./current.ts --config-b ./proposed.ts --generate-actions— synthetic blast radius.txfence replay --audit-log ./audit.jsonl --config ./proposed.ts --only-changed— historical blast radius.- Review newly-rejected entries from both — are any of them legitimate?
- Merge if the diff is acceptable.
diff and replay use the same ChangedCheck type. The classification logic is shared, so a check that appears as newly_rejected in diff will surface identically in replay — you only learn one mental model.