@crediolabs/policy-synth 0.1.8 → 0.1.9

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.
@@ -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':
@@ -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.8",
3
+ "version": "0.1.9",
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) ---
@@ -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':
@@ -222,6 +222,8 @@ function renderVecElement(leaf: PredicateLeaf): string {
222
222
  case 'call_contract':
223
223
  case 'call_fn':
224
224
  case 'call_arg':
225
+ case 'call_arg_len':
226
+ case 'call_arg_field':
225
227
  case 'amount':
226
228
  case 'window_spent':
227
229
  case 'now':
@@ -119,6 +119,8 @@ function renderVecElement(leaf: PredicateLeaf): string {
119
119
  case 'call_contract':
120
120
  case 'call_fn':
121
121
  case 'call_arg':
122
+ case 'call_arg_len':
123
+ case 'call_arg_field':
122
124
  case 'amount':
123
125
  case 'window_spent':
124
126
  case 'now':
@@ -381,6 +381,93 @@ function appendProtocolSpecificConstraints(
381
381
  }
382
382
  }
383
383
 
384
+ // Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
385
+ // reserve_token_ids - no map fields to bind). The `requests` vec
386
+ // (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
387
+ // Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
388
+ // 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
389
+ // liquidation/auction fills. Pinning only one element is unsafe: a caller
390
+ // can append a second element with a different action (WithdrawCollateral
391
+ // -> Borrow on a different asset, any amount, then auction fills). Length
392
+ // + per-element pinning is total; a quantifier over elements is not. If we
393
+ // cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
394
+ if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
395
+ const requestsArg = topLevel.args[3]
396
+ if (requestsArg && requestsArg.type === 'vec') {
397
+ const elements = requestsArg.value
398
+ interpreterConstraints.push({
399
+ op: 'compare',
400
+ compare: {
401
+ selector: { kind: 'arg_len', argIndex: 3 },
402
+ operator: 'eq',
403
+ value: String(elements.length),
404
+ },
405
+ })
406
+ for (let i = 0; i < elements.length; i++) {
407
+ const element = elements[i]
408
+ if (!element || element.type !== 'map') continue
409
+ const fields = element.value
410
+ const fieldValue = (
411
+ name: string,
412
+ scalarType: 'address' | 'i128' | 'u32'
413
+ ): string | null => {
414
+ const entry = fields.find((e) => e.key === name)
415
+ if (!entry) return null
416
+ if (entry.val.type === 'address' && scalarType === 'address') return entry.val.value
417
+ if (entry.val.type === 'i128' && scalarType === 'i128') return entry.val.value
418
+ if (entry.val.type === 'u32' && scalarType === 'u32') return entry.val.value
419
+ return null
420
+ }
421
+ const address = fieldValue('address', 'address')
422
+ const amount = fieldValue('amount', 'i128')
423
+ const requestType = fieldValue('request_type', 'u32')
424
+ if (address === null || amount === null || requestType === null) continue
425
+ interpreterConstraints.push({
426
+ op: 'compare',
427
+ compare: {
428
+ selector: {
429
+ kind: 'arg_field',
430
+ argIndex: 3,
431
+ element: i,
432
+ field: 'request_type',
433
+ scalarType: 'u32',
434
+ },
435
+ operator: 'eq',
436
+ value: requestType,
437
+ },
438
+ })
439
+ interpreterConstraints.push({
440
+ op: 'compare',
441
+ compare: {
442
+ selector: {
443
+ kind: 'arg_field',
444
+ argIndex: 3,
445
+ element: i,
446
+ field: 'address',
447
+ scalarType: 'address',
448
+ },
449
+ operator: 'eq',
450
+ value: address,
451
+ },
452
+ })
453
+ interpreterConstraints.push({
454
+ op: 'compare',
455
+ compare: {
456
+ selector: {
457
+ kind: 'arg_field',
458
+ argIndex: 3,
459
+ element: i,
460
+ field: 'amount',
461
+ scalarType: 'i128',
462
+ },
463
+ operator: 'lte',
464
+ value: amount,
465
+ },
466
+ })
467
+ }
468
+ }
469
+ }
470
+
384
471
  // SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
385
472
  // The interpreter adapter is the ONLY way to express an exact ordered
386
473
  // sequence (OZ built-ins cannot). Element order is preserved verbatim.
@@ -51,6 +51,15 @@ const ADJACENT_ASSETS = [
51
51
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
52
52
 
53
53
  // The 15 dimensions the synth pipeline uses for self-verify and minimise.
54
+ // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
55
+ // alongside the existing dimensions so the per-element binds emitted for
56
+ // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
57
+ // minimise. Without these, no deny case exercises the new leaves and the
58
+ // minimise pass drops them as "redundant" - reopening the element-append
59
+ // hole they were added to close. Both dimensions are no-ops for any fixture
60
+ // whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
61
+ // generator short-circuits on leaf-kind), so they cannot make production
62
+ // synthesis refuse for fixtures that do not exercise the new grammar.
54
63
  const ORIGINAL_DIMENSIONS: string[] = [
55
64
  'amount',
56
65
  'asset',
@@ -67,6 +76,8 @@ const ORIGINAL_DIMENSIONS: string[] = [
67
76
  'oracle_deviation_exceeded',
68
77
  'oracle_paused',
69
78
  'soroswap_allowed_path',
79
+ 'vec_append',
80
+ 'map_field_flip',
70
81
  ]
71
82
 
72
83
  export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS }
@@ -263,7 +274,9 @@ export function generateCases(
263
274
  hasConstrainedArg &&
264
275
  permitCtx.args.length >= 2 &&
265
276
  permitCtx.args[0]?.type === 'address' &&
266
- permitCtx.args[1]?.type === 'address'
277
+ permitCtx.args[1]?.type === 'address' &&
278
+ (permitCtx.args[0] as { type: 'address'; value: string }).value !==
279
+ (permitCtx.args[1] as { type: 'address'; value: string }).value
267
280
  ) {
268
281
  const ctx = cloneContext(permitCtx)
269
282
  // Guard above guarantees args[0] and args[1] exist and are address-typed.
@@ -274,6 +287,55 @@ export function generateCases(
274
287
  denies.push({ dimension: 'argument_reorder', ctx })
275
288
  }
276
289
 
290
+ // --- map_field_flip: flip a bound map field to a different valid value of
291
+ // the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
292
+ // Targets each `call_arg_field` leaf and produces a ctx whose vec element
293
+ // has a different value of the recorded field (different request_type,
294
+ // different amount, different address). The blend-submit case uses this
295
+ // to detect a missing request_type pin. ---
296
+ if (!dimensions || dimensions.includes('map_field_flip')) {
297
+ for (const comparison of facts.comparisons) {
298
+ const sel = comparison.left
299
+ if (sel.kind !== 'call_arg_field') continue
300
+ const arg = permitCtx.args[sel.index]
301
+ if (!arg || arg.type !== 'vec') continue
302
+ const element = arg.value[sel.element]
303
+ if (!element || element.type !== 'map' || !Array.isArray(element.value)) continue
304
+ const entry = element.value.find((e) => e.key === sel.field)
305
+ if (!entry) continue
306
+ const flipped = flipFieldValue(entry.val)
307
+ if (flipped === null) continue
308
+ const ctx = cloneContext(permitCtx)
309
+ const clonedVec = arg.value.map(cloneScVal)
310
+ const clonedElement = clonedVec[sel.element]
311
+ if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value)) continue
312
+ clonedElement.value = clonedElement.value.map((e) =>
313
+ e.key === sel.field ? { key: e.key, val: flipped } : e
314
+ )
315
+ clonedVec[sel.element] = clonedElement
316
+ ctx.args[sel.index] = { type: 'vec', value: clonedVec }
317
+ denies.push({ dimension: 'map_field_flip', ctx })
318
+ }
319
+ }
320
+
321
+ // --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
322
+ // OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
323
+ // length pin a caller can append an extra element to defeat per-element binds. ---
324
+ if (!dimensions || dimensions.includes('vec_append')) {
325
+ for (const comparison of facts.comparisons) {
326
+ const sel = comparison.left
327
+ if (sel.kind !== 'call_arg_len') continue
328
+ const arg = permitCtx.args[sel.index]
329
+ if (!arg || arg.type !== 'vec') continue
330
+ const ctx = cloneContext(permitCtx)
331
+ ctx.args[sel.index] = {
332
+ type: 'vec',
333
+ value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
334
+ }
335
+ denies.push({ dimension: 'vec_append', ctx })
336
+ }
337
+ }
338
+
277
339
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
278
340
  return { permit: cloneContext(permitCtx), denies }
279
341
  }
@@ -528,3 +590,26 @@ function cloneDepthError(value: ScVal): never {
528
590
  err.depthContext = value.type
529
591
  throw err
530
592
  }
593
+
594
+ /** Build a value of the SAME ScVal type that differs from the recorded one,
595
+ * used by the `map_field_flip` mutation to defeat a per-element pin without
596
+ * changing the wire type (a type/arity change is rejected by host dispatch
597
+ * before Policy::enforce, so it is not the predicate's job). Returns null
598
+ * when the ScVal type has no obvious different value (e.g. opaque/other). */
599
+ function flipFieldValue(val: ScVal): ScVal | null {
600
+ switch (val.type) {
601
+ case 'address': {
602
+ const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE'
603
+ const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5'
604
+ return { type: 'address', value: val.value === a ? b : a }
605
+ }
606
+ case 'i128':
607
+ case 'u64':
608
+ case 'u32':
609
+ return { type: val.type, value: String(BigInt(val.value) + 1n) }
610
+ case 'symbol':
611
+ return { type: 'symbol', value: `${val.value}x` }
612
+ default:
613
+ return null
614
+ }
615
+ }