@crediolabs/policy-synth 0.1.8 → 0.1.10
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/dist/adapters/interpreter/adapter.js +21 -3
- package/dist/adapters/oz/adapter.js +8 -0
- package/dist/ir/types.d.ts +9 -0
- package/dist/predicate/encode.js +9 -0
- package/dist/review-card/builder.js +32 -0
- package/dist/review-card/cross-check.js +40 -0
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +12 -0
- package/dist/run/schemas.js +14 -0
- package/dist/synth/compose-from-recording.js +89 -0
- package/dist/synth/deny-cases.js +89 -1
- package/dist/synth/evaluate.js +49 -0
- package/dist/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist/synth/synthesize-from-mandate.js +27 -2
- package/dist/synth/synthesize-from-recording.d.ts +25 -1
- package/dist/synth/synthesize-from-recording.js +66 -1
- package/dist/types.d.ts +8 -0
- package/dist-cjs/adapters/interpreter/adapter.js +21 -3
- package/dist-cjs/adapters/oz/adapter.js +8 -0
- package/dist-cjs/ir/types.d.ts +9 -0
- package/dist-cjs/predicate/encode.js +9 -0
- package/dist-cjs/review-card/builder.js +32 -0
- package/dist-cjs/review-card/cross-check.js +40 -0
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +12 -0
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/compose-from-recording.js +89 -0
- package/dist-cjs/synth/deny-cases.js +89 -1
- package/dist-cjs/synth/evaluate.js +49 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
- package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
- package/dist-cjs/synth/synthesize-from-recording.js +66 -1
- package/dist-cjs/types.d.ts +8 -0
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +20 -4
- package/src/adapters/oz/adapter.ts +8 -0
- package/src/ir/types.ts +8 -0
- package/src/predicate/encode.ts +9 -0
- package/src/review-card/builder.ts +28 -0
- package/src/review-card/cross-check.ts +40 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/compose-from-recording.ts +87 -0
- package/src/synth/deny-cases.ts +86 -1
- package/src/synth/evaluate.ts +41 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +103 -4
- package/src/types.ts +10 -0
package/dist-cjs/run/schemas.js
CHANGED
|
@@ -178,6 +178,11 @@ exports.SynthesizePolicyMandateInputSchema = zod_1.z.object({
|
|
|
178
178
|
source: zod_1.z.literal('mandate'),
|
|
179
179
|
mandate: exports.MandateSpecSchema,
|
|
180
180
|
ozConfig: exports.OzAdapterConfigSchema.optional(),
|
|
181
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
182
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
183
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
184
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
185
|
+
explain: zod_1.z.boolean().optional(),
|
|
181
186
|
});
|
|
182
187
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
183
188
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -203,6 +208,15 @@ exports.SynthesizePolicyRecordingInputSchema = zod_1.z.object({
|
|
|
203
208
|
confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
|
|
204
209
|
interpreter: exports.InterpreterOptionsSchema.optional(),
|
|
205
210
|
ozConfig: exports.OzAdapterConfigSchema.optional(),
|
|
211
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
212
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
213
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
214
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
215
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
216
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
217
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
218
|
+
// explain.
|
|
219
|
+
explain: zod_1.z.boolean().optional(),
|
|
206
220
|
});
|
|
207
221
|
exports.SynthesizePolicyInputSchema = zod_1.z.discriminatedUnion('source', [
|
|
208
222
|
exports.SynthesizePolicyMandateInputSchema,
|
|
@@ -279,6 +279,95 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
|
+
// Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
|
|
283
|
+
// reserve_token_ids - no map fields to bind). The `requests` vec
|
|
284
|
+
// (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
|
|
285
|
+
// Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
|
|
286
|
+
// 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
|
|
287
|
+
// liquidation/auction fills. Pinning only one element is unsafe: a caller
|
|
288
|
+
// can append a second element with a different action (WithdrawCollateral
|
|
289
|
+
// -> Borrow on a different asset, any amount, then auction fills). Length
|
|
290
|
+
// + per-element pinning is total; a quantifier over elements is not. If we
|
|
291
|
+
// cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
|
|
292
|
+
if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
|
|
293
|
+
const requestsArg = topLevel.args[3];
|
|
294
|
+
if (requestsArg && requestsArg.type === 'vec') {
|
|
295
|
+
const elements = requestsArg.value;
|
|
296
|
+
interpreterConstraints.push({
|
|
297
|
+
op: 'compare',
|
|
298
|
+
compare: {
|
|
299
|
+
selector: { kind: 'arg_len', argIndex: 3 },
|
|
300
|
+
operator: 'eq',
|
|
301
|
+
value: String(elements.length),
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
for (let i = 0; i < elements.length; i++) {
|
|
305
|
+
const element = elements[i];
|
|
306
|
+
if (!element || element.type !== 'map')
|
|
307
|
+
continue;
|
|
308
|
+
const fields = element.value;
|
|
309
|
+
const fieldValue = (name, scalarType) => {
|
|
310
|
+
const entry = fields.find((e) => e.key === name);
|
|
311
|
+
if (!entry)
|
|
312
|
+
return null;
|
|
313
|
+
if (entry.val.type === 'address' && scalarType === 'address')
|
|
314
|
+
return entry.val.value;
|
|
315
|
+
if (entry.val.type === 'i128' && scalarType === 'i128')
|
|
316
|
+
return entry.val.value;
|
|
317
|
+
if (entry.val.type === 'u32' && scalarType === 'u32')
|
|
318
|
+
return entry.val.value;
|
|
319
|
+
return null;
|
|
320
|
+
};
|
|
321
|
+
const address = fieldValue('address', 'address');
|
|
322
|
+
const amount = fieldValue('amount', 'i128');
|
|
323
|
+
const requestType = fieldValue('request_type', 'u32');
|
|
324
|
+
if (address === null || amount === null || requestType === null)
|
|
325
|
+
continue;
|
|
326
|
+
interpreterConstraints.push({
|
|
327
|
+
op: 'compare',
|
|
328
|
+
compare: {
|
|
329
|
+
selector: {
|
|
330
|
+
kind: 'arg_field',
|
|
331
|
+
argIndex: 3,
|
|
332
|
+
element: i,
|
|
333
|
+
field: 'request_type',
|
|
334
|
+
scalarType: 'u32',
|
|
335
|
+
},
|
|
336
|
+
operator: 'eq',
|
|
337
|
+
value: requestType,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
interpreterConstraints.push({
|
|
341
|
+
op: 'compare',
|
|
342
|
+
compare: {
|
|
343
|
+
selector: {
|
|
344
|
+
kind: 'arg_field',
|
|
345
|
+
argIndex: 3,
|
|
346
|
+
element: i,
|
|
347
|
+
field: 'address',
|
|
348
|
+
scalarType: 'address',
|
|
349
|
+
},
|
|
350
|
+
operator: 'eq',
|
|
351
|
+
value: address,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
interpreterConstraints.push({
|
|
355
|
+
op: 'compare',
|
|
356
|
+
compare: {
|
|
357
|
+
selector: {
|
|
358
|
+
kind: 'arg_field',
|
|
359
|
+
argIndex: 3,
|
|
360
|
+
element: i,
|
|
361
|
+
field: 'amount',
|
|
362
|
+
scalarType: 'i128',
|
|
363
|
+
},
|
|
364
|
+
operator: 'lte',
|
|
365
|
+
value: amount,
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
282
371
|
// SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
|
|
283
372
|
// The interpreter adapter is the ONLY way to express an exact ordered
|
|
284
373
|
// sequence (OZ built-ins cannot). Element order is preserved verbatim.
|
|
@@ -21,6 +21,15 @@ const ADJACENT_ASSETS = [
|
|
|
21
21
|
const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
|
|
22
22
|
exports.OVERPERMISSIVE_DIMENSIONS = OVERPERMISSIVE_DIMENSIONS;
|
|
23
23
|
// The 15 dimensions the synth pipeline uses for self-verify and minimise.
|
|
24
|
+
// Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
|
|
25
|
+
// alongside the existing dimensions so the per-element binds emitted for
|
|
26
|
+
// Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
|
|
27
|
+
// minimise. Without these, no deny case exercises the new leaves and the
|
|
28
|
+
// minimise pass drops them as "redundant" - reopening the element-append
|
|
29
|
+
// hole they were added to close. Both dimensions are no-ops for any fixture
|
|
30
|
+
// whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
|
|
31
|
+
// generator short-circuits on leaf-kind), so they cannot make production
|
|
32
|
+
// synthesis refuse for fixtures that do not exercise the new grammar.
|
|
24
33
|
const ORIGINAL_DIMENSIONS = [
|
|
25
34
|
'amount',
|
|
26
35
|
'asset',
|
|
@@ -37,6 +46,8 @@ const ORIGINAL_DIMENSIONS = [
|
|
|
37
46
|
'oracle_deviation_exceeded',
|
|
38
47
|
'oracle_paused',
|
|
39
48
|
'soroswap_allowed_path',
|
|
49
|
+
'vec_append',
|
|
50
|
+
'map_field_flip',
|
|
40
51
|
];
|
|
41
52
|
exports.ORIGINAL_DIMENSIONS = ORIGINAL_DIMENSIONS;
|
|
42
53
|
/** Build deterministic model-evaluated alternatives without mutating the intended call.
|
|
@@ -196,7 +207,9 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
196
207
|
hasConstrainedArg &&
|
|
197
208
|
permitCtx.args.length >= 2 &&
|
|
198
209
|
permitCtx.args[0]?.type === 'address' &&
|
|
199
|
-
permitCtx.args[1]?.type === 'address'
|
|
210
|
+
permitCtx.args[1]?.type === 'address' &&
|
|
211
|
+
permitCtx.args[0].value !==
|
|
212
|
+
permitCtx.args[1].value) {
|
|
200
213
|
const ctx = cloneContext(permitCtx);
|
|
201
214
|
// Guard above guarantees args[0] and args[1] exist and are address-typed.
|
|
202
215
|
const a0 = ctx.args[0];
|
|
@@ -205,6 +218,59 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
205
218
|
ctx.args[1] = a0;
|
|
206
219
|
denies.push({ dimension: 'argument_reorder', ctx });
|
|
207
220
|
}
|
|
221
|
+
// --- map_field_flip: flip a bound map field to a different valid value of
|
|
222
|
+
// the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
|
|
223
|
+
// Targets each `call_arg_field` leaf and produces a ctx whose vec element
|
|
224
|
+
// has a different value of the recorded field (different request_type,
|
|
225
|
+
// different amount, different address). The blend-submit case uses this
|
|
226
|
+
// to detect a missing request_type pin. ---
|
|
227
|
+
if (!dimensions || dimensions.includes('map_field_flip')) {
|
|
228
|
+
for (const comparison of facts.comparisons) {
|
|
229
|
+
const sel = comparison.left;
|
|
230
|
+
if (sel.kind !== 'call_arg_field')
|
|
231
|
+
continue;
|
|
232
|
+
const arg = permitCtx.args[sel.index];
|
|
233
|
+
if (!arg || arg.type !== 'vec')
|
|
234
|
+
continue;
|
|
235
|
+
const element = arg.value[sel.element];
|
|
236
|
+
if (!element || element.type !== 'map' || !Array.isArray(element.value))
|
|
237
|
+
continue;
|
|
238
|
+
const entry = element.value.find((e) => e.key === sel.field);
|
|
239
|
+
if (!entry)
|
|
240
|
+
continue;
|
|
241
|
+
const flipped = flipFieldValue(entry.val);
|
|
242
|
+
if (flipped === null)
|
|
243
|
+
continue;
|
|
244
|
+
const ctx = cloneContext(permitCtx);
|
|
245
|
+
const clonedVec = arg.value.map(cloneScVal);
|
|
246
|
+
const clonedElement = clonedVec[sel.element];
|
|
247
|
+
if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value))
|
|
248
|
+
continue;
|
|
249
|
+
clonedElement.value = clonedElement.value.map((e) => e.key === sel.field ? { key: e.key, val: flipped } : e);
|
|
250
|
+
clonedVec[sel.element] = clonedElement;
|
|
251
|
+
ctx.args[sel.index] = { type: 'vec', value: clonedVec };
|
|
252
|
+
denies.push({ dimension: 'map_field_flip', ctx });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
|
|
256
|
+
// OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
|
|
257
|
+
// length pin a caller can append an extra element to defeat per-element binds. ---
|
|
258
|
+
if (!dimensions || dimensions.includes('vec_append')) {
|
|
259
|
+
for (const comparison of facts.comparisons) {
|
|
260
|
+
const sel = comparison.left;
|
|
261
|
+
if (sel.kind !== 'call_arg_len')
|
|
262
|
+
continue;
|
|
263
|
+
const arg = permitCtx.args[sel.index];
|
|
264
|
+
if (!arg || arg.type !== 'vec')
|
|
265
|
+
continue;
|
|
266
|
+
const ctx = cloneContext(permitCtx);
|
|
267
|
+
ctx.args[sel.index] = {
|
|
268
|
+
type: 'vec',
|
|
269
|
+
value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
|
|
270
|
+
};
|
|
271
|
+
denies.push({ dimension: 'vec_append', ctx });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
208
274
|
// Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
|
|
209
275
|
return { permit: cloneContext(permitCtx), denies };
|
|
210
276
|
}
|
|
@@ -436,3 +502,25 @@ function cloneDepthError(value) {
|
|
|
436
502
|
err.depthContext = value.type;
|
|
437
503
|
throw err;
|
|
438
504
|
}
|
|
505
|
+
/** Build a value of the SAME ScVal type that differs from the recorded one,
|
|
506
|
+
* used by the `map_field_flip` mutation to defeat a per-element pin without
|
|
507
|
+
* changing the wire type (a type/arity change is rejected by host dispatch
|
|
508
|
+
* before Policy::enforce, so it is not the predicate's job). Returns null
|
|
509
|
+
* when the ScVal type has no obvious different value (e.g. opaque/other). */
|
|
510
|
+
function flipFieldValue(val) {
|
|
511
|
+
switch (val.type) {
|
|
512
|
+
case 'address': {
|
|
513
|
+
const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE';
|
|
514
|
+
const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5';
|
|
515
|
+
return { type: 'address', value: val.value === a ? b : a };
|
|
516
|
+
}
|
|
517
|
+
case 'i128':
|
|
518
|
+
case 'u64':
|
|
519
|
+
case 'u32':
|
|
520
|
+
return { type: val.type, value: String(BigInt(val.value) + 1n) };
|
|
521
|
+
case 'symbol':
|
|
522
|
+
return { type: 'symbol', value: `${val.value}x` };
|
|
523
|
+
default:
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
@@ -178,6 +178,38 @@ function evalCompare(op, left, right, ctx) {
|
|
|
178
178
|
return evalArgOrderedCompare(op, actual, right);
|
|
179
179
|
return evalArgEq(op, actual, right, ctx);
|
|
180
180
|
}
|
|
181
|
+
// --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
|
|
182
|
+
// Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
|
|
183
|
+
if (left.kind === 'call_arg_len') {
|
|
184
|
+
const actual = ctx.args[left.index];
|
|
185
|
+
if (!actual || actual.type !== 'vec')
|
|
186
|
+
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
187
|
+
if (right.kind !== 'literal_u32')
|
|
188
|
+
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
189
|
+
return actual.value.length === right.value
|
|
190
|
+
? { permit: true }
|
|
191
|
+
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
192
|
+
}
|
|
193
|
+
// --- step 4d: call_arg_field: the value of a field in the map at element i
|
|
194
|
+
// of the vec at argument index. Fails closed on a non-vec arg, an
|
|
195
|
+
// out-of-range element, a missing field, a non-map element, or a type
|
|
196
|
+
// mismatch between the field ScVal and the literal leaf.
|
|
197
|
+
if (left.kind === 'call_arg_field') {
|
|
198
|
+
const actual = ctx.args[left.index];
|
|
199
|
+
if (!actual || actual.type !== 'vec')
|
|
200
|
+
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
201
|
+
const element = actual.value[left.element];
|
|
202
|
+
if (!element || element.type !== 'map')
|
|
203
|
+
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
204
|
+
if (!Array.isArray(element.value))
|
|
205
|
+
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
206
|
+
const entry = element.value.find((e) => e.key === left.field);
|
|
207
|
+
if (!entry)
|
|
208
|
+
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
209
|
+
if (op === 'eq')
|
|
210
|
+
return evalArgEq(op, entry.val, right, ctx);
|
|
211
|
+
return evalArgOrderedCompare(op, entry.val, right);
|
|
212
|
+
}
|
|
181
213
|
// --- step 6: AMOUNT_BOUND ---
|
|
182
214
|
if (left.kind === 'amount' && op !== 'eq') {
|
|
183
215
|
return evalAmountCompare(op, left.token, right, ctx);
|
|
@@ -337,6 +369,23 @@ function resolveLeaf(leaf, ctx) {
|
|
|
337
369
|
return { type: 'symbol', value: ctx.fn };
|
|
338
370
|
case 'call_arg':
|
|
339
371
|
return ctx.args[leaf.index];
|
|
372
|
+
case 'call_arg_len':
|
|
373
|
+
// No direct ScVal projection: the length is an integer the comparator
|
|
374
|
+
// resolves against the right-hand literal. Returning undefined keeps
|
|
375
|
+
// the `in` membership path structurally informed (no haystack match).
|
|
376
|
+
return undefined;
|
|
377
|
+
case 'call_arg_field': {
|
|
378
|
+
const actual = ctx.args[leaf.index];
|
|
379
|
+
if (!actual || actual.type !== 'vec')
|
|
380
|
+
return undefined;
|
|
381
|
+
const element = actual.value[leaf.element];
|
|
382
|
+
if (!element || element.type !== 'map')
|
|
383
|
+
return undefined;
|
|
384
|
+
if (!Array.isArray(element.value))
|
|
385
|
+
return undefined;
|
|
386
|
+
const entry = element.value.find((e) => e.key === leaf.field);
|
|
387
|
+
return entry ? entry.val : undefined;
|
|
388
|
+
}
|
|
340
389
|
case 'amount':
|
|
341
390
|
case 'window_spent':
|
|
342
391
|
case 'oracle_price':
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
|
|
2
2
|
import type { ToolResponse } from '../errors.ts';
|
|
3
3
|
import type { MandateSpec } from '../mandate/types.ts';
|
|
4
|
-
import type { ProposedPolicy } from '../types.ts';
|
|
5
|
-
|
|
4
|
+
import type { PredicateNode, ProposedPolicy } from '../types.ts';
|
|
5
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
6
|
+
export declare function synthesizeFromMandate(spec: MandateSpec, ozConfig: OzAdapterConfig,
|
|
7
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
8
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
9
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
10
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
11
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
12
|
+
* altered. */
|
|
13
|
+
opts?: {
|
|
14
|
+
explain?: true;
|
|
15
|
+
}): ToolResponse<ProposedPolicy> & {
|
|
16
|
+
explain?: {
|
|
17
|
+
predicateTree: PredicateNode | null;
|
|
18
|
+
simulation: SimulationResult;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -13,7 +13,14 @@ exports.synthesizeFromMandate = synthesizeFromMandate;
|
|
|
13
13
|
const adapter_ts_1 = require("../adapters/oz/adapter.js");
|
|
14
14
|
const to_ir_ts_1 = require("../mandate/to-ir.js");
|
|
15
15
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
|
|
16
|
-
function synthesizeFromMandate(spec, ozConfig
|
|
16
|
+
function synthesizeFromMandate(spec, ozConfig,
|
|
17
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
18
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
19
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
20
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
21
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
22
|
+
* altered. */
|
|
23
|
+
opts) {
|
|
17
24
|
const ir = (0, to_ir_ts_1.mandateToPolicyIR)(spec);
|
|
18
25
|
const adapter = (0, adapter_ts_1.createOzAdapter)(ozConfig);
|
|
19
26
|
const result = adapter.compile(ir);
|
|
@@ -33,5 +40,23 @@ function synthesizeFromMandate(spec, ozConfig) {
|
|
|
33
40
|
...result.proposed,
|
|
34
41
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
35
42
|
};
|
|
36
|
-
|
|
43
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
44
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
45
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
46
|
+
const envelope = { ok: true, data: proposed };
|
|
47
|
+
if (opts?.explain) {
|
|
48
|
+
envelope.explain = {
|
|
49
|
+
predicateTree: null,
|
|
50
|
+
simulation: {
|
|
51
|
+
permit: {
|
|
52
|
+
tx: 'deny',
|
|
53
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
54
|
+
},
|
|
55
|
+
evaluatedCases: [],
|
|
56
|
+
backend: 'ts-model',
|
|
57
|
+
simulatorVersion: 'not-run',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return envelope;
|
|
37
62
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
|
|
2
2
|
import type { ToolError, ToolResponse } from '../errors.ts';
|
|
3
3
|
import { type Network, type PredicateNode, type ProposedPolicy, type RecordedTransaction } from '../types.ts';
|
|
4
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
4
5
|
import { type ComposeUserResponses } from './compose-from-recording.ts';
|
|
5
6
|
/** Per-policy interpreter adapter config the caller opts into. Absent ->
|
|
6
7
|
* recording path runs in week-1 mode (OZ adapter only, warnings for the
|
|
@@ -41,8 +42,31 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
41
42
|
threshold: number;
|
|
42
43
|
};
|
|
43
44
|
interpreter?: InterpreterAdapterOptions;
|
|
45
|
+
/** --explain opt-in. When true, the orchestrator attaches the
|
|
46
|
+
* in-memory `PredicateNode` + the corresponding `SimulationResult`
|
|
47
|
+
* to the success envelope so the CLI can render a human-readable
|
|
48
|
+
* review card. Absent or false -> the success envelope is unchanged
|
|
49
|
+
* (byte-identical to today). The flag is ADDITIVE: the existing
|
|
50
|
+
* ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
|
|
51
|
+
* never altered by enabling explain. */
|
|
52
|
+
explain?: true;
|
|
44
53
|
}
|
|
45
|
-
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy
|
|
54
|
+
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
|
|
55
|
+
/** Present iff `opts.explain === true` and the synthesis succeeded. The
|
|
56
|
+
* `predicateTree` is the exact in-memory `PredicateNode` (canonical
|
|
57
|
+
* JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
|
|
58
|
+
* - reading the predicate tree from the policy document is therefore
|
|
59
|
+
* unnecessary; the orchestrator carries it through. The `simulation`
|
|
60
|
+
* is the verdict produced by the same self-verify pipeline that already
|
|
61
|
+
* runs in the recording path (runHarness + evaluate), so the value is
|
|
62
|
+
* REAL not synthetic. Absent when the synthesis did not engage the
|
|
63
|
+
* interpreter (no interpreter opts supplied) - in that case the CLI
|
|
64
|
+
* builds a minimal honest SimulationResult downstream. */
|
|
65
|
+
explain?: {
|
|
66
|
+
predicateTree: PredicateNode | null;
|
|
67
|
+
simulation: SimulationResult;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
46
70
|
/** ToolError-shaped error helper used by the body to surface a structured
|
|
47
71
|
* failure that the envelope converts to `{ok:false, error}`. */
|
|
48
72
|
declare function throwToolError(code: ToolError['code'], message: string): never;
|
|
@@ -199,6 +199,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
199
199
|
...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
|
|
200
200
|
};
|
|
201
201
|
const composed = (0, compose_from_recording_ts_1.composeFromRecording)(facts, scope.contract, topLevel, composeOpts);
|
|
202
|
+
// --explain hook: capture the in-memory predicate tree + the real
|
|
203
|
+
// self-verify verdict so the CLI can render a faithful review card.
|
|
204
|
+
// The verdict below is built from the SAME runHarness + evaluate that
|
|
205
|
+
// already gates the synthesis (it is not a parallel simulation); the
|
|
206
|
+
// intermediate inputs (harnessCases, evalResult) are otherwise discarded
|
|
207
|
+
// after the gate, so the explain hook reuses them - no extra work.
|
|
208
|
+
let explain = null;
|
|
209
|
+
let explainSim = null;
|
|
202
210
|
// 5. OZ compile (always runs).
|
|
203
211
|
const ozAdapter = (0, adapter_ts_2.createOzAdapter)(ozConfig);
|
|
204
212
|
const compileRes = ozAdapter.compile(composed.ir);
|
|
@@ -376,6 +384,36 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
376
384
|
},
|
|
377
385
|
};
|
|
378
386
|
}
|
|
387
|
+
// --explain capture: the interpreter path already produced the real
|
|
388
|
+
// self-verify verdict (runHarness passed, evalResult.permit is true).
|
|
389
|
+
// Build the SimulationResult from those outputs so the CLI card
|
|
390
|
+
// quotes the SAME verdict that gated the synthesis. We re-evaluate
|
|
391
|
+
// each deny case to surface its concrete reason; the harness only
|
|
392
|
+
// records whether the got-matches-expected boundary held.
|
|
393
|
+
if (opts.explain) {
|
|
394
|
+
explain = finalPredicate;
|
|
395
|
+
const evaluatedCases = [
|
|
396
|
+
{
|
|
397
|
+
dimension: 'permit',
|
|
398
|
+
outcome: evalResult.permit ? 'permit' : 'deny',
|
|
399
|
+
reason: 'matches recorded call',
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
for (const deny of harnessCases.denies) {
|
|
403
|
+
const r = (0, evaluate_ts_1.evaluate)(finalPredicate, deny.ctx);
|
|
404
|
+
evaluatedCases.push({
|
|
405
|
+
dimension: deny.dimension,
|
|
406
|
+
outcome: r.permit ? 'permit' : 'deny',
|
|
407
|
+
reason: r.permit ? 'no matching deny' : r.reason,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
explainSim = {
|
|
411
|
+
permit: { tx: 'permit' },
|
|
412
|
+
evaluatedCases,
|
|
413
|
+
backend: 'ts-model',
|
|
414
|
+
simulatorVersion: 'ts-model-1.0.0',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
379
417
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
380
418
|
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
381
419
|
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
@@ -473,7 +511,34 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
473
511
|
],
|
|
474
512
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
475
513
|
};
|
|
476
|
-
|
|
514
|
+
// --explain success envelope. The interpreter path populated
|
|
515
|
+
// `explain` + `explainSim` from the real self-verify verdict above;
|
|
516
|
+
// the OZ-only path did not (no predicate tree exists). When opts.explain
|
|
517
|
+
// is set and the OZ-only path ran, construct the minimal honest
|
|
518
|
+
// SimulationResult: the verdict is NOT a passing simulation - the
|
|
519
|
+
// interpreter was never engaged, so permit is deny with a truthful
|
|
520
|
+
// reason and evaluatedCases is empty.
|
|
521
|
+
const envelope = { ok: true, data: proposed };
|
|
522
|
+
if (opts.explain) {
|
|
523
|
+
if (explainSim) {
|
|
524
|
+
envelope.explain = { predicateTree: explain, simulation: explainSim };
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
envelope.explain = {
|
|
528
|
+
predicateTree: null,
|
|
529
|
+
simulation: {
|
|
530
|
+
permit: {
|
|
531
|
+
tx: 'deny',
|
|
532
|
+
reason: 'No self-verification was performed (interpreter adapter was not engaged)',
|
|
533
|
+
},
|
|
534
|
+
evaluatedCases: [],
|
|
535
|
+
backend: 'ts-model',
|
|
536
|
+
simulatorVersion: 'not-run',
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return envelope;
|
|
477
542
|
}
|
|
478
543
|
/** OZ-side `uncovered` warning patterns that the interpreter adapter
|
|
479
544
|
* actually lowers when wired in. When the interpreter adapter succeeds, we
|
package/dist-cjs/types.d.ts
CHANGED
|
@@ -182,6 +182,14 @@ export type PredicateLeaf = {
|
|
|
182
182
|
} | {
|
|
183
183
|
kind: 'call_arg';
|
|
184
184
|
index: number;
|
|
185
|
+
} | {
|
|
186
|
+
kind: 'call_arg_len';
|
|
187
|
+
index: number;
|
|
188
|
+
} | {
|
|
189
|
+
kind: 'call_arg_field';
|
|
190
|
+
index: number;
|
|
191
|
+
element: number;
|
|
192
|
+
field: string;
|
|
185
193
|
} | {
|
|
186
194
|
kind: 'amount';
|
|
187
195
|
token: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-synth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "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.",
|
|
6
6
|
"type": "module",
|
|
@@ -389,6 +389,10 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
|
|
|
389
389
|
switch (s.kind) {
|
|
390
390
|
case 'arg':
|
|
391
391
|
return { kind: 'call_arg', index: s.argIndex }
|
|
392
|
+
case 'arg_len':
|
|
393
|
+
return { kind: 'call_arg_len', index: s.argIndex }
|
|
394
|
+
case 'arg_field':
|
|
395
|
+
return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
|
|
392
396
|
case 'amount':
|
|
393
397
|
return { kind: 'amount', token: s.token }
|
|
394
398
|
case 'window_spent':
|
|
@@ -415,14 +419,15 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
|
|
|
415
419
|
* selector kind to the matching `literal_*` kind. The IR compare value is a
|
|
416
420
|
* raw string (i128-safe); the selector kind fixes the canonical wire type:
|
|
417
421
|
* - arg -> IR scalarType (set by the recorder/parser)
|
|
422
|
+
* - arg_len -> u32 (vec length is a small non-negative integer)
|
|
423
|
+
* - arg_field -> IR scalarType (the field's recorded type)
|
|
418
424
|
* - amount -> i128 (canonical Stellar token amount encoding)
|
|
419
425
|
* - window_spent -> i128 (canonical amount encoding)
|
|
420
426
|
* - oracle_price -> i128 (canonical price encoding)
|
|
421
427
|
* - invocation_count -> u32 (counts are small non-negative integers)
|
|
422
428
|
* - now / valid_until -> u64 (unix timestamps in seconds) */
|
|
423
429
|
function literalFromIRCompare(c: IRCompare): PredicateLeaf {
|
|
424
|
-
const scalarType: IRScalarType =
|
|
425
|
-
c.selector.kind === 'arg' ? c.selector.scalarType : literalScalarForSelector(c.selector.kind)
|
|
430
|
+
const scalarType: IRScalarType = selectorScalarType(c.selector)
|
|
426
431
|
return literalFromScalar(c.value, scalarType)
|
|
427
432
|
}
|
|
428
433
|
|
|
@@ -433,14 +438,16 @@ function literalScalarForSelector(kind: IRSelector['kind']): IRScalarType {
|
|
|
433
438
|
case 'oracle_price':
|
|
434
439
|
return 'i128'
|
|
435
440
|
case 'invocation_count':
|
|
441
|
+
case 'arg_len':
|
|
436
442
|
return 'u32'
|
|
437
443
|
case 'now':
|
|
438
444
|
case 'valid_until':
|
|
439
445
|
return 'u64'
|
|
440
446
|
case 'arg':
|
|
447
|
+
case 'arg_field':
|
|
441
448
|
case 'calldata':
|
|
442
449
|
case 'value':
|
|
443
|
-
// arg -> caller handles scalarType; calldata/value -> unreachable (Path-B).
|
|
450
|
+
// arg / arg_field -> caller handles scalarType; calldata/value -> unreachable (Path-B).
|
|
444
451
|
return 'i128'
|
|
445
452
|
}
|
|
446
453
|
}
|
|
@@ -473,9 +480,10 @@ function literalFromScalar(value: string, scalarType: IRScalarType): PredicateLe
|
|
|
473
480
|
/** Scalar type of an IRSelector for the purpose of building literal leaves
|
|
474
481
|
* (the right-hand side of `eq` / elements of an `in` haystack / elements of
|
|
475
482
|
* a `literal_vec`). Mirrors `literalScalarForSelector` for OZ extensions and
|
|
476
|
-
* uses the selector's own `scalarType` for `arg` selectors. */
|
|
483
|
+
* uses the selector's own `scalarType` for `arg` and `arg_field` selectors. */
|
|
477
484
|
function selectorScalarType(selector: IRSelector): IRScalarType {
|
|
478
485
|
if (selector.kind === 'arg') return selector.scalarType
|
|
486
|
+
if (selector.kind === 'arg_field') return selector.scalarType
|
|
479
487
|
return literalScalarForSelector(selector.kind)
|
|
480
488
|
}
|
|
481
489
|
|
|
@@ -569,6 +577,10 @@ function describeCondition(cond: IRCondition): string {
|
|
|
569
577
|
return `per-call amount comparison on ${s.token}`
|
|
570
578
|
case 'arg':
|
|
571
579
|
return `argument comparison on arg ${s.argIndex}`
|
|
580
|
+
case 'arg_len':
|
|
581
|
+
return `vec length comparison on arg ${s.argIndex}`
|
|
582
|
+
case 'arg_field':
|
|
583
|
+
return `map field comparison on arg ${s.argIndex}.${s.field}`
|
|
572
584
|
case 'calldata':
|
|
573
585
|
return 'EVM calldata comparison'
|
|
574
586
|
case 'value':
|
|
@@ -585,6 +597,10 @@ function describeSelector(s: IRSelector): string {
|
|
|
585
597
|
switch (s.kind) {
|
|
586
598
|
case 'arg':
|
|
587
599
|
return `arg ${s.argIndex}`
|
|
600
|
+
case 'arg_len':
|
|
601
|
+
return `arg_len(${s.argIndex})`
|
|
602
|
+
case 'arg_field':
|
|
603
|
+
return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`
|
|
588
604
|
case 'amount':
|
|
589
605
|
return `amount(${s.token})`
|
|
590
606
|
case 'window_spent':
|
|
@@ -307,6 +307,10 @@ function describeCondition(cond: IRCondition): string {
|
|
|
307
307
|
return `per-call amount comparison on ${s.token} (predicate DSL)`
|
|
308
308
|
case 'arg':
|
|
309
309
|
return `argument comparison on arg ${s.argIndex} (predicate DSL)`
|
|
310
|
+
case 'arg_len':
|
|
311
|
+
return `vec length comparison on arg ${s.argIndex} (predicate DSL)`
|
|
312
|
+
case 'arg_field':
|
|
313
|
+
return `map field comparison on arg ${s.argIndex}.${s.field} (predicate DSL)`
|
|
310
314
|
case 'calldata':
|
|
311
315
|
return 'EVM calldata comparison (predicate DSL)'
|
|
312
316
|
case 'value':
|
|
@@ -323,6 +327,10 @@ function describeSelector(s: IRSelector): string {
|
|
|
323
327
|
switch (s.kind) {
|
|
324
328
|
case 'arg':
|
|
325
329
|
return `arg ${s.argIndex}`
|
|
330
|
+
case 'arg_len':
|
|
331
|
+
return `arg_len(${s.argIndex})`
|
|
332
|
+
case 'arg_field':
|
|
333
|
+
return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`
|
|
326
334
|
case 'amount':
|
|
327
335
|
return `amount(${s.token})`
|
|
328
336
|
case 'window_spent':
|
package/src/ir/types.ts
CHANGED
|
@@ -41,6 +41,14 @@ export type IRSelector =
|
|
|
41
41
|
vecMode?: IRVecMode
|
|
42
42
|
scalarType: IRScalarType
|
|
43
43
|
} // Stellar StellarComp
|
|
44
|
+
| { kind: 'arg_len'; argIndex: number } // length of a vec-typed argument as u32
|
|
45
|
+
| {
|
|
46
|
+
kind: 'arg_field'
|
|
47
|
+
argIndex: number
|
|
48
|
+
element: number
|
|
49
|
+
field: string
|
|
50
|
+
scalarType: IRScalarType
|
|
51
|
+
} // field of a map element within a vec-typed argument
|
|
44
52
|
| { kind: 'calldata'; offset: number; length: number } // EVM ByteComp
|
|
45
53
|
| { kind: 'value' } // EVM tx.value
|
|
46
54
|
// --- OZ extensions (not in NEAR-V2; lowered only by adapters that support them) ---
|
package/src/predicate/encode.ts
CHANGED
|
@@ -207,6 +207,15 @@ function encodeLeaf(leaf: PredicateLeaf): xdr.ScVal {
|
|
|
207
207
|
return xdr.ScVal.scvVec([symbol('call_fn')])
|
|
208
208
|
case 'call_arg':
|
|
209
209
|
return xdr.ScVal.scvVec([symbol('call_arg'), xdr.ScVal.scvU32(leaf.index)])
|
|
210
|
+
case 'call_arg_len':
|
|
211
|
+
return xdr.ScVal.scvVec([symbol('call_arg_len'), xdr.ScVal.scvU32(leaf.index)])
|
|
212
|
+
case 'call_arg_field':
|
|
213
|
+
return xdr.ScVal.scvVec([
|
|
214
|
+
symbol('call_arg_field'),
|
|
215
|
+
xdr.ScVal.scvU32(leaf.index),
|
|
216
|
+
xdr.ScVal.scvU32(leaf.element),
|
|
217
|
+
xdr.ScVal.scvSymbol(leaf.field),
|
|
218
|
+
])
|
|
210
219
|
case 'amount':
|
|
211
220
|
return xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)])
|
|
212
221
|
case 'window_spent':
|