@crediolabs/policy-synth 0.1.9 → 0.1.11
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 +62 -11
- package/dist/errors.d.ts +1 -1
- package/dist/install/build-add-context-rule.d.ts +48 -0
- package/dist/install/build-add-context-rule.js +304 -0
- package/dist/ir/types.d.ts +4 -0
- package/dist/predicate/decode.d.ts +11 -0
- package/dist/predicate/decode.js +234 -0
- package/dist/predicate/encode.js +79 -14
- package/dist/predicate/from-json.d.ts +4 -0
- package/dist/predicate/from-json.js +113 -0
- package/dist/predicate/index.d.ts +2 -0
- package/dist/predicate/index.js +5 -1
- package/dist/registry/identify.js +21 -2
- package/dist/registry/protocols.d.ts +50 -1
- package/dist/registry/protocols.js +88 -0
- package/dist/review-card/builder.d.ts +13 -0
- package/dist/review-card/builder.js +61 -5
- package/dist/review-card/cross-check.js +50 -3
- package/dist/review-card/index.d.ts +1 -1
- package/dist/review-card/index.js +1 -1
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +16 -4
- package/dist/run/schemas.js +14 -0
- package/dist/synth/compose-from-recording.d.ts +4 -0
- package/dist/synth/compose-from-recording.js +34 -8
- package/dist/synth/deny-cases.d.ts +4 -1
- package/dist/synth/deny-cases.js +3 -1
- package/dist/synth/evaluate.js +130 -3
- package/dist/synth/permit-context.d.ts +15 -0
- package/dist/synth/permit-context.js +116 -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 +80 -3
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +62 -11
- package/dist-cjs/errors.d.ts +1 -1
- package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
- package/dist-cjs/install/build-add-context-rule.js +308 -0
- package/dist-cjs/ir/types.d.ts +4 -0
- package/dist-cjs/predicate/decode.d.ts +11 -0
- package/dist-cjs/predicate/decode.js +239 -0
- package/dist-cjs/predicate/encode.js +79 -14
- package/dist-cjs/predicate/from-json.d.ts +4 -0
- package/dist-cjs/predicate/from-json.js +116 -0
- package/dist-cjs/predicate/index.d.ts +2 -0
- package/dist-cjs/predicate/index.js +10 -2
- package/dist-cjs/registry/identify.js +20 -1
- package/dist-cjs/registry/protocols.d.ts +50 -1
- package/dist-cjs/registry/protocols.js +89 -1
- package/dist-cjs/review-card/builder.d.ts +13 -0
- package/dist-cjs/review-card/builder.js +62 -5
- package/dist-cjs/review-card/cross-check.js +50 -3
- package/dist-cjs/review-card/index.d.ts +1 -1
- package/dist-cjs/review-card/index.js +2 -1
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +16 -4
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
- package/dist-cjs/synth/compose-from-recording.js +34 -8
- package/dist-cjs/synth/deny-cases.d.ts +4 -1
- package/dist-cjs/synth/deny-cases.js +3 -0
- package/dist-cjs/synth/evaluate.js +130 -3
- package/dist-cjs/synth/permit-context.d.ts +15 -0
- package/dist-cjs/synth/permit-context.js +119 -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 +80 -3
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +72 -11
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
- package/src/errors.ts +13 -0
- package/src/install/build-add-context-rule.ts +429 -0
- package/src/ir/types.ts +4 -0
- package/src/predicate/decode.ts +242 -0
- package/src/predicate/encode.ts +110 -13
- package/src/predicate/from-json.ts +124 -0
- package/src/predicate/index.ts +5 -1
- package/src/registry/identify.ts +22 -2
- package/src/registry/protocols.ts +90 -1
- package/src/review-card/builder.ts +57 -5
- package/src/review-card/cross-check.ts +50 -3
- package/src/review-card/index.ts +1 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/compose-from-recording.ts +46 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +131 -3
- package/src/synth/permit-context.ts +137 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +116 -6
- package/src/types.ts +12 -0
|
@@ -80,16 +80,57 @@ function pushComparison(
|
|
|
80
80
|
out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`)
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
+
if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
|
|
84
|
+
out.push(`Length of arg[${left.index}] is ${right.value}`)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// call_arg_field: must mirror the builder's templates byte-for-byte so
|
|
88
|
+
// the cross-check can assert a faithful summary.
|
|
89
|
+
if (left.kind === 'call_arg_field') {
|
|
90
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`
|
|
91
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op)
|
|
92
|
+
if (right.kind === 'literal_address') {
|
|
93
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (right.kind === 'literal_symbol') {
|
|
97
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (right.kind === 'literal_bytes') {
|
|
101
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (right.kind === 'literal_u64') {
|
|
105
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (right.kind === 'literal_i128') {
|
|
109
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (right.kind === 'literal_u32') {
|
|
113
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (right.kind === 'literal_vec') {
|
|
117
|
+
out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Mirrors the builder's wording byte-for-byte: how many calls the rule
|
|
122
|
+
// permits, since the bound counts the calls already made.
|
|
83
123
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
84
|
-
|
|
124
|
+
const allowed = op === 'lt' ? right.value : right.value + 1
|
|
125
|
+
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`)
|
|
85
126
|
return
|
|
86
127
|
}
|
|
87
128
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
88
129
|
out.push(`Amount <= ${right.value}`)
|
|
89
130
|
return
|
|
90
131
|
}
|
|
91
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
92
|
-
out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`)
|
|
132
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
133
|
+
out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`)
|
|
93
134
|
return
|
|
94
135
|
}
|
|
95
136
|
}
|
|
@@ -121,6 +162,7 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
121
162
|
case 'call_arg':
|
|
122
163
|
case 'call_arg_len':
|
|
123
164
|
case 'call_arg_field':
|
|
165
|
+
case 'call_arg_scaled':
|
|
124
166
|
case 'amount':
|
|
125
167
|
case 'window_spent':
|
|
126
168
|
case 'now':
|
|
@@ -128,6 +170,11 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
128
170
|
case 'invocation_count_in_window':
|
|
129
171
|
case 'oracle_price':
|
|
130
172
|
return `<${leaf.kind}>`
|
|
173
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
174
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
175
|
+
// card is where a human has to be able to see it.
|
|
176
|
+
case 'oracle_threshold':
|
|
177
|
+
return `${leaf.value} (${leaf.decimals} dp)`
|
|
131
178
|
}
|
|
132
179
|
}
|
|
133
180
|
|
package/src/review-card/index.ts
CHANGED
package/src/run/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type ErrorCode,
|
|
22
22
|
type MandateSpec,
|
|
23
23
|
type OzAdapterConfig,
|
|
24
|
+
type PredicateNode,
|
|
24
25
|
type ProposedPolicy,
|
|
25
26
|
placeholderOzConfig,
|
|
26
27
|
type RecordedTransaction,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type ToolError,
|
|
32
33
|
type ToolResponse,
|
|
33
34
|
} from '../index.ts'
|
|
35
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
34
36
|
import {
|
|
35
37
|
type RecordTransactionInput,
|
|
36
38
|
RecordTransactionInputSchema,
|
|
@@ -112,7 +114,14 @@ export async function runRecordTransaction(
|
|
|
112
114
|
* `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
|
|
113
115
|
* XDR decode error in the adapter) would otherwise surface as
|
|
114
116
|
* "[object Object]" in the MCP transport. */
|
|
115
|
-
export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
117
|
+
export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
118
|
+
ToolResponse<ProposedPolicy> & {
|
|
119
|
+
explain?: {
|
|
120
|
+
predicateTree: PredicateNode | null
|
|
121
|
+
simulation: SimulationResult
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
> {
|
|
116
125
|
const parsed = SynthesizePolicyInputSchema.safeParse(raw)
|
|
117
126
|
if (!parsed.success) {
|
|
118
127
|
return {
|
|
@@ -128,7 +137,11 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
|
|
|
128
137
|
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
129
138
|
// exact-optional MandateSpec rejects; the schema already validated the
|
|
130
139
|
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
131
|
-
return await synthesizeFromMandate(
|
|
140
|
+
return await synthesizeFromMandate(
|
|
141
|
+
input.mandate as MandateSpec,
|
|
142
|
+
ozConfig,
|
|
143
|
+
input.explain === true ? { explain: true } : {}
|
|
144
|
+
)
|
|
132
145
|
}
|
|
133
146
|
// recording source
|
|
134
147
|
const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
|
|
@@ -141,6 +154,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
|
|
|
141
154
|
? { confidenceOverride: input.confidenceOverride }
|
|
142
155
|
: {}),
|
|
143
156
|
...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
|
|
157
|
+
...(input.explain === true ? { explain: true } : {}),
|
|
144
158
|
} as SynthesizeFromRecordingOptions,
|
|
145
159
|
ozConfig
|
|
146
160
|
)
|
package/src/run/schemas.ts
CHANGED
|
@@ -197,6 +197,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
|
|
|
197
197
|
source: z.literal('mandate'),
|
|
198
198
|
mandate: MandateSpecSchema,
|
|
199
199
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
200
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
201
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
202
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
203
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
204
|
+
explain: z.boolean().optional(),
|
|
200
205
|
})
|
|
201
206
|
|
|
202
207
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
@@ -224,6 +229,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
|
|
|
224
229
|
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
225
230
|
interpreter: InterpreterOptionsSchema.optional(),
|
|
226
231
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
232
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
233
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
234
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
235
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
236
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
237
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
238
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
239
|
+
// explain.
|
|
240
|
+
explain: z.boolean().optional(),
|
|
227
241
|
})
|
|
228
242
|
|
|
229
243
|
export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
@@ -60,6 +60,10 @@ export interface OraclePriceBound {
|
|
|
60
60
|
asset: string
|
|
61
61
|
operator: IRCompOp
|
|
62
62
|
value: string
|
|
63
|
+
/** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
|
|
64
|
+
* 9 dp, and a threshold silently assumed to share that basis is what let a
|
|
65
|
+
* raw 14-dp bound permit everything. The author states it; we convert. */
|
|
66
|
+
decimals: number
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
|
|
@@ -184,10 +188,19 @@ export function composeFromRecording(
|
|
|
184
188
|
value: limit,
|
|
185
189
|
},
|
|
186
190
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
// A rolling spend cap always goes to OZ, whose `spending_limit` is the
|
|
192
|
+
// audited implementation. The interpreter is NOT a fallback for the
|
|
193
|
+
// token != scopeContract case: on chain it sees one authorized call,
|
|
194
|
+
// not the transaction's token movements, so it has no per-call amount
|
|
195
|
+
// to accumulate and the counter would never move. OZ reports the case
|
|
196
|
+
// it cannot cover (it pins the limit to the context contract), which
|
|
197
|
+
// is the honest outcome - the old fallback produced an interpreter
|
|
198
|
+
// predicate that silently never bound.
|
|
199
|
+
ozConstraints.push(spendCond)
|
|
200
|
+
if (interpreterEnabled && token !== scopeContract) {
|
|
201
|
+
warnings.push(
|
|
202
|
+
`rolling spend cap on ${token} cannot be enforced on chain: OZ spending_limit pins the limit to the context contract, and the interpreter cannot observe token movements. Bound the per-call value with an argument cap plus an invocation-count limit instead.`
|
|
203
|
+
)
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
206
|
}
|
|
@@ -213,7 +226,10 @@ export function composeFromRecording(
|
|
|
213
226
|
op: 'compare',
|
|
214
227
|
compare: {
|
|
215
228
|
selector: { kind: 'invocation_count', windowSeconds },
|
|
216
|
-
|
|
229
|
+
// `lt`, not `lte`: the leaf reports the calls ALREADY made in the
|
|
230
|
+
// window, so `< N` is what permits N of them. With `lte` a limit
|
|
231
|
+
// of N let an N+1th call through.
|
|
232
|
+
operator: 'lt',
|
|
217
233
|
value: String(invocationLimit),
|
|
218
234
|
},
|
|
219
235
|
}
|
|
@@ -245,6 +261,7 @@ export function composeFromRecording(
|
|
|
245
261
|
selector: { kind: 'oracle_price', asset: b.asset },
|
|
246
262
|
operator: b.operator,
|
|
247
263
|
value: b.value,
|
|
264
|
+
valueDecimals: b.decimals,
|
|
248
265
|
},
|
|
249
266
|
}
|
|
250
267
|
if (interpreterEnabled) {
|
|
@@ -405,7 +422,17 @@ function appendProtocolSpecificConstraints(
|
|
|
405
422
|
})
|
|
406
423
|
for (let i = 0; i < elements.length; i++) {
|
|
407
424
|
const element = elements[i]
|
|
408
|
-
|
|
425
|
+
// An element we cannot bind leaves that request's action, asset and
|
|
426
|
+
// amount free while the length pin makes the policy LOOK total. The
|
|
427
|
+
// length pin still stays (it is a real restriction, and dropping it
|
|
428
|
+
// would only widen the policy) - what must not happen is dropping the
|
|
429
|
+
// element quietly.
|
|
430
|
+
if (element?.type !== 'map') {
|
|
431
|
+
warnings.push(
|
|
432
|
+
`Blend submit requests element ${i} is not a Request map (recorded as ${element?.type ?? 'nothing'}): its request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`
|
|
433
|
+
)
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
409
436
|
const fields = element.value
|
|
410
437
|
const fieldValue = (
|
|
411
438
|
name: string,
|
|
@@ -421,7 +448,19 @@ function appendProtocolSpecificConstraints(
|
|
|
421
448
|
const address = fieldValue('address', 'address')
|
|
422
449
|
const amount = fieldValue('amount', 'i128')
|
|
423
450
|
const requestType = fieldValue('request_type', 'u32')
|
|
424
|
-
if (address === null || amount === null || requestType === null)
|
|
451
|
+
if (address === null || amount === null || requestType === null) {
|
|
452
|
+
const missing = [
|
|
453
|
+
requestType === null ? 'request_type (u32)' : null,
|
|
454
|
+
address === null ? 'address' : null,
|
|
455
|
+
amount === null ? 'amount (i128)' : null,
|
|
456
|
+
].filter((f): f is string => f !== null)
|
|
457
|
+
// Partial pins are deliberately NOT emitted (see the note above): a
|
|
458
|
+
// half-pinned request reads as a bound one on the review card.
|
|
459
|
+
warnings.push(
|
|
460
|
+
`Blend submit requests element ${i} could not be bound: ${missing.join(', ')} missing or of an unexpected type. That request's request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
425
464
|
interpreterConstraints.push({
|
|
426
465
|
op: 'compare',
|
|
427
466
|
compare: {
|
package/src/synth/deny-cases.ts
CHANGED
|
@@ -566,7 +566,9 @@ function cloneContext(ctx: EvalContext): EvalContext {
|
|
|
566
566
|
return cloned
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
-
|
|
569
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
570
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
571
|
+
export function cloneScVal(value: ScVal, depth = 0): ScVal {
|
|
570
572
|
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
571
573
|
// payload cannot RangeError the JS stack during deny-case mutation. Over-depth
|
|
572
574
|
// throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
|
package/src/synth/evaluate.ts
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
|
|
33
33
|
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
34
34
|
|
|
35
|
+
/** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
|
|
36
|
+
* in oracle.rs. A threshold on any other basis must say so. */
|
|
37
|
+
const NORMALISED_DECIMALS = 9
|
|
38
|
+
/** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
|
|
39
|
+
const MAX_ORACLE_THRESHOLD_DECIMALS = 18
|
|
40
|
+
|
|
41
|
+
|
|
35
42
|
export interface EvalContext {
|
|
36
43
|
/** Contract the interpreter is asked to enforce against. */
|
|
37
44
|
contract: string
|
|
@@ -206,6 +213,13 @@ function evalCompare(
|
|
|
206
213
|
|
|
207
214
|
// --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
|
|
208
215
|
if (left.kind === 'call_arg') {
|
|
216
|
+
// The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
|
|
217
|
+
// Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
|
|
218
|
+
// -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
|
|
219
|
+
// generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
|
|
220
|
+
if (right.kind === 'call_arg_scaled') {
|
|
221
|
+
return evalScaledArgCompare(op, left, right, ctx)
|
|
222
|
+
}
|
|
209
223
|
const actual = ctx.args[left.index]
|
|
210
224
|
// An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
|
|
211
225
|
// compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
|
|
@@ -216,6 +230,12 @@ function evalCompare(
|
|
|
216
230
|
return evalArgEq(op, actual, right, ctx)
|
|
217
231
|
}
|
|
218
232
|
|
|
233
|
+
// --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
|
|
234
|
+
// Symmetric form so a policy that phrases the floor either way works.
|
|
235
|
+
if (left.kind === 'call_arg_scaled') {
|
|
236
|
+
return evalScaledArgCompare(op, right, left, ctx)
|
|
237
|
+
}
|
|
238
|
+
|
|
219
239
|
// --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
|
|
220
240
|
// Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
|
|
221
241
|
if (left.kind === 'call_arg_len') {
|
|
@@ -337,6 +357,100 @@ function evalArgEq(
|
|
|
337
357
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
338
358
|
}
|
|
339
359
|
|
|
360
|
+
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
|
|
361
|
+
* path: the scaled leaf is `args[index] * num / den` (truncating toward
|
|
362
|
+
* zero). On `checked_mul` / `checked_div` failure (overflow or
|
|
363
|
+
* divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
|
|
364
|
+
* failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
|
|
365
|
+
* rather than the generic `ARG_MISMATCH`.
|
|
366
|
+
*
|
|
367
|
+
* `left` is whichever side of the compare is NOT the scaled leaf. A
|
|
368
|
+
* scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
|
|
369
|
+
* leaves has no definable semantics). The other operand must be a
|
|
370
|
+
* numeric shape (`call_arg` carrying a number, or a numeric literal);
|
|
371
|
+
* anything else is `ARG_MISMATCH`. */
|
|
372
|
+
function evalScaledArgCompare(
|
|
373
|
+
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
|
|
374
|
+
left: PredicateLeaf,
|
|
375
|
+
right: PredicateLeaf,
|
|
376
|
+
ctx: EvalContext
|
|
377
|
+
): EvalResult {
|
|
378
|
+
// Identify which side is scaled, and pull the other side as a number.
|
|
379
|
+
let scaled: { index: number; num: string; den: string }
|
|
380
|
+
let other: PredicateLeaf
|
|
381
|
+
let scaledOnRight: boolean
|
|
382
|
+
if (left.kind === 'call_arg_scaled') {
|
|
383
|
+
scaled = left
|
|
384
|
+
other = right
|
|
385
|
+
scaledOnRight = false
|
|
386
|
+
} else if (right.kind === 'call_arg_scaled') {
|
|
387
|
+
scaled = right
|
|
388
|
+
other = left
|
|
389
|
+
scaledOnRight = true
|
|
390
|
+
} else {
|
|
391
|
+
// Neither side is scaled - this is a programming error in the
|
|
392
|
+
// dispatcher; the contract returns ARG_MISMATCH to fail closed.
|
|
393
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
394
|
+
}
|
|
395
|
+
// Source `args[index]` -> BigInt. An out-of-bounds index or a
|
|
396
|
+
// non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
|
|
397
|
+
// a violated floor is the wrong code when the operand itself could
|
|
398
|
+
// not be read.
|
|
399
|
+
if (scaled.index >= ctx.args.length) {
|
|
400
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
401
|
+
}
|
|
402
|
+
const input = argNumericBigInt(ctx.args[scaled.index])
|
|
403
|
+
if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
404
|
+
let num: bigint
|
|
405
|
+
let den: bigint
|
|
406
|
+
try {
|
|
407
|
+
num = BigInt(scaled.num)
|
|
408
|
+
den = BigInt(scaled.den)
|
|
409
|
+
} catch {
|
|
410
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
411
|
+
}
|
|
412
|
+
// Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
|
|
413
|
+
// check is a defensive belt-and-braces - a future validator
|
|
414
|
+
// regression cannot panic the frame on a divide-by-zero.
|
|
415
|
+
if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
416
|
+
const product = input * num
|
|
417
|
+
// BigInt overflow is silent in JS (it does not throw on multiplication).
|
|
418
|
+
// We can detect over-range by dividing and checking the result against
|
|
419
|
+
// i128 bounds, but the more practical check is whether the result
|
|
420
|
+
// divides cleanly. We rely on BigInt's arbitrary precision and then
|
|
421
|
+
// surface ARITHMETIC_OVERFLOW if the result is outside i128.
|
|
422
|
+
let scaledValue: bigint
|
|
423
|
+
try {
|
|
424
|
+
// BigInt division truncates toward zero (matches Rust `i128::checked_div`).
|
|
425
|
+
scaledValue = product / den
|
|
426
|
+
} catch {
|
|
427
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
428
|
+
}
|
|
429
|
+
// i128 range check: if the scaled value is outside i128, the contract
|
|
430
|
+
// would have wrapped on i128 arithmetic; surface the same deny.
|
|
431
|
+
const I128_MAX = (1n << 127n) - 1n
|
|
432
|
+
const I128_MIN = -(1n << 127n)
|
|
433
|
+
if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
|
|
434
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
435
|
+
}
|
|
436
|
+
// Resolving the operand when the scaled side is on the right: the
|
|
437
|
+
// operand is `left`; the scaled value is the RHS. The AST order is
|
|
438
|
+
// `left <op> scaled`, so the comparator applies to `other <op> scaled`.
|
|
439
|
+
// When the scaled side is on the left, the order is `scaled <op> other`,
|
|
440
|
+
// i.e. `scaled <op> other_val`.
|
|
441
|
+
let otherVal: bigint | null
|
|
442
|
+
if (other.kind === 'call_arg') {
|
|
443
|
+
otherVal = argNumericBigInt(ctx.args[other.index])
|
|
444
|
+
} else {
|
|
445
|
+
otherVal = literalNumericBigInt(other)
|
|
446
|
+
}
|
|
447
|
+
if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
448
|
+
const pass = scaledOnRight
|
|
449
|
+
? bigintCmp(op, otherVal.toString(), scaledValue.toString())
|
|
450
|
+
: bigintCmp(op, scaledValue.toString(), otherVal.toString())
|
|
451
|
+
return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' }
|
|
452
|
+
}
|
|
453
|
+
|
|
340
454
|
/** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
|
|
341
455
|
* reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
|
|
342
456
|
* surface) and compares it to a numeric literal via BigInt. A non-numeric arg
|
|
@@ -430,6 +544,7 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
|
|
|
430
544
|
case 'invocation_count_in_window':
|
|
431
545
|
case 'now':
|
|
432
546
|
case 'valid_until':
|
|
547
|
+
case 'call_arg_scaled':
|
|
433
548
|
return undefined // selector leaves with no ScVal projection
|
|
434
549
|
case 'literal_address':
|
|
435
550
|
return { type: 'address', value: leaf.value }
|
|
@@ -507,9 +622,22 @@ function evalOracleCompare(
|
|
|
507
622
|
if (!mapped) throw new OracleError('ORACLE_STALE')
|
|
508
623
|
throw new OracleError(mapped)
|
|
509
624
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
625
|
+
// Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
|
|
626
|
+
// decimal basis: prices are on the normalised 9-dp basis, and assuming a
|
|
627
|
+
// bare literal shares it is what let a raw 14-dp threshold permit
|
|
628
|
+
// everything. A bare literal is refused rather than assumed.
|
|
629
|
+
if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
|
|
630
|
+
if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
|
|
631
|
+
throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
|
|
632
|
+
}
|
|
633
|
+
// Scale BOTH sides up to the wider basis rather than dividing the threshold
|
|
634
|
+
// down: dividing truncates, and a truncated bound moves the permit boundary.
|
|
635
|
+
const decimals = BigInt(right.decimals)
|
|
636
|
+
const normalised = BigInt(NORMALISED_DECIMALS)
|
|
637
|
+
const common = decimals > normalised ? decimals : normalised
|
|
638
|
+
const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
|
|
639
|
+
const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
|
|
640
|
+
return bigintCmp(op, String(priceScaled), String(literalScaled))
|
|
513
641
|
? { permit: true }
|
|
514
642
|
: { permit: false, reason: 'FN_MISMATCH' }
|
|
515
643
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/synth/permit-context.ts - build the EvalContext that represents the
|
|
2
|
+
// recorded call being replayed.
|
|
3
|
+
//
|
|
4
|
+
// Extracted from the over-permissiveness harness so the same construction
|
|
5
|
+
// feeds both the in-process battery and the on-chain replay
|
|
6
|
+
// (scripts/verify-mutations-testnet.ts). Two callers building this
|
|
7
|
+
// separately would be two chances to disagree about what "the recorded
|
|
8
|
+
// call" means, which is the one thing both must share.
|
|
9
|
+
|
|
10
|
+
import type { PredicateLeaf, PredicateNode, RecordedTransaction } from '../types.ts'
|
|
11
|
+
import { cloneScVal } from './deny-cases.ts'
|
|
12
|
+
import type { EvalContext } from './evaluate.ts'
|
|
13
|
+
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
14
|
+
|
|
15
|
+
export interface PermitContextResponses {
|
|
16
|
+
windowSeconds: number
|
|
17
|
+
invocationLimit?: number
|
|
18
|
+
limitAmount?: string
|
|
19
|
+
validUntilLedger: number
|
|
20
|
+
oraclePriceBound?: Array<{ asset: string; operator: string; value: string; decimals: number }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildPermitContext(
|
|
24
|
+
tx: RecordedTransaction,
|
|
25
|
+
responses: PermitContextResponses,
|
|
26
|
+
predicate: PredicateNode
|
|
27
|
+
): EvalContext {
|
|
28
|
+
const amountByToken: Record<string, string> = {}
|
|
29
|
+
const totals = new Map<string, bigint>()
|
|
30
|
+
for (const m of tx.tokenMovements) {
|
|
31
|
+
const current = totals.get(m.token) ?? 0n
|
|
32
|
+
totals.set(m.token, current + BigInt(m.amount))
|
|
33
|
+
}
|
|
34
|
+
for (const [token, total] of totals) {
|
|
35
|
+
amountByToken[token] = total.toString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const topLevel = tx.invocations[0]
|
|
39
|
+
const scopeContract = topLevel?.contract ?? ''
|
|
40
|
+
|
|
41
|
+
const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = {}
|
|
42
|
+
visitOracleLeaves(predicate, (asset, op, bound) => {
|
|
43
|
+
let price: bigint
|
|
44
|
+
switch (op) {
|
|
45
|
+
case 'lt':
|
|
46
|
+
price = bound - 1n
|
|
47
|
+
break
|
|
48
|
+
case 'gt':
|
|
49
|
+
price = bound + 1n
|
|
50
|
+
break
|
|
51
|
+
default:
|
|
52
|
+
price = bound
|
|
53
|
+
}
|
|
54
|
+
if (price < 0n) price = 0n
|
|
55
|
+
oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const ctx: EvalContext = {
|
|
59
|
+
contract: scopeContract,
|
|
60
|
+
fn: topLevel?.fn ?? '',
|
|
61
|
+
args: (topLevel?.args ?? []).map(cloneScVal),
|
|
62
|
+
atLedger: tx.ledgerSequence,
|
|
63
|
+
nowSeconds: tx.fetchedAt,
|
|
64
|
+
amountByToken,
|
|
65
|
+
windowSpentByToken: {},
|
|
66
|
+
invocationCountByWindow: {},
|
|
67
|
+
oraclePriceByAsset,
|
|
68
|
+
}
|
|
69
|
+
if (responses.validUntilLedger !== undefined) {
|
|
70
|
+
ctx.validUntilLedger = responses.validUntilLedger
|
|
71
|
+
}
|
|
72
|
+
return ctx
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function visitOracleLeaves(
|
|
76
|
+
node: PredicateNode,
|
|
77
|
+
visit: (asset: string, op: string, bound: bigint) => void
|
|
78
|
+
): void {
|
|
79
|
+
switch (node.op) {
|
|
80
|
+
case 'and':
|
|
81
|
+
case 'or':
|
|
82
|
+
for (const child of node.children) visitOracleLeaves(child, visit)
|
|
83
|
+
return
|
|
84
|
+
case 'not':
|
|
85
|
+
visitOracleLeaves(node.child, visit)
|
|
86
|
+
return
|
|
87
|
+
case 'eq':
|
|
88
|
+
case 'lt':
|
|
89
|
+
case 'lte':
|
|
90
|
+
case 'gt':
|
|
91
|
+
case 'gte': {
|
|
92
|
+
const leftIsOracle = node.left.kind === 'oracle_price'
|
|
93
|
+
const rightIsOracle = node.right.kind === 'oracle_price'
|
|
94
|
+
let asset: string | undefined
|
|
95
|
+
let literal: bigint | undefined
|
|
96
|
+
if (leftIsOracle) {
|
|
97
|
+
asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined
|
|
98
|
+
literal = oracleThresholdNormalised(node.right)
|
|
99
|
+
} else if (rightIsOracle) {
|
|
100
|
+
asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined
|
|
101
|
+
literal = oracleThresholdNormalised(node.left)
|
|
102
|
+
}
|
|
103
|
+
if (asset !== undefined && literal !== undefined) {
|
|
104
|
+
visit(asset, node.op, literal)
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
case 'in':
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
|
|
114
|
+
* oracle.rs. */
|
|
115
|
+
const NORMALISED_DECIMALS = 9n
|
|
116
|
+
|
|
117
|
+
/** A threshold restated on the normalised basis, so a derived permit price is
|
|
118
|
+
* comparable to it. Thresholds carry their own basis, so the conversion has
|
|
119
|
+
* to happen here - reading the digits raw would build a context off by a
|
|
120
|
+
* factor of 10^(decimals-9). Returns undefined for any other leaf. */
|
|
121
|
+
function oracleThresholdNormalised(leaf: PredicateLeaf): bigint | undefined {
|
|
122
|
+
if (leaf.kind !== 'oracle_threshold') return undefined
|
|
123
|
+
let value: bigint
|
|
124
|
+
try {
|
|
125
|
+
value = BigInt(leaf.value)
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
const decimals = BigInt(leaf.decimals)
|
|
130
|
+
if (decimals <= NORMALISED_DECIMALS) {
|
|
131
|
+
return value * 10n ** (NORMALISED_DECIMALS - decimals)
|
|
132
|
+
}
|
|
133
|
+
// Floor: a finer-grained threshold has no exact 9-dp representation. The
|
|
134
|
+
// caller only needs a price that lands on the right side of the bound, and
|
|
135
|
+
// it offsets by one from here.
|
|
136
|
+
return value / 10n ** (decimals - NORMALISED_DECIMALS)
|
|
137
|
+
}
|
|
@@ -13,14 +13,27 @@ import { createOzAdapter } from '../adapters/oz/adapter.ts'
|
|
|
13
13
|
import type { ToolResponse } from '../errors.ts'
|
|
14
14
|
import { mandateToPolicyIR } from '../mandate/to-ir.ts'
|
|
15
15
|
import type { MandateSpec } from '../mandate/types.ts'
|
|
16
|
-
import type { ProposedPolicy } from '../types.ts'
|
|
16
|
+
import type { PredicateNode, ProposedPolicy } from '../types.ts'
|
|
17
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
17
18
|
|
|
18
19
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
|
|
19
20
|
|
|
20
21
|
export function synthesizeFromMandate(
|
|
21
22
|
spec: MandateSpec,
|
|
22
|
-
ozConfig: OzAdapterConfig
|
|
23
|
-
|
|
23
|
+
ozConfig: OzAdapterConfig,
|
|
24
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
25
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
26
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
27
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
28
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
29
|
+
* altered. */
|
|
30
|
+
opts?: { explain?: true }
|
|
31
|
+
): ToolResponse<ProposedPolicy> & {
|
|
32
|
+
explain?: {
|
|
33
|
+
predicateTree: PredicateNode | null
|
|
34
|
+
simulation: SimulationResult
|
|
35
|
+
}
|
|
36
|
+
} {
|
|
24
37
|
const ir = mandateToPolicyIR(spec)
|
|
25
38
|
const adapter = createOzAdapter(ozConfig)
|
|
26
39
|
const result = adapter.compile(ir)
|
|
@@ -42,5 +55,28 @@ export function synthesizeFromMandate(
|
|
|
42
55
|
...result.proposed,
|
|
43
56
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
44
57
|
}
|
|
45
|
-
|
|
58
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
59
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
60
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
61
|
+
const envelope: ToolResponse<ProposedPolicy> & {
|
|
62
|
+
explain?: {
|
|
63
|
+
predicateTree: PredicateNode | null
|
|
64
|
+
simulation: SimulationResult
|
|
65
|
+
}
|
|
66
|
+
} = { ok: true, data: proposed }
|
|
67
|
+
if (opts?.explain) {
|
|
68
|
+
envelope.explain = {
|
|
69
|
+
predicateTree: null,
|
|
70
|
+
simulation: {
|
|
71
|
+
permit: {
|
|
72
|
+
tx: 'deny',
|
|
73
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
74
|
+
},
|
|
75
|
+
evaluatedCases: [],
|
|
76
|
+
backend: 'ts-model',
|
|
77
|
+
simulatorVersion: 'not-run',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return envelope
|
|
46
82
|
}
|