@crediolabs/policy-synth 0.1.10 → 0.1.12
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 +83 -11
- package/dist/adapters/oz/adapter.js +4 -0
- 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 +23 -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 +63 -5
- package/dist/review-card/cross-check.js +32 -3
- package/dist/review-card/index.d.ts +1 -1
- package/dist/review-card/index.js +1 -1
- package/dist/run/schemas.d.ts +4 -4
- package/dist/synth/compose-from-recording.d.ts +18 -0
- package/dist/synth/compose-from-recording.js +75 -10
- 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-recording.js +14 -2
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +83 -11
- package/dist-cjs/adapters/oz/adapter.js +4 -0
- 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 +23 -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 +64 -5
- package/dist-cjs/review-card/cross-check.js +32 -3
- package/dist-cjs/review-card/index.d.ts +1 -1
- package/dist-cjs/review-card/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +4 -4
- package/dist-cjs/synth/compose-from-recording.d.ts +18 -0
- package/dist-cjs/synth/compose-from-recording.js +75 -10
- 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-recording.js +14 -2
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +93 -11
- package/src/adapters/oz/adapter.ts +4 -0
- 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 +23 -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 +58 -5
- package/src/review-card/cross-check.ts +36 -3
- package/src/review-card/index.ts +1 -0
- package/src/synth/compose-from-recording.ts +101 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +130 -3
- package/src/synth/permit-context.ts +136 -0
- package/src/synth/synthesize-from-recording.ts +13 -2
- package/src/types.ts +12 -0
|
@@ -139,10 +139,98 @@ export const SOROSWAP_ABI = {
|
|
|
139
139
|
],
|
|
140
140
|
},
|
|
141
141
|
};
|
|
142
|
+
/** OpenZeppelin smart-account ABI subset.
|
|
143
|
+
*
|
|
144
|
+
* Why this lives in the recogniser: every user's smart account has a
|
|
145
|
+
* different C-address, so a per-deployment pin would never cover them.
|
|
146
|
+
* What is the same across every OpenZeppelin multisig account is the
|
|
147
|
+
* small set of public context-rule entrypoints. A recording that
|
|
148
|
+
* invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
|
|
149
|
+
* is overwhelmingly likely to be calling the user's own OpenZeppelin
|
|
150
|
+
* smart account (the canonical reason this product exists); we
|
|
151
|
+
* recognise that pattern by interface, the same way SEP-41 is
|
|
152
|
+
* recognised by interface.
|
|
153
|
+
*
|
|
154
|
+
* SECURITY PROPERTY (load-bearing):
|
|
155
|
+
* - Recognition claims the call TARGETS a particular protocol. We
|
|
156
|
+
* only claim `oz_account` when the (fn, args) shape uniquely matches
|
|
157
|
+
* one of the three entrypoints below AND the call looks like a real
|
|
158
|
+
* context-rule operation. The match is exact: off-by-one arg count,
|
|
159
|
+
* off-by-one arg type, or a method name close-but-wrong returns null
|
|
160
|
+
* (fail-closed). A contract that incidentally happens to expose a
|
|
161
|
+
* single matching fn is not sufficient - the recorder requires ALL
|
|
162
|
+
* invocations to individually match, so a hostile contract with
|
|
163
|
+
* exactly one matching fn would still contribute to the unknown
|
|
164
|
+
* bucket for any other calls.
|
|
165
|
+
* - We do NOT claim `oz_account` for unknown calls, for arg-shape
|
|
166
|
+
* mismatches, or for calls where the recorder could not pin down
|
|
167
|
+
* the (fn, args) shape. The freshness gate then runs at the same
|
|
168
|
+
* 1.0 threshold - lowering the gate for unknown protocols remains
|
|
169
|
+
* a separate, opt-in production override (see RecordInput below).
|
|
170
|
+
*
|
|
171
|
+
* ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
|
|
172
|
+
* pinned from the OpenZeppelin Reloaded `multisig_account_example`
|
|
173
|
+
* contract (commit ef82b65, fetched 2026-07-28).
|
|
174
|
+
*
|
|
175
|
+
* SCOPE NOTE: this ABI only covers the three entrypoints whose args
|
|
176
|
+
* map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
|
|
177
|
+
* ...). `add_context_rule` - the install path - takes `(Symbol,
|
|
178
|
+
* String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
|
|
179
|
+
* `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
|
|
180
|
+
* not in the AbiArgType set today (the recogniser's `argsMatchAbi`
|
|
181
|
+
* helper rejects `other` deliberately so a close-but-wrong call does
|
|
182
|
+
* not slip through). Excluding `add_context_rule` is the right
|
|
183
|
+
* trade-off for now - it never blocks OZ smart-account recognition,
|
|
184
|
+
* because the install tx always follows a recognised
|
|
185
|
+
* `batch_add_signer` (the demo's first-pass tx), so the
|
|
186
|
+
* contract still ends up in `knownContracts`. Updating the Arg type
|
|
187
|
+
* vocabulary to cover these shapes is a separate concern (and would
|
|
188
|
+
* also lift the SEP-41 / Blend recognition in the same change).
|
|
189
|
+
*/
|
|
190
|
+
export const OZ_ACCOUNT_ABI = {
|
|
191
|
+
batch_add_signer: {
|
|
192
|
+
args: [
|
|
193
|
+
{
|
|
194
|
+
name: 'context_rule_id',
|
|
195
|
+
type: 'u32',
|
|
196
|
+
meaning: 'OZ context rule id this signer is added to',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'signers',
|
|
200
|
+
type: 'vec',
|
|
201
|
+
meaning: 'vec<SignerKey> signers to add (Delegated G-address or Account C-address)',
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
batch_remove_signer: {
|
|
206
|
+
args: [
|
|
207
|
+
{
|
|
208
|
+
name: 'context_rule_id',
|
|
209
|
+
type: 'u32',
|
|
210
|
+
meaning: 'OZ context rule id to remove signers from',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'signers',
|
|
214
|
+
type: 'vec',
|
|
215
|
+
meaning: 'vec<SignerKey> signers to remove from the rule',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
remove_context_rule: {
|
|
220
|
+
args: [
|
|
221
|
+
{
|
|
222
|
+
name: 'context_rule_id',
|
|
223
|
+
type: 'u32',
|
|
224
|
+
meaning: 'OZ context rule id to delete',
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
};
|
|
142
229
|
export const PROTOCOL_ABIS = {
|
|
143
230
|
sep41: SEP41_ABI,
|
|
144
231
|
blend: BLEND_ABI,
|
|
145
232
|
soroswap: SOROSWAP_ABI,
|
|
233
|
+
oz_account: OZ_ACCOUNT_ABI,
|
|
146
234
|
};
|
|
147
235
|
export function getAbi(protocol) {
|
|
148
236
|
return PROTOCOL_ABIS[protocol];
|
|
@@ -12,3 +12,16 @@ export interface ReviewCardSummary {
|
|
|
12
12
|
/** Build a deterministic review-card summary from a policy + context rule +
|
|
13
13
|
* simulation result. Pure: same inputs -> byte-identical output. */
|
|
14
14
|
export declare function buildReviewCardSummary(predicate: PredicateNode | null, policyRefs: PolicyRef[], contextRule: ContextRuleDraft, simulation: SimulationResult): ReviewCardSummary;
|
|
15
|
+
/** Walk every comparison / membership node of the predicate and invoke
|
|
16
|
+
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
17
|
+
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
18
|
+
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
19
|
+
/** Every constraint sentence for one predicate, in walk order.
|
|
20
|
+
*
|
|
21
|
+
* `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
|
|
22
|
+
* and needs the refs / context rule / simulation to do it. This renders a
|
|
23
|
+
* predicate on its own, which is what a caller holding a policy already
|
|
24
|
+
* INSTALLED on chain has: it can read the document out of the interpreter's
|
|
25
|
+
* storage and decode it, but there is no proposal around it. Same renderers,
|
|
26
|
+
* so an installed rule reads exactly as it did on the review card. */
|
|
27
|
+
export declare function describePredicate(predicate: PredicateNode): string[];
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// 2. The interpreter `PredicateNode`. One string per constraint leaf,
|
|
18
18
|
// rendered by enclosing-comparison kind. Templates (Task 7b):
|
|
19
|
-
// - invocation_count_in_window
|
|
19
|
+
// - invocation_count_in_window < N -> At most N calls per <window> seconds
|
|
20
20
|
// - call_arg[i] in [list] -> Recipient/arg must be one of [list]
|
|
21
21
|
// - eq(call_arg[i], literal_vec[...]) -> Path must be exactly [list]
|
|
22
22
|
// - oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
@@ -81,6 +81,23 @@ function renderOzPrimitive(ref) {
|
|
|
81
81
|
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
82
82
|
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
83
83
|
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
84
|
+
/** Every constraint sentence for one predicate, in walk order.
|
|
85
|
+
*
|
|
86
|
+
* `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
|
|
87
|
+
* and needs the refs / context rule / simulation to do it. This renders a
|
|
88
|
+
* predicate on its own, which is what a caller holding a policy already
|
|
89
|
+
* INSTALLED on chain has: it can read the document out of the interpreter's
|
|
90
|
+
* storage and decode it, but there is no proposal around it. Same renderers,
|
|
91
|
+
* so an installed rule reads exactly as it did on the review card. */
|
|
92
|
+
export function describePredicate(predicate) {
|
|
93
|
+
const constraints = [];
|
|
94
|
+
walkPredicate(predicate, (node) => {
|
|
95
|
+
const line = renderConstraint(node);
|
|
96
|
+
if (line !== null)
|
|
97
|
+
constraints.push(line);
|
|
98
|
+
});
|
|
99
|
+
return constraints;
|
|
100
|
+
}
|
|
84
101
|
function walkPredicate(node, visit) {
|
|
85
102
|
switch (node.op) {
|
|
86
103
|
case 'and':
|
|
@@ -168,17 +185,52 @@ function renderComparison(node) {
|
|
|
168
185
|
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
169
186
|
}
|
|
170
187
|
}
|
|
171
|
-
//
|
|
188
|
+
// The bound is compared against the calls ALREADY made in the window, so
|
|
189
|
+
// `< N` permits N of them and `<= N` permits one more. Report how many
|
|
190
|
+
// calls the rule allows rather than restating the comparison.
|
|
172
191
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
173
|
-
|
|
192
|
+
const allowed = node.op === 'lt' ? right.value : right.value + 1;
|
|
193
|
+
return `At most ${allowed} calls per ${left.windowSecs} seconds`;
|
|
194
|
+
}
|
|
195
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
196
|
+
//
|
|
197
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
198
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
199
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
200
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
201
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
202
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
203
|
+
// silently understated the policy.
|
|
204
|
+
//
|
|
205
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
206
|
+
// reads as a path rather than as a comparison.
|
|
207
|
+
if (left.kind === 'call_arg') {
|
|
208
|
+
const head = `arg[${left.index}]`;
|
|
209
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
210
|
+
if (right.kind === 'literal_i128')
|
|
211
|
+
return `${head} ${sep} ${right.value}`;
|
|
212
|
+
if (right.kind === 'literal_u64')
|
|
213
|
+
return `${head} ${sep} ${right.value}`;
|
|
214
|
+
if (right.kind === 'literal_u32')
|
|
215
|
+
return `${head} ${sep} ${right.value}`;
|
|
216
|
+
if (right.kind === 'literal_address')
|
|
217
|
+
return `${head} ${sep} ${right.value}`;
|
|
218
|
+
if (right.kind === 'literal_symbol')
|
|
219
|
+
return `${head} ${sep} ${right.value}`;
|
|
220
|
+
if (right.kind === 'literal_bytes')
|
|
221
|
+
return `${head} ${sep} ${right.value}`;
|
|
222
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
223
|
+
if (right.kind === 'call_arg_scaled') {
|
|
224
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`;
|
|
225
|
+
}
|
|
174
226
|
}
|
|
175
227
|
// amount <= v -> Amount <= v
|
|
176
228
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
177
229
|
return `Amount <= ${right.value}`;
|
|
178
230
|
}
|
|
179
231
|
// oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
180
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
181
|
-
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`;
|
|
232
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
233
|
+
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value} (${right.decimals} dp)`;
|
|
182
234
|
}
|
|
183
235
|
// Any other comparison shape is a structural fail-closed: do not surface
|
|
184
236
|
// a misleading line. Cross-check still requires every leaf produce a
|
|
@@ -216,6 +268,7 @@ function renderVecElement(leaf) {
|
|
|
216
268
|
case 'call_arg':
|
|
217
269
|
case 'call_arg_len':
|
|
218
270
|
case 'call_arg_field':
|
|
271
|
+
case 'call_arg_scaled':
|
|
219
272
|
case 'amount':
|
|
220
273
|
case 'window_spent':
|
|
221
274
|
case 'now':
|
|
@@ -223,6 +276,11 @@ function renderVecElement(leaf) {
|
|
|
223
276
|
case 'invocation_count_in_window':
|
|
224
277
|
case 'oracle_price':
|
|
225
278
|
return `<${leaf.kind}>`;
|
|
279
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
280
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
281
|
+
// card is where a human has to be able to see it.
|
|
282
|
+
case 'oracle_threshold':
|
|
283
|
+
return `${leaf.value} (${leaf.decimals} dp)`;
|
|
226
284
|
}
|
|
227
285
|
}
|
|
228
286
|
function renderHaystackElement(leaf) {
|
|
@@ -105,16 +105,39 @@ function pushComparison(left, right, op, out) {
|
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
// Mirrors the builder's wording byte-for-byte: how many calls the rule
|
|
109
|
+
// permits, since the bound counts the calls already made.
|
|
108
110
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
109
|
-
|
|
111
|
+
const allowed = op === 'lt' ? right.value : right.value + 1;
|
|
112
|
+
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
|
|
110
113
|
return;
|
|
111
114
|
}
|
|
115
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
116
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
117
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
118
|
+
if (left.kind === 'call_arg') {
|
|
119
|
+
const head = `arg[${left.index}]`;
|
|
120
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
121
|
+
if (right.kind === 'literal_i128' ||
|
|
122
|
+
right.kind === 'literal_u64' ||
|
|
123
|
+
right.kind === 'literal_u32' ||
|
|
124
|
+
right.kind === 'literal_address' ||
|
|
125
|
+
right.kind === 'literal_symbol' ||
|
|
126
|
+
right.kind === 'literal_bytes') {
|
|
127
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (right.kind === 'call_arg_scaled') {
|
|
131
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
112
135
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
113
136
|
out.push(`Amount <= ${right.value}`);
|
|
114
137
|
return;
|
|
115
138
|
}
|
|
116
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
117
|
-
out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`);
|
|
139
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
140
|
+
out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`);
|
|
118
141
|
return;
|
|
119
142
|
}
|
|
120
143
|
}
|
|
@@ -145,6 +168,7 @@ function renderVecElement(leaf) {
|
|
|
145
168
|
case 'call_arg':
|
|
146
169
|
case 'call_arg_len':
|
|
147
170
|
case 'call_arg_field':
|
|
171
|
+
case 'call_arg_scaled':
|
|
148
172
|
case 'amount':
|
|
149
173
|
case 'window_spent':
|
|
150
174
|
case 'now':
|
|
@@ -152,6 +176,11 @@ function renderVecElement(leaf) {
|
|
|
152
176
|
case 'invocation_count_in_window':
|
|
153
177
|
case 'oracle_price':
|
|
154
178
|
return `<${leaf.kind}>`;
|
|
179
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
180
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
181
|
+
// card is where a human has to be able to see it.
|
|
182
|
+
case 'oracle_threshold':
|
|
183
|
+
return `${leaf.value} (${leaf.decimals} dp)`;
|
|
155
184
|
}
|
|
156
185
|
}
|
|
157
186
|
function renderHaystackElement(leaf) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { buildReviewCardSummary, type ReviewCardSummary, } from './builder.ts';
|
|
1
|
+
export { buildReviewCardSummary, describePredicate, type ReviewCardSummary, } from './builder.ts';
|
|
2
2
|
export { type ConflictAnnotation, classifyConflict, type RuleRef, } from './conflict.ts';
|
|
3
3
|
export { summaryCrossCheck } from './cross-check.ts';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// src/review-card/index.ts - re-export the deterministic review-card surface.
|
|
2
|
-
export { buildReviewCardSummary, } from "./builder.js";
|
|
2
|
+
export { buildReviewCardSummary, describePredicate, } from "./builder.js";
|
|
3
3
|
export { classifyConflict, } from "./conflict.js";
|
|
4
4
|
export { summaryCrossCheck } from "./cross-check.js";
|
package/dist/run/schemas.d.ts
CHANGED
|
@@ -1093,13 +1093,13 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1093
1093
|
network: "mainnet" | "testnet";
|
|
1094
1094
|
source: "recording";
|
|
1095
1095
|
recordedTx: {
|
|
1096
|
+
signers: string[];
|
|
1096
1097
|
events: {
|
|
1097
1098
|
contract: string;
|
|
1098
1099
|
topics: string[];
|
|
1099
1100
|
data?: unknown;
|
|
1100
1101
|
}[];
|
|
1101
1102
|
network: "mainnet" | "testnet";
|
|
1102
|
-
signers: string[];
|
|
1103
1103
|
invocations: unknown[];
|
|
1104
1104
|
tokenMovements: {
|
|
1105
1105
|
amount: string;
|
|
@@ -1158,13 +1158,13 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1158
1158
|
network: "mainnet" | "testnet";
|
|
1159
1159
|
source: "recording";
|
|
1160
1160
|
recordedTx: {
|
|
1161
|
+
signers: string[];
|
|
1161
1162
|
events: {
|
|
1162
1163
|
contract: string;
|
|
1163
1164
|
topics: string[];
|
|
1164
1165
|
data?: unknown;
|
|
1165
1166
|
}[];
|
|
1166
1167
|
network: "mainnet" | "testnet";
|
|
1167
|
-
signers: string[];
|
|
1168
1168
|
invocations: unknown[];
|
|
1169
1169
|
tokenMovements: {
|
|
1170
1170
|
amount: string;
|
|
@@ -1750,13 +1750,13 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1750
1750
|
network: "mainnet" | "testnet";
|
|
1751
1751
|
source: "recording";
|
|
1752
1752
|
recordedTx: {
|
|
1753
|
+
signers: string[];
|
|
1753
1754
|
events: {
|
|
1754
1755
|
contract: string;
|
|
1755
1756
|
topics: string[];
|
|
1756
1757
|
data?: unknown;
|
|
1757
1758
|
}[];
|
|
1758
1759
|
network: "mainnet" | "testnet";
|
|
1759
|
-
signers: string[];
|
|
1760
1760
|
invocations: unknown[];
|
|
1761
1761
|
tokenMovements: {
|
|
1762
1762
|
amount: string;
|
|
@@ -1815,13 +1815,13 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1815
1815
|
network: "mainnet" | "testnet";
|
|
1816
1816
|
source: "recording";
|
|
1817
1817
|
recordedTx: {
|
|
1818
|
+
signers: string[];
|
|
1818
1819
|
events: {
|
|
1819
1820
|
contract: string;
|
|
1820
1821
|
topics: string[];
|
|
1821
1822
|
data?: unknown;
|
|
1822
1823
|
}[];
|
|
1823
1824
|
network: "mainnet" | "testnet";
|
|
1824
|
-
signers: string[];
|
|
1825
1825
|
invocations: unknown[];
|
|
1826
1826
|
tokenMovements: {
|
|
1827
1827
|
amount: string;
|
|
@@ -8,6 +8,10 @@ export interface OraclePriceBound {
|
|
|
8
8
|
asset: string;
|
|
9
9
|
operator: IRCompOp;
|
|
10
10
|
value: string;
|
|
11
|
+
/** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
|
|
12
|
+
* 9 dp, and a threshold silently assumed to share that basis is what let a
|
|
13
|
+
* raw 14-dp bound permit everything. The author states it; we convert. */
|
|
14
|
+
decimals: number;
|
|
11
15
|
}
|
|
12
16
|
/** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
|
|
13
17
|
* synth might apply must come from here - the recording supplies observed
|
|
@@ -27,6 +31,20 @@ export interface ComposeUserResponses {
|
|
|
27
31
|
* `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
|
|
28
32
|
* entries on the same asset emit multiple leaves. */
|
|
29
33
|
oraclePriceBound?: OraclePriceBound[];
|
|
34
|
+
/** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
|
|
35
|
+
* `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
|
|
36
|
+
* slippage floor bounding the output arg against the input arg of the same
|
|
37
|
+
* call.
|
|
38
|
+
*
|
|
39
|
+
* REQUIRED to be supplied by the caller: it is never derived from the
|
|
40
|
+
* recording. The recorded in/out pair is a price at one moment, and
|
|
41
|
+
* freezing it as policy would deny ordinary trades as soon as the rate
|
|
42
|
+
* moves. Absent, no floor is emitted and the existing unbounded-output
|
|
43
|
+
* warning stands. */
|
|
44
|
+
swapMinOutRatio?: {
|
|
45
|
+
num: string;
|
|
46
|
+
den: string;
|
|
47
|
+
};
|
|
30
48
|
/** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
|
|
31
49
|
* swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
|
|
32
50
|
* pin. Absent -> the recipient is pinned to the recorded value (mirroring
|
|
@@ -105,11 +105,17 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
105
105
|
value: limit,
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
// A rolling spend cap always goes to OZ, whose `spending_limit` is the
|
|
109
|
+
// audited implementation. The interpreter is NOT a fallback for the
|
|
110
|
+
// token != scopeContract case: on chain it sees one authorized call,
|
|
111
|
+
// not the transaction's token movements, so it has no per-call amount
|
|
112
|
+
// to accumulate and the counter would never move. OZ reports the case
|
|
113
|
+
// it cannot cover (it pins the limit to the context contract), which
|
|
114
|
+
// is the honest outcome - the old fallback produced an interpreter
|
|
115
|
+
// predicate that silently never bound.
|
|
116
|
+
ozConstraints.push(spendCond);
|
|
117
|
+
if (interpreterEnabled && token !== scopeContract) {
|
|
118
|
+
warnings.push(`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.`);
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
}
|
|
@@ -134,7 +140,10 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
134
140
|
op: 'compare',
|
|
135
141
|
compare: {
|
|
136
142
|
selector: { kind: 'invocation_count', windowSeconds },
|
|
137
|
-
|
|
143
|
+
// `lt`, not `lte`: the leaf reports the calls ALREADY made in the
|
|
144
|
+
// window, so `< N` is what permits N of them. With `lte` a limit
|
|
145
|
+
// of N let an N+1th call through.
|
|
146
|
+
operator: 'lt',
|
|
138
147
|
value: String(invocationLimit),
|
|
139
148
|
},
|
|
140
149
|
};
|
|
@@ -165,6 +174,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
165
174
|
selector: { kind: 'oracle_price', asset: b.asset },
|
|
166
175
|
operator: b.operator,
|
|
167
176
|
value: b.value,
|
|
177
|
+
valueDecimals: b.decimals,
|
|
168
178
|
},
|
|
169
179
|
};
|
|
170
180
|
if (interpreterEnabled) {
|
|
@@ -187,7 +197,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
187
197
|
// the window_spent path above already consumed the limit, so the per-call
|
|
188
198
|
// arg cap is skipped to avoid binding one limit to two different semantics.
|
|
189
199
|
const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
|
|
190
|
-
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled);
|
|
200
|
+
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
|
|
191
201
|
}
|
|
192
202
|
// `scope.method` is carried on BOTH IRs so each adapter produces a
|
|
193
203
|
// self-consistent rule. The interpreter adapter lowers scope.method into a
|
|
@@ -229,7 +239,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
229
239
|
* SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
|
|
230
240
|
* (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
|
|
231
241
|
* call_arg[2]). */
|
|
232
|
-
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled) {
|
|
242
|
+
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
|
|
233
243
|
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
234
244
|
// a single-element allowlist; the interpreter adapter lowers it to `in`.
|
|
235
245
|
// When interpreter is not enabled, route to OZ so the caller sees today's
|
|
@@ -300,8 +310,15 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
300
310
|
});
|
|
301
311
|
for (let i = 0; i < elements.length; i++) {
|
|
302
312
|
const element = elements[i];
|
|
303
|
-
|
|
313
|
+
// An element we cannot bind leaves that request's action, asset and
|
|
314
|
+
// amount free while the length pin makes the policy LOOK total. The
|
|
315
|
+
// length pin still stays (it is a real restriction, and dropping it
|
|
316
|
+
// would only widen the policy) - what must not happen is dropping the
|
|
317
|
+
// element quietly.
|
|
318
|
+
if (element?.type !== 'map') {
|
|
319
|
+
warnings.push(`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`);
|
|
304
320
|
continue;
|
|
321
|
+
}
|
|
305
322
|
const fields = element.value;
|
|
306
323
|
const fieldValue = (name, scalarType) => {
|
|
307
324
|
const entry = fields.find((e) => e.key === name);
|
|
@@ -318,8 +335,17 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
318
335
|
const address = fieldValue('address', 'address');
|
|
319
336
|
const amount = fieldValue('amount', 'i128');
|
|
320
337
|
const requestType = fieldValue('request_type', 'u32');
|
|
321
|
-
if (address === null || amount === null || requestType === null)
|
|
338
|
+
if (address === null || amount === null || requestType === null) {
|
|
339
|
+
const missing = [
|
|
340
|
+
requestType === null ? 'request_type (u32)' : null,
|
|
341
|
+
address === null ? 'address' : null,
|
|
342
|
+
amount === null ? 'amount (i128)' : null,
|
|
343
|
+
].filter((f) => f !== null);
|
|
344
|
+
// Partial pins are deliberately NOT emitted (see the note above): a
|
|
345
|
+
// half-pinned request reads as a bound one on the review card.
|
|
346
|
+
warnings.push(`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`);
|
|
322
347
|
continue;
|
|
348
|
+
}
|
|
323
349
|
interpreterConstraints.push({
|
|
324
350
|
op: 'compare',
|
|
325
351
|
compare: {
|
|
@@ -421,6 +447,30 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
421
447
|
ozConstraints.push(cond);
|
|
422
448
|
}
|
|
423
449
|
}
|
|
450
|
+
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
451
|
+
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
452
|
+
// which is the case the unbounded-swap warning above describes.
|
|
453
|
+
const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn);
|
|
454
|
+
const minOutRatio = swapMinOutRatio;
|
|
455
|
+
if (minOutRatio !== undefined &&
|
|
456
|
+
inputArgIndex !== undefined &&
|
|
457
|
+
outMinArgIndex !== undefined &&
|
|
458
|
+
inputAmountArg &&
|
|
459
|
+
inputAmountArg.type === 'i128') {
|
|
460
|
+
const floor = {
|
|
461
|
+
op: 'slippage_floor',
|
|
462
|
+
outArgIndex: outMinArgIndex,
|
|
463
|
+
inArgIndex: inputArgIndex,
|
|
464
|
+
num: minOutRatio.num,
|
|
465
|
+
den: minOutRatio.den,
|
|
466
|
+
};
|
|
467
|
+
if (interpreterEnabled) {
|
|
468
|
+
interpreterConstraints.push(floor);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
ozConstraints.push(floor);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
424
474
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
425
475
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
426
476
|
// arg. When absent, PIN the recipient to the recorded value - mirroring the
|
|
@@ -465,6 +515,21 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
465
515
|
* the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
|
|
466
516
|
* input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
|
|
467
517
|
* function has no positional input-amount argument -> undefined (no cap bound). */
|
|
518
|
+
/** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
|
|
519
|
+
*
|
|
520
|
+
* Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
|
|
521
|
+
* fixes the output and varies the input, so its output needs no floor (its
|
|
522
|
+
* arg[1] is `amount_in_max`, already bounded by the input cap). Returning
|
|
523
|
+
* undefined there keeps a floor from being pinned to the wrong argument. */
|
|
524
|
+
function soroswapMinOutArgIndex(fn) {
|
|
525
|
+
switch (fn) {
|
|
526
|
+
case 'swap_exact_tokens_for_tokens':
|
|
527
|
+
case 'swap_exact_in_for_tokens':
|
|
528
|
+
return 1;
|
|
529
|
+
default:
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
468
533
|
function soroswapInputAmountArgIndex(fn) {
|
|
469
534
|
switch (fn) {
|
|
470
535
|
case 'swap_exact_tokens_for_tokens':
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PredicateNode } from '../types.ts';
|
|
1
|
+
import type { PredicateNode, ScVal } from '../types.ts';
|
|
2
2
|
import type { EvalContext } from './evaluate.ts';
|
|
3
3
|
export interface DenyCase {
|
|
4
4
|
dimension: string;
|
|
@@ -20,3 +20,6 @@ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
|
|
|
20
20
|
* are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
|
|
21
21
|
* existing fixtures do not regress. */
|
|
22
22
|
export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
|
|
23
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
24
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
25
|
+
export declare function cloneScVal(value: ScVal, depth?: number): ScVal;
|
package/dist/synth/deny-cases.js
CHANGED
|
@@ -476,7 +476,9 @@ function cloneContext(ctx) {
|
|
|
476
476
|
cloned.signerWeights = { ...ctx.signerWeights };
|
|
477
477
|
return cloned;
|
|
478
478
|
}
|
|
479
|
-
|
|
479
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
480
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
481
|
+
export function cloneScVal(value, depth = 0) {
|
|
480
482
|
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
481
483
|
// payload cannot RangeError the JS stack during deny-case mutation. Over-depth
|
|
482
484
|
// throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
|