@crediolabs/policy-synth 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -5
- package/dist/adapters/interpreter/adapter.d.ts +38 -0
- package/dist/adapters/interpreter/adapter.js +522 -0
- package/dist/adapters/interpreter/index.d.ts +1 -0
- package/dist/adapters/interpreter/index.js +2 -0
- package/dist/adapters/oz/adapter.js +2 -0
- package/dist/codegen/compile-gate.d.ts +33 -0
- package/dist/codegen/compile-gate.js +119 -0
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/template.d.ts +18 -0
- package/dist/codegen/template.js +131 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ir/types.d.ts +13 -2
- package/dist/predicate/encode.d.ts +10 -0
- package/dist/predicate/encode.js +249 -0
- package/dist/predicate/index.d.ts +1 -0
- package/dist/predicate/index.js +2 -0
- package/dist/record/decode.d.ts +6 -3
- package/dist/record/decode.js +13 -3
- package/dist/review-card/builder.d.ts +14 -0
- package/dist/review-card/builder.js +261 -0
- package/dist/review-card/conflict.d.ts +40 -0
- package/dist/review-card/conflict.js +111 -0
- package/dist/review-card/cross-check.d.ts +11 -0
- package/dist/review-card/cross-check.js +148 -0
- package/dist/review-card/index.d.ts +3 -0
- package/dist/review-card/index.js +4 -0
- package/dist/run/index.d.ts +31 -0
- package/dist/run/index.js +164 -0
- package/dist/run/schemas.d.ts +2003 -0
- package/dist/run/schemas.js +215 -0
- package/dist/synth/compose-from-recording.d.ts +35 -7
- package/dist/synth/compose-from-recording.js +225 -34
- package/dist/synth/deny-cases.d.ts +12 -0
- package/dist/synth/deny-cases.js +377 -0
- package/dist/synth/evaluate.d.ts +39 -0
- package/dist/synth/evaluate.js +425 -0
- package/dist/synth/harness.d.ts +16 -0
- package/dist/synth/harness.js +26 -0
- package/dist/synth/index.d.ts +4 -0
- package/dist/synth/index.js +4 -0
- package/dist/synth/minimize.d.ts +4 -0
- package/dist/synth/minimize.js +38 -0
- package/dist/synth/predicate-literals.d.ts +5 -0
- package/dist/synth/predicate-literals.js +25 -0
- package/dist/synth/synthesize-from-recording.d.ts +37 -5
- package/dist/synth/synthesize-from-recording.js +550 -18
- package/dist/types.d.ts +40 -1
- package/dist/types.js +17 -0
- package/dist/verify/envelope.d.ts +15 -0
- package/dist/verify/envelope.js +22 -0
- package/dist/verify/index.d.ts +3 -0
- package/dist/verify/index.js +3 -0
- package/dist/verify/simulate.d.ts +31 -0
- package/dist/verify/simulate.js +258 -0
- package/dist/verify/verify.d.ts +21 -0
- package/dist/verify/verify.js +189 -0
- package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
- package/dist-cjs/adapters/interpreter/adapter.js +527 -0
- package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
- package/dist-cjs/adapters/interpreter/index.js +8 -0
- package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
- package/dist-cjs/adapters/oz/adapter.js +289 -0
- package/dist-cjs/adapters/oz/index.d.ts +1 -0
- package/dist-cjs/adapters/oz/index.js +8 -0
- package/dist-cjs/codegen/compile-gate.d.ts +33 -0
- package/dist-cjs/codegen/compile-gate.js +123 -0
- package/dist-cjs/codegen/index.d.ts +2 -0
- package/dist-cjs/codegen/index.js +14 -0
- package/dist-cjs/codegen/template.d.ts +18 -0
- package/dist-cjs/codegen/template.js +134 -0
- package/dist-cjs/errors.d.ts +37 -0
- package/dist-cjs/errors.js +3 -0
- package/dist-cjs/index.d.ts +11 -0
- package/dist-cjs/index.js +27 -0
- package/dist-cjs/ir/index.d.ts +1 -0
- package/dist-cjs/ir/index.js +3 -0
- package/dist-cjs/ir/types.d.ts +108 -0
- package/dist-cjs/ir/types.js +12 -0
- package/dist-cjs/mandate/index.d.ts +2 -0
- package/dist-cjs/mandate/index.js +6 -0
- package/dist-cjs/mandate/to-ir.d.ts +3 -0
- package/dist-cjs/mandate/to-ir.js +63 -0
- package/dist-cjs/mandate/types.d.ts +20 -0
- package/dist-cjs/mandate/types.js +9 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/predicate/encode.d.ts +10 -0
- package/dist-cjs/predicate/encode.js +252 -0
- package/dist-cjs/predicate/index.d.ts +1 -0
- package/dist-cjs/predicate/index.js +6 -0
- package/dist-cjs/record/decode.d.ts +79 -0
- package/dist-cjs/record/decode.js +398 -0
- package/dist-cjs/record/freshness.d.ts +17 -0
- package/dist-cjs/record/freshness.js +55 -0
- package/dist-cjs/record/index.d.ts +21 -0
- package/dist-cjs/record/index.js +166 -0
- package/dist-cjs/record/movements.d.ts +20 -0
- package/dist-cjs/record/movements.js +192 -0
- package/dist-cjs/record/rpc.d.ts +22 -0
- package/dist-cjs/record/rpc.js +74 -0
- package/dist-cjs/record/validate.d.ts +22 -0
- package/dist-cjs/record/validate.js +63 -0
- package/dist-cjs/registry/identify.d.ts +11 -0
- package/dist-cjs/registry/identify.js +87 -0
- package/dist-cjs/registry/index.d.ts +3 -0
- package/dist-cjs/registry/index.js +15 -0
- package/dist-cjs/registry/known-addresses.d.ts +16 -0
- package/dist-cjs/registry/known-addresses.js +53 -0
- package/dist-cjs/registry/protocols.d.ts +38 -0
- package/dist-cjs/registry/protocols.js +153 -0
- package/dist-cjs/review-card/builder.d.ts +14 -0
- package/dist-cjs/review-card/builder.js +264 -0
- package/dist-cjs/review-card/conflict.d.ts +40 -0
- package/dist-cjs/review-card/conflict.js +114 -0
- package/dist-cjs/review-card/cross-check.d.ts +11 -0
- package/dist-cjs/review-card/cross-check.js +151 -0
- package/dist-cjs/review-card/index.d.ts +3 -0
- package/dist-cjs/review-card/index.js +10 -0
- package/dist-cjs/run/index.d.ts +31 -0
- package/dist-cjs/run/index.js +178 -0
- package/dist-cjs/run/schemas.d.ts +2003 -0
- package/dist-cjs/run/schemas.js +218 -0
- package/dist-cjs/seams/index.d.ts +1 -0
- package/dist-cjs/seams/index.js +3 -0
- package/dist-cjs/seams/types.d.ts +66 -0
- package/dist-cjs/seams/types.js +12 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
- package/dist-cjs/synth/compose-from-recording.js +356 -0
- package/dist-cjs/synth/deny-cases.d.ts +12 -0
- package/dist-cjs/synth/deny-cases.js +380 -0
- package/dist-cjs/synth/evaluate.d.ts +39 -0
- package/dist-cjs/synth/evaluate.js +428 -0
- package/dist-cjs/synth/harness.d.ts +16 -0
- package/dist-cjs/synth/harness.js +29 -0
- package/dist-cjs/synth/index.d.ts +9 -0
- package/dist-cjs/synth/index.js +23 -0
- package/dist-cjs/synth/lower.d.ts +23 -0
- package/dist-cjs/synth/lower.js +119 -0
- package/dist-cjs/synth/minimize.d.ts +4 -0
- package/dist-cjs/synth/minimize.js +41 -0
- package/dist-cjs/synth/predicate-literals.d.ts +5 -0
- package/dist-cjs/synth/predicate-literals.js +28 -0
- package/dist-cjs/synth/scope.d.ts +26 -0
- package/dist-cjs/synth/scope.js +81 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
- package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
- package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
- package/dist-cjs/synth/synthesize-from-recording.js +711 -0
- package/dist-cjs/types.d.ts +288 -0
- package/dist-cjs/types.js +55 -0
- package/dist-cjs/verify/envelope.d.ts +15 -0
- package/dist-cjs/verify/envelope.js +23 -0
- package/dist-cjs/verify/index.d.ts +3 -0
- package/dist-cjs/verify/index.js +8 -0
- package/dist-cjs/verify/simulate.d.ts +31 -0
- package/dist-cjs/verify/simulate.js +261 -0
- package/dist-cjs/verify/verify.d.ts +21 -0
- package/dist-cjs/verify/verify.js +192 -0
- package/package.json +43 -3
- package/src/adapters/interpreter/adapter.ts +642 -0
- package/src/adapters/interpreter/index.ts +8 -0
- package/src/adapters/oz/adapter.ts +2 -0
- package/src/codegen/compile-gate.ts +162 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/template.ts +148 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +2 -0
- package/src/ir/types.ts +9 -2
- package/src/predicate/encode.ts +307 -0
- package/src/predicate/index.ts +3 -0
- package/src/record/decode.ts +21 -10
- package/src/review-card/builder.ts +303 -0
- package/src/review-card/conflict.ts +143 -0
- package/src/review-card/cross-check.ts +158 -0
- package/src/review-card/index.ts +12 -0
- package/src/run/index.ts +213 -0
- package/src/run/schemas.ts +242 -0
- package/src/synth/compose-from-recording.ts +277 -43
- package/src/synth/deny-cases.ts +466 -0
- package/src/synth/evaluate.ts +493 -0
- package/src/synth/harness.ts +40 -0
- package/src/synth/index.ts +12 -0
- package/src/synth/minimize.ts +44 -0
- package/src/synth/predicate-literals.ts +27 -0
- package/src/synth/synthesize-from-recording.ts +655 -19
- package/src/types.ts +38 -2
- package/src/verify/envelope.ts +28 -0
- package/src/verify/index.ts +5 -0
- package/src/verify/simulate.ts +311 -0
- package/src/verify/verify.ts +243 -0
|
@@ -1,27 +1,53 @@
|
|
|
1
1
|
// src/synth/synthesize-from-recording.ts - recording-path orchestrator.
|
|
2
2
|
//
|
|
3
3
|
// `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
|
|
4
|
-
// bounded policy from a `RecordedTransaction` via the same `PolicyIR` +
|
|
5
|
-
//
|
|
4
|
+
// bounded policy from a `RecordedTransaction` via the same `PolicyIR` + adapter
|
|
5
|
+
// pair used by the deterministic Mandate path. The flow:
|
|
6
6
|
//
|
|
7
7
|
// 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
|
|
8
8
|
// the caller may relax via `confidenceOverride.threshold`).
|
|
9
9
|
// 2. `lower(tx)` -> IntentFacts.
|
|
10
10
|
// 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
|
|
11
|
-
// 4. `composeFromRecording(facts, scope, opts)` ->
|
|
11
|
+
// 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
|
|
12
|
+
// interpreterIr (predicate-shape), ambiguities, warnings }.
|
|
12
13
|
// 5. `ozAdapter.compile(ir)` -> CompileResult.
|
|
13
|
-
// 6.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
14
|
+
// 6. If `interpreterIr.rules[0].constraints.length > 0` AND the caller
|
|
15
|
+
// opted in to the interpreter adapter:
|
|
16
|
+
// a. Run `interpreterAdapter.compile(interpreterIr)`.
|
|
17
|
+
// b. Throw -> SYNTHESIS_ERROR carrying the gate code
|
|
18
|
+
// (SCOPE_SELF_CALL, ORACLE_LEAF_INVALID_POSITION,
|
|
19
|
+
// ORACLE_PARAMS_OUT_OF_RANGE). Never install an OZ-only partial
|
|
20
|
+
// policy - a warning can be ignored and yields an over-broad rule.
|
|
21
|
+
// c. `covered === false` -> SYNTHESIS_ERROR carrying the uncovered
|
|
22
|
+
// descriptor (the user asked for the interpreter to enforce these
|
|
23
|
+
// constraints; refusing them silently is the audit failure mode).
|
|
24
|
+
// d. `covered === true` -> merge its PolicyDocument + PolicyRef into
|
|
25
|
+
// the OZ-shaped ProposedPolicy; the merged `contextRule.policies`
|
|
26
|
+
// is `[interpreterRef?, ...oz_builtinRefs]` and must satisfy
|
|
27
|
+
// `OZ_LIMITS.maxPoliciesPerRule` (= 5).
|
|
28
|
+
// 7. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
|
|
29
|
+
// (the OZ `uncovered` markers, prefixed), and ambiguities.
|
|
16
30
|
//
|
|
17
31
|
// Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
|
|
18
32
|
// No randomness, no clock, no globals.
|
|
19
33
|
|
|
34
|
+
import type { InterpreterAdapterConfig } from '../adapters/interpreter/adapter.ts'
|
|
35
|
+
import {
|
|
36
|
+
createInterpreterAdapter,
|
|
37
|
+
lowerRuleToPredicate,
|
|
38
|
+
PLACEHOLDER_INTERPRETER_ADDRESS,
|
|
39
|
+
} from '../adapters/interpreter/adapter.ts'
|
|
20
40
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
|
|
21
41
|
import { createOzAdapter } from '../adapters/oz/adapter.ts'
|
|
22
42
|
import type { ToolError, ToolResponse } from '../errors.ts'
|
|
43
|
+
import { encodePredicate } from '../predicate/encode.ts'
|
|
23
44
|
import {
|
|
45
|
+
type ContractInvocation,
|
|
46
|
+
MAX_SCVAL_CLONE_DEPTH,
|
|
24
47
|
type Network,
|
|
48
|
+
OZ_LIMITS,
|
|
49
|
+
type PredicateLeaf,
|
|
50
|
+
type PredicateNode,
|
|
25
51
|
type ProposedPolicy,
|
|
26
52
|
type RecordedTransaction,
|
|
27
53
|
SOROBAN_LIMITS,
|
|
@@ -31,31 +57,132 @@ import {
|
|
|
31
57
|
type ComposeUserResponses,
|
|
32
58
|
composeFromRecording,
|
|
33
59
|
} from './compose-from-recording.ts'
|
|
60
|
+
import { generateCases } from './deny-cases.ts'
|
|
61
|
+
import { type EvalContext, evaluate } from './evaluate.ts'
|
|
62
|
+
import { runHarness } from './harness.ts'
|
|
34
63
|
import { lower } from './lower.ts'
|
|
64
|
+
import { minimize } from './minimize.ts'
|
|
35
65
|
import { decideScope, type ScopeDecision } from './scope.ts'
|
|
36
66
|
|
|
37
67
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
|
|
38
68
|
|
|
69
|
+
/** Per-policy interpreter adapter config the caller opts into. Absent ->
|
|
70
|
+
* recording path runs in week-1 mode (OZ adapter only, warnings for the
|
|
71
|
+
* constraints OZ cannot express). */
|
|
72
|
+
export interface InterpreterAdapterOptions {
|
|
73
|
+
/** The smart account this interpreter policy will be installed against.
|
|
74
|
+
* Must be a C... contract address - the on-chain policy-bound account.
|
|
75
|
+
* The G... source account from the recording is a separate concept. */
|
|
76
|
+
smartAccountAddress: string
|
|
77
|
+
/** Per-rule install nonce (first install = 1). Defaults to 1. */
|
|
78
|
+
installNonce?: number
|
|
79
|
+
/** Per-policy oracle overrides. Tighten-only vs the wasm defaults
|
|
80
|
+
* (maxStalenessSeconds <= 600, maxDeviationBps <= 200); widening is
|
|
81
|
+
* rejected at the options boundary. */
|
|
82
|
+
oracleParams?: { maxStalenessSeconds?: number; maxDeviationBps?: number }
|
|
83
|
+
/** @internal Test-only seam. When set, the orchestrator uses this
|
|
84
|
+
* PredicateNode as the starting point for self-verify + minimise instead
|
|
85
|
+
* of calling the interpreter adapter's compile(). Production callers MUST
|
|
86
|
+
* NOT set this; the field exists so the self-verify pipeline can be
|
|
87
|
+
* exercised on hand-crafted predicates (redundant conjuncts for
|
|
88
|
+
* minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
|
|
89
|
+
* `smartAccountAddress` + `installNonce` + `oracleParams` are still
|
|
90
|
+
* carried through so the emitted PolicyRef + PolicyDocument match the
|
|
91
|
+
* recorded call shape. */
|
|
92
|
+
__testPredicateNode?: PredicateNode
|
|
93
|
+
}
|
|
94
|
+
|
|
39
95
|
/** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
|
|
40
96
|
* answers to the ambiguity prompts (windowSeconds, validUntilLedger,
|
|
41
|
-
* limitAmount, invocationLimit).
|
|
42
|
-
*
|
|
97
|
+
* limitAmount, invocationLimit, oraclePriceBound, swapRecipientAllowlist).
|
|
98
|
+
* `interpreter` opts the recording path into the interpreter adapter. */
|
|
43
99
|
export interface SynthesizeFromRecordingOptions {
|
|
44
100
|
network: Network
|
|
45
101
|
userResponses?: ComposeUserResponses
|
|
46
102
|
confidenceOverride?: { threshold: number }
|
|
103
|
+
interpreter?: InterpreterAdapterOptions
|
|
47
104
|
}
|
|
48
105
|
|
|
49
|
-
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
50
106
|
export function synthesizeFromRecording(
|
|
51
107
|
tx: RecordedTransaction,
|
|
52
108
|
opts: SynthesizeFromRecordingOptions,
|
|
53
109
|
ozConfig: OzAdapterConfig
|
|
110
|
+
): ToolResponse<ProposedPolicy> {
|
|
111
|
+
// Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
|
|
112
|
+
// with a string `.code`) inside the body is converted to a structured
|
|
113
|
+
// `{ok:false, error}`; anything else is rethrown so genuine bugs crash
|
|
114
|
+
// instead of being silently swallowed. The envelope must wrap the entire
|
|
115
|
+
// synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
|
|
116
|
+
// TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
|
|
117
|
+
// a structured ToolError rather than a thrown RangeError.
|
|
118
|
+
try {
|
|
119
|
+
return synthesizeFromRecordingInner(tx, opts, ozConfig)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (isToolErrorShape(e)) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: {
|
|
125
|
+
code: e.code as ToolError['code'],
|
|
126
|
+
message: e.message,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
retryable: false,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw e
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True when `e` is the ToolError-shaped throw the internal pipeline uses to
|
|
137
|
+
* signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
|
|
138
|
+
* depth-cap throws). Detected by a string `code` field — the only contract
|
|
139
|
+
* the body's internal helpers agree on. */
|
|
140
|
+
function isToolErrorShape(e: unknown): e is { code: string; message: string } {
|
|
141
|
+
if (e === null || typeof e !== 'object') return false
|
|
142
|
+
const code = (e as { code?: unknown }).code
|
|
143
|
+
const message = (e as { message?: unknown }).message
|
|
144
|
+
return typeof code === 'string' && typeof message === 'string'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** ToolError-shaped error helper used by the body to surface a structured
|
|
148
|
+
* failure that the envelope converts to `{ok:false, error}`. */
|
|
149
|
+
function throwToolError(code: ToolError['code'], message: string): never {
|
|
150
|
+
const err = new Error(message) as Error & { code: string; severity: string; retryable: boolean }
|
|
151
|
+
err.code = code
|
|
152
|
+
err.severity = 'error'
|
|
153
|
+
err.retryable = false
|
|
154
|
+
throw err
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
158
|
+
function synthesizeFromRecordingInner(
|
|
159
|
+
tx: RecordedTransaction,
|
|
160
|
+
opts: SynthesizeFromRecordingOptions,
|
|
161
|
+
ozConfig: OzAdapterConfig
|
|
54
162
|
): ToolResponse<ProposedPolicy> {
|
|
55
163
|
// 0. validate inputs (fail closed - never synthesize from garbage).
|
|
56
164
|
const invalid = validateOptions(opts)
|
|
57
165
|
if (invalid) return { ok: false, error: invalid }
|
|
58
166
|
|
|
167
|
+
// 0a. per-movement amount validation (item 2). Synthesizing from a recording
|
|
168
|
+
// whose `tokenMovements[].amount` does not match the canonical positive
|
|
169
|
+
// decimal integer format is a malformed-input failure, not a runtime
|
|
170
|
+
// one - reject it here so the downstream `BigInt(m.amount)` in
|
|
171
|
+
// `buildPermitContext` cannot throw a SyntaxError past the envelope.
|
|
172
|
+
for (const [i, m] of tx.tokenMovements.entries()) {
|
|
173
|
+
if (!/^[0-9]+$/.test(m.amount)) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: {
|
|
177
|
+
code: 'RECORDING_VALIDATION_FAILED',
|
|
178
|
+
message: `tokenMovements[${i}].amount must be a positive decimal integer string, got: ${m.amount}`,
|
|
179
|
+
severity: 'error',
|
|
180
|
+
retryable: false,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
59
186
|
// 1. parseConfidence gate.
|
|
60
187
|
const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed
|
|
61
188
|
if (tx.parseConfidence.overall < threshold) {
|
|
@@ -98,9 +225,6 @@ export function synthesizeFromRecording(
|
|
|
98
225
|
if (!scopeRes.ok) return scopeRes
|
|
99
226
|
const scope: ScopeDecision = scopeRes.data
|
|
100
227
|
if (scope.kind !== 'call_contract') {
|
|
101
|
-
// A default scope on the recording-path is not currently a covered flow;
|
|
102
|
-
// surface SYNTHESIS_ERROR so the orchestrator never silently emits an
|
|
103
|
-
// over-broad default-scoped policy.
|
|
104
228
|
return {
|
|
105
229
|
ok: false,
|
|
106
230
|
error: {
|
|
@@ -116,13 +240,14 @@ export function synthesizeFromRecording(
|
|
|
116
240
|
const topLevel = tx.invocations[0] ?? null
|
|
117
241
|
const composeOpts: ComposeOptions = {
|
|
118
242
|
network: opts.network,
|
|
243
|
+
interpreterEnabled: opts.interpreter !== undefined,
|
|
119
244
|
...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
|
|
120
245
|
}
|
|
121
246
|
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
|
|
122
247
|
|
|
123
|
-
// 5. OZ compile.
|
|
124
|
-
const
|
|
125
|
-
const compileRes =
|
|
248
|
+
// 5. OZ compile (always runs).
|
|
249
|
+
const ozAdapter = createOzAdapter(ozConfig)
|
|
250
|
+
const compileRes = ozAdapter.compile(composed.ir)
|
|
126
251
|
|
|
127
252
|
if (!compileRes.proposed) {
|
|
128
253
|
return {
|
|
@@ -137,12 +262,263 @@ export function synthesizeFromRecording(
|
|
|
137
262
|
}
|
|
138
263
|
}
|
|
139
264
|
|
|
140
|
-
// 6.
|
|
265
|
+
// 6. Interpreter compile (opt-in; fail-closed when the user routed real
|
|
266
|
+
// restrictions to the interpreter).
|
|
267
|
+
const interpreterOpts = opts.interpreter
|
|
268
|
+
|
|
269
|
+
let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
|
|
270
|
+
let interpreterPolicyRef: ProposedPolicy['policyRefs'][number] | null = null
|
|
271
|
+
|
|
272
|
+
if (interpreterOpts) {
|
|
273
|
+
const interpreterConfig: InterpreterAdapterConfig = {
|
|
274
|
+
network: opts.network,
|
|
275
|
+
installNonce: interpreterOpts.installNonce ?? 1,
|
|
276
|
+
smartAccountAddress: interpreterOpts.smartAccountAddress,
|
|
277
|
+
...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let startingPredicate: PredicateNode | null = null
|
|
281
|
+
const testSeam = interpreterOpts.__testPredicateNode
|
|
282
|
+
if (testSeam !== undefined) {
|
|
283
|
+
startingPredicate = testSeam
|
|
284
|
+
} else {
|
|
285
|
+
const interpreterAdapter = createInterpreterAdapter(interpreterConfig)
|
|
286
|
+
|
|
287
|
+
let interpreterRes: Awaited<ReturnType<typeof interpreterAdapter.compile>>
|
|
288
|
+
try {
|
|
289
|
+
interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
|
|
290
|
+
} catch (e) {
|
|
291
|
+
const code = (e as { code?: ToolError['code'] }).code
|
|
292
|
+
const allowedCodes: ToolError['code'][] = [
|
|
293
|
+
'SCOPE_SELF_CALL',
|
|
294
|
+
'ORACLE_LEAF_INVALID_POSITION',
|
|
295
|
+
'ORACLE_PARAMS_OUT_OF_RANGE',
|
|
296
|
+
'MALFORMED_PREDICATE',
|
|
297
|
+
'PREDICATE_TOO_LARGE',
|
|
298
|
+
'PREDICATE_TOO_DEEP',
|
|
299
|
+
'TOO_MANY_LEAVES',
|
|
300
|
+
'IN_OPERAND_LIMIT',
|
|
301
|
+
'PREDICATE_ORACLE_OVER_LIMIT',
|
|
302
|
+
]
|
|
303
|
+
const surfacedCode = code && allowedCodes.includes(code) ? code : 'SYNTHESIS_ERROR'
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: {
|
|
307
|
+
code: surfacedCode,
|
|
308
|
+
message: `interpreter predicate could not be compiled: ${(e as Error).message}`,
|
|
309
|
+
severity: 'error',
|
|
310
|
+
retryable: false,
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!interpreterRes.covered) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: {
|
|
319
|
+
code: 'SYNTHESIS_ERROR',
|
|
320
|
+
message: `interpreter predicate is not fully covered: ${interpreterRes.uncovered.join('; ')}`,
|
|
321
|
+
severity: 'error',
|
|
322
|
+
retryable: false,
|
|
323
|
+
details: { uncovered: interpreterRes.uncovered },
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!interpreterRes.proposed) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
error: {
|
|
332
|
+
code: 'SYNTHESIS_ERROR',
|
|
333
|
+
message: 'interpreter adapter returned no installable policy',
|
|
334
|
+
severity: 'error',
|
|
335
|
+
retryable: false,
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interpreterPolicyDocument = interpreterRes.proposed.policyDocuments[0] ?? null
|
|
341
|
+
interpreterPolicyRef = interpreterRes.proposed.policyRefs[0] ?? null
|
|
342
|
+
if (!interpreterPolicyDocument || !interpreterPolicyRef) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
error: {
|
|
346
|
+
code: 'SYNTHESIS_ERROR',
|
|
347
|
+
message: 'interpreter adapter returned a policy missing the document or ref',
|
|
348
|
+
severity: 'error',
|
|
349
|
+
retryable: false,
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const firstInterpreterRule = composed.interpreterIr.rules[0]
|
|
355
|
+
if (!firstInterpreterRule) {
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
error: {
|
|
359
|
+
code: 'SYNTHESIS_ERROR',
|
|
360
|
+
message: 'interpreter IR has no rules to lower',
|
|
361
|
+
severity: 'error',
|
|
362
|
+
retryable: false,
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
startingPredicate = lowerRuleToPredicate(firstInterpreterRule, interpreterConfig)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!startingPredicate) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
error: {
|
|
373
|
+
code: 'SYNTHESIS_ERROR',
|
|
374
|
+
message: 'interpreter predicate was not derived',
|
|
375
|
+
severity: 'error',
|
|
376
|
+
retryable: false,
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 6b. Self-verify + minimise.
|
|
382
|
+
if (!topLevel) {
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
error: {
|
|
386
|
+
code: 'SYNTHESIS_ERROR',
|
|
387
|
+
message: 'self-verify requires a recorded top-level invocation',
|
|
388
|
+
severity: 'error',
|
|
389
|
+
retryable: false,
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (scope.kind !== 'call_contract') {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: {
|
|
397
|
+
code: 'SYNTHESIS_ERROR',
|
|
398
|
+
message: 'self-verify requires a call_contract scope',
|
|
399
|
+
severity: 'error',
|
|
400
|
+
retryable: false,
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
|
|
405
|
+
|
|
406
|
+
const finalPredicate: PredicateNode =
|
|
407
|
+
startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
|
|
408
|
+
|
|
409
|
+
const harnessCases = generateCases(finalPredicate, permitCtx)
|
|
410
|
+
const harnessResult = runHarness(finalPredicate, harnessCases)
|
|
411
|
+
if (!harnessResult.ok) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: {
|
|
415
|
+
code: 'DENY_CASE_FAILURE',
|
|
416
|
+
message: `self-verify harness failed for the interpreter predicate (${harnessResult.failures.length} failure(s))`,
|
|
417
|
+
severity: 'error',
|
|
418
|
+
retryable: false,
|
|
419
|
+
details: { failures: harnessResult.failures },
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const evalResult = evaluate(finalPredicate, permitCtx)
|
|
425
|
+
if (!evalResult.permit) {
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
error: {
|
|
429
|
+
code: 'DENY_CASE_FAILURE',
|
|
430
|
+
message: `intended recorded call was denied by the interpreter predicate: ${evalResult.reason}`,
|
|
431
|
+
severity: 'error',
|
|
432
|
+
retryable: false,
|
|
433
|
+
details: { reason: evalResult.reason },
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
439
|
+
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
440
|
+
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
441
|
+
// breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
|
|
442
|
+
// envelope converts them to a structured `{ok:false, error}`.
|
|
443
|
+
const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
|
|
444
|
+
|
|
445
|
+
if (testSeam !== undefined) {
|
|
446
|
+
interpreterPolicyDocument = {
|
|
447
|
+
grammarVersion: 1,
|
|
448
|
+
installNonce: interpreterOpts.installNonce ?? 1,
|
|
449
|
+
encodedPredicate,
|
|
450
|
+
predicateHash,
|
|
451
|
+
...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
|
|
452
|
+
}
|
|
453
|
+
interpreterPolicyRef = {
|
|
454
|
+
kind: 'interpreter',
|
|
455
|
+
interpreterAddress: PLACEHOLDER_INTERPRETER_ADDRESS,
|
|
456
|
+
predicateBlobBase64: encodedPredicate,
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
if (!interpreterPolicyDocument || !interpreterPolicyRef) {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
error: {
|
|
463
|
+
code: 'SYNTHESIS_ERROR',
|
|
464
|
+
message: 'interpreter policy document or ref was lost before re-encode',
|
|
465
|
+
severity: 'error',
|
|
466
|
+
retryable: false,
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
interpreterPolicyDocument = {
|
|
471
|
+
...interpreterPolicyDocument,
|
|
472
|
+
encodedPredicate,
|
|
473
|
+
predicateHash,
|
|
474
|
+
}
|
|
475
|
+
if (interpreterPolicyRef.kind === 'interpreter') {
|
|
476
|
+
interpreterPolicyRef = {
|
|
477
|
+
...interpreterPolicyRef,
|
|
478
|
+
predicateBlobBase64: encodedPredicate,
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// When the interpreter succeeds, OZ-side `uncovered` warnings that the
|
|
485
|
+
// interpreter actually lowered are misleading: OZ really did not lower them,
|
|
486
|
+
// but the interpreter did. Drop those so the user-facing warnings reflect
|
|
487
|
+
// what is still UN-enforced, not what OZ alone could not do.
|
|
488
|
+
const ozUncovered = interpreterPolicyRef
|
|
489
|
+
? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
|
|
490
|
+
: compileRes.uncovered
|
|
491
|
+
|
|
492
|
+
// 7. Merge into the OZ-shaped ProposedPolicy.
|
|
493
|
+
const ozRefs = compileRes.proposed.policyRefs
|
|
494
|
+
const mergedRefs: ProposedPolicy['policyRefs'] = []
|
|
495
|
+
if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
|
|
496
|
+
for (const r of ozRefs) mergedRefs.push(r)
|
|
497
|
+
|
|
498
|
+
if (mergedRefs.length > OZ_LIMITS.maxPoliciesPerRule) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
error: {
|
|
502
|
+
code: 'POLICY_CAP_EXCEEDED',
|
|
503
|
+
message: `merged policy count ${mergedRefs.length} exceeds OZ maxPoliciesPerRule (${OZ_LIMITS.maxPoliciesPerRule})`,
|
|
504
|
+
severity: 'error',
|
|
505
|
+
retryable: false,
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const mergedContextRule = {
|
|
511
|
+
...compileRes.proposed.contextRule,
|
|
512
|
+
policies: mergedRefs,
|
|
513
|
+
}
|
|
514
|
+
|
|
141
515
|
const proposed: ProposedPolicy = {
|
|
142
|
-
|
|
516
|
+
contextRule: mergedContextRule,
|
|
517
|
+
policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
|
|
518
|
+
policyRefs: mergedRefs,
|
|
143
519
|
parseConfidence: { ...tx.parseConfidence },
|
|
144
520
|
warnings: [
|
|
145
|
-
...
|
|
521
|
+
...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
146
522
|
...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
|
|
147
523
|
],
|
|
148
524
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
@@ -150,9 +526,27 @@ export function synthesizeFromRecording(
|
|
|
150
526
|
return { ok: true, data: proposed }
|
|
151
527
|
}
|
|
152
528
|
|
|
529
|
+
// Re-export the throwToolError helper for callers that need to surface a
|
|
530
|
+
// ToolError-shaped throw inside the body (e.g. tests).
|
|
531
|
+
export { throwToolError }
|
|
532
|
+
|
|
533
|
+
/** OZ-side `uncovered` warning patterns that the interpreter adapter
|
|
534
|
+
* actually lowers when wired in. When the interpreter adapter succeeds, we
|
|
535
|
+
* drop matching entries from the OZ uncovered list so the user-facing
|
|
536
|
+
* warnings reflect what is still UN-enforced rather than what OZ alone
|
|
537
|
+
* could not do. Match the exact descriptor strings the OZ adapter emits
|
|
538
|
+
* (see `src/adapters/oz/adapter.ts#describeCondition` /
|
|
539
|
+
* `describeSelector`). */
|
|
540
|
+
const INTERPRETER_COVERED_OZ_PATTERN =
|
|
541
|
+
/^per-method scoping to|^value allowlist on arg|^exact ordered sequence on arg|^oracle price condition on|^invocation-count window|^spending_limit on token .+ needs a CallContract context scoped to that token/
|
|
542
|
+
|
|
153
543
|
/** Reject non-sane inputs before any policy is synthesized. windowSeconds /
|
|
154
544
|
* validUntilLedger / invocationLimit must be positive integers; limitAmount a
|
|
155
|
-
* positive i128 decimal string; network mainnet|testnet.
|
|
545
|
+
* positive i128 decimal string; network mainnet|testnet. When the caller
|
|
546
|
+
* opts into the interpreter adapter, `smartAccountAddress` must be a C...
|
|
547
|
+
* contract address (the on-chain policy-bound account, NOT the G... source
|
|
548
|
+
* account from the recording), `installNonce` must be a positive integer
|
|
549
|
+
* (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
|
|
156
550
|
function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null {
|
|
157
551
|
if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
|
|
158
552
|
return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`)
|
|
@@ -189,9 +583,71 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
|
|
|
189
583
|
)
|
|
190
584
|
}
|
|
191
585
|
}
|
|
586
|
+
if (opts.interpreter) {
|
|
587
|
+
const sa = opts.interpreter.smartAccountAddress
|
|
588
|
+
if (typeof sa !== 'string' || sa.length === 0) {
|
|
589
|
+
return synthesisError(
|
|
590
|
+
`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
// Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
|
|
594
|
+
// C.../56-char shape check so a fixture/LLM-seam marker is reported
|
|
595
|
+
// with the specific placeholder error (not a generic "must be a
|
|
596
|
+
// C... contract" message). A real install routes a C... contract
|
|
597
|
+
// address derivable from the on-chain account; any
|
|
598
|
+
// 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
|
|
599
|
+
// fixture marker that must never reach the install payload.
|
|
600
|
+
if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
|
|
601
|
+
return synthesisError(
|
|
602
|
+
`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
if (!isContractAddress(sa)) {
|
|
606
|
+
return synthesisError(
|
|
607
|
+
`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
const nonce = opts.interpreter.installNonce
|
|
611
|
+
// Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
|
|
612
|
+
// u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
|
|
613
|
+
// the validUntilLedger SOROBAN_LIMITS.u32Max check above.
|
|
614
|
+
if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
|
|
615
|
+
return synthesisError(
|
|
616
|
+
`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
const op = opts.interpreter.oracleParams
|
|
620
|
+
if (op) {
|
|
621
|
+
const MAX_STALE = 600
|
|
622
|
+
const MAX_DEV = 200
|
|
623
|
+
if (
|
|
624
|
+
op.maxStalenessSeconds !== undefined &&
|
|
625
|
+
(!isPositiveInt(op.maxStalenessSeconds) || op.maxStalenessSeconds > MAX_STALE)
|
|
626
|
+
) {
|
|
627
|
+
return synthesisError(
|
|
628
|
+
`interpreter.oracleParams.maxStalenessSeconds must be a positive integer <= ${MAX_STALE} (tighten-only), got: ${op.maxStalenessSeconds}`
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
if (
|
|
632
|
+
op.maxDeviationBps !== undefined &&
|
|
633
|
+
(!isPositiveInt(op.maxDeviationBps) || op.maxDeviationBps > MAX_DEV)
|
|
634
|
+
) {
|
|
635
|
+
return synthesisError(
|
|
636
|
+
`interpreter.oracleParams.maxDeviationBps must be a positive integer <= ${MAX_DEV} (tighten-only), got: ${op.maxDeviationBps}`
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
192
641
|
return null
|
|
193
642
|
}
|
|
194
643
|
|
|
644
|
+
/** Placeholder/stub smart-account prefixes (item 5). Mirrors the
|
|
645
|
+
* `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
|
|
646
|
+
* the interpreter-contract strkey; a real install must point at a C...
|
|
647
|
+
* contract address derivable from the on-chain account, never a
|
|
648
|
+
* fixture/LLM-seam marker. */
|
|
649
|
+
const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
|
|
650
|
+
|
|
195
651
|
function isPositiveInt(n: number): boolean {
|
|
196
652
|
return Number.isInteger(n) && n > 0
|
|
197
653
|
}
|
|
@@ -206,6 +662,13 @@ function isPositiveI128(s: string): boolean {
|
|
|
206
662
|
}
|
|
207
663
|
}
|
|
208
664
|
|
|
665
|
+
/** True when `s` looks like a Stellar C... contract address (strkey). The
|
|
666
|
+
* source account from the recording is a G... account; the smart account is
|
|
667
|
+
* a separate C... contract. */
|
|
668
|
+
function isContractAddress(s: string): boolean {
|
|
669
|
+
return s.startsWith('C') && s.length === 56
|
|
670
|
+
}
|
|
671
|
+
|
|
209
672
|
function synthesisError(message: string): ToolError {
|
|
210
673
|
return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false }
|
|
211
674
|
}
|
|
@@ -224,3 +687,176 @@ function mergeAmbiguities(
|
|
|
224
687
|
}
|
|
225
688
|
return out
|
|
226
689
|
}
|
|
690
|
+
|
|
691
|
+
/** Build the permit `EvalContext` the self-verify harness drives. The shape
|
|
692
|
+
* mirrors the intended recorded call (the only call the user actually
|
|
693
|
+
* performed) so:
|
|
694
|
+
* - `evaluate(predicate, ctx).permit === true` must hold (the predicate
|
|
695
|
+
* permits the recorded call it was derived from); a failure surfaces
|
|
696
|
+
* as DENY_CASE_FAILURE.
|
|
697
|
+
* - `generateCases(predicate, ctx)` produces a deny battery that reflects
|
|
698
|
+
* the actual recorded move (real amount, real args, real window start).
|
|
699
|
+
* Amounts are summed per-token over all movements of the recorded tx
|
|
700
|
+
* (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
|
|
701
|
+
* contains a price+timestamp satisfying each `oracle_price` leaf in the
|
|
702
|
+
* predicate so the intended call permits under every bound; the harness
|
|
703
|
+
* then mutates those entries (stale / missing / deviation / paused) to
|
|
704
|
+
* exercise the ORACLE_* deny paths. */
|
|
705
|
+
function buildPermitContext(
|
|
706
|
+
tx: RecordedTransaction,
|
|
707
|
+
scope: Extract<ScopeDecision, { kind: 'call_contract' }>,
|
|
708
|
+
topLevel: ContractInvocation,
|
|
709
|
+
userResponses: ComposeUserResponses | undefined,
|
|
710
|
+
predicate: PredicateNode
|
|
711
|
+
): EvalContext {
|
|
712
|
+
const amountByToken: Record<string, string> = {}
|
|
713
|
+
const totals = new Map<string, bigint>()
|
|
714
|
+
for (const m of tx.tokenMovements) {
|
|
715
|
+
const current = totals.get(m.token) ?? 0n
|
|
716
|
+
totals.set(m.token, current + BigInt(m.amount))
|
|
717
|
+
}
|
|
718
|
+
for (const [token, total] of totals) {
|
|
719
|
+
amountByToken[token] = total.toString()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = oracleSatisfyingPrices(
|
|
723
|
+
predicate,
|
|
724
|
+
tx.fetchedAt
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
const ctx: EvalContext = {
|
|
728
|
+
contract: scope.contract,
|
|
729
|
+
fn: topLevel.fn,
|
|
730
|
+
args: topLevel.args.map(cloneScVal),
|
|
731
|
+
atLedger: tx.ledgerSequence,
|
|
732
|
+
nowSeconds: tx.fetchedAt,
|
|
733
|
+
amountByToken,
|
|
734
|
+
windowSpentByToken: {},
|
|
735
|
+
invocationCountByWindow: {},
|
|
736
|
+
oraclePriceByAsset,
|
|
737
|
+
}
|
|
738
|
+
if (userResponses?.validUntilLedger !== undefined) {
|
|
739
|
+
ctx.validUntilLedger = userResponses.validUntilLedger
|
|
740
|
+
}
|
|
741
|
+
return ctx
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function cloneScVal(
|
|
745
|
+
value: { type: string; value: unknown },
|
|
746
|
+
depth = 0
|
|
747
|
+
): ContractInvocation['args'][number] {
|
|
748
|
+
// EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
|
|
749
|
+
// (decoded by the recorder). We clone top-level shells so the harness can
|
|
750
|
+
// mutate deny cases without aliasing the recorded call. Recursion is
|
|
751
|
+
// bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
|
|
752
|
+
// cannot RangeError the JS stack; the over-depth branch throws a
|
|
753
|
+
// ToolError-shaped error that the `synthesizeFromRecording` envelope
|
|
754
|
+
// (item 3) converts to `{ok:false, error}`.
|
|
755
|
+
if (value.type === 'vec') {
|
|
756
|
+
if (depth >= MAX_SCVAL_CLONE_DEPTH) {
|
|
757
|
+
throw cloneDepthError(value)
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
type: 'vec',
|
|
761
|
+
value: (value.value as ContractInvocation['args'][number][]).map((v) =>
|
|
762
|
+
cloneScVal(v, depth + 1)
|
|
763
|
+
),
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return { ...value } as ContractInvocation['args'][number]
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
|
|
770
|
+
* The shape (object with string `code`) is what the `synthesizeFromRecording`
|
|
771
|
+
* envelope detects and converts to a structured `{ok:false, error}`. */
|
|
772
|
+
function cloneDepthError(value: { type: string; value: unknown }): never {
|
|
773
|
+
const err = new Error(
|
|
774
|
+
`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
|
|
775
|
+
) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
|
|
776
|
+
err.code = 'SYNTHESIS_ERROR'
|
|
777
|
+
err.severity = 'error'
|
|
778
|
+
err.retryable = false
|
|
779
|
+
// Tag the offending value so failures are diagnosable.
|
|
780
|
+
err.depthContext = value.type
|
|
781
|
+
throw err
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Walk every `oracle_price` leaf in the predicate and return a price map
|
|
785
|
+
* whose entries satisfy the bound so the intended call permits. The
|
|
786
|
+
* timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
|
|
787
|
+
* fresh-oracle deny case in `generateCases` is the only path that flips
|
|
788
|
+
* this map. Negatives are clamped at 0 - oracle prices are non-negative on
|
|
789
|
+
* Stellar. */
|
|
790
|
+
function oracleSatisfyingPrices(
|
|
791
|
+
predicate: PredicateNode,
|
|
792
|
+
nowSeconds: number
|
|
793
|
+
): EvalContext['oraclePriceByAsset'] {
|
|
794
|
+
const out: EvalContext['oraclePriceByAsset'] = {}
|
|
795
|
+
visitOracleLeaves(predicate, (asset, op, bound) => {
|
|
796
|
+
let price: bigint
|
|
797
|
+
switch (op) {
|
|
798
|
+
case 'lt':
|
|
799
|
+
case 'gt':
|
|
800
|
+
price = op === 'lt' ? bound - 1n : bound + 1n
|
|
801
|
+
break
|
|
802
|
+
case 'lte':
|
|
803
|
+
case 'gte':
|
|
804
|
+
case 'eq':
|
|
805
|
+
price = bound
|
|
806
|
+
break
|
|
807
|
+
}
|
|
808
|
+
if (price < 0n) price = 0n
|
|
809
|
+
out[asset] = { price: price.toString(), timestampSeconds: nowSeconds }
|
|
810
|
+
})
|
|
811
|
+
return out
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function visitOracleLeaves(
|
|
815
|
+
node: PredicateNode,
|
|
816
|
+
visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void
|
|
817
|
+
): void {
|
|
818
|
+
switch (node.op) {
|
|
819
|
+
case 'and':
|
|
820
|
+
case 'or':
|
|
821
|
+
for (const child of node.children) visitOracleLeaves(child, visit)
|
|
822
|
+
return
|
|
823
|
+
case 'not':
|
|
824
|
+
visitOracleLeaves(node.child, visit)
|
|
825
|
+
return
|
|
826
|
+
case 'eq':
|
|
827
|
+
case 'lt':
|
|
828
|
+
case 'lte':
|
|
829
|
+
case 'gt':
|
|
830
|
+
case 'gte': {
|
|
831
|
+
const leftLeaf: PredicateLeaf = node.left
|
|
832
|
+
const rightLeaf: PredicateLeaf = node.right
|
|
833
|
+
const leftIsOracle = leftLeaf.kind === 'oracle_price'
|
|
834
|
+
const rightIsOracle = rightLeaf.kind === 'oracle_price'
|
|
835
|
+
let oracleAsset: string | undefined
|
|
836
|
+
let literal: bigint | undefined
|
|
837
|
+
if (leftIsOracle) {
|
|
838
|
+
oracleAsset = leftLeaf.asset
|
|
839
|
+
literal = oracleLiteralFromLeaf(rightLeaf)
|
|
840
|
+
} else if (rightIsOracle) {
|
|
841
|
+
oracleAsset = rightLeaf.asset
|
|
842
|
+
literal = oracleLiteralFromLeaf(leftLeaf)
|
|
843
|
+
}
|
|
844
|
+
if (oracleAsset === undefined || literal === undefined) return
|
|
845
|
+
visit(oracleAsset, node.op, literal)
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
case 'in':
|
|
849
|
+
// `in` is pure membership; oracle leaves inside haystacks are
|
|
850
|
+
// forbidden by the position rule, so there's nothing to set up here.
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function oracleLiteralFromLeaf(leaf: PredicateLeaf): bigint | undefined {
|
|
856
|
+
if (leaf.kind !== 'literal_i128') return undefined
|
|
857
|
+
try {
|
|
858
|
+
return BigInt(leaf.value)
|
|
859
|
+
} catch {
|
|
860
|
+
return undefined
|
|
861
|
+
}
|
|
862
|
+
}
|