@crediolabs/policy-synth 0.1.17 → 0.2.0
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/README.md +44 -246
- package/dist/adapters/interpreter/adapter.d.ts +6 -7
- package/dist/adapters/interpreter/adapter.js +28 -48
- package/dist/adapters/oz/adapter.js +12 -13
- package/dist/codegen/compile-gate.js +5 -0
- package/dist/codegen/template.js +17 -0
- package/dist/install/authority-overlap.d.ts +134 -0
- package/dist/install/authority-overlap.js +0 -0
- package/dist/install/build-add-context-rule.d.ts +8 -0
- package/dist/install/build-add-context-rule.js +17 -46
- package/dist/install/build-install-policy.d.ts +12 -5
- package/dist/install/build-install-policy.js +32 -23
- package/dist/install/build-merge-policy.d.ts +70 -0
- package/dist/install/build-merge-policy.js +130 -0
- package/dist/install/get-interpreter-info.js +2 -2
- package/dist/install/index.d.ts +3 -1
- package/dist/install/index.js +8 -1
- package/dist/install/oz-auth.js +5 -3
- package/dist/install/plan-merge-policy.d.ts +49 -0
- package/dist/install/plan-merge-policy.js +86 -0
- package/dist/install/read-account-rules.d.ts +100 -0
- package/dist/install/read-account-rules.js +283 -0
- package/dist/predicate/decode.js +1 -1
- package/dist/predicate/encode.js +171 -10
- package/dist/record/decode.js +5 -6
- package/dist/registry/protocols.d.ts +1 -1
- package/dist/registry/protocols.js +1 -1
- package/dist/review-card/builder.d.ts +6 -0
- package/dist/review-card/builder.js +9 -1
- package/dist/run/index.d.ts +104 -18
- package/dist/run/index.js +359 -75
- package/dist/run/schemas.d.ts +479 -18
- package/dist/run/schemas.js +132 -24
- package/dist/synth/compose-from-recording.d.ts +10 -15
- package/dist/synth/compose-from-recording.js +79 -124
- package/dist/synth/deny-cases.d.ts +5 -0
- package/dist/synth/deny-cases.js +52 -13
- package/dist/synth/evaluate.js +69 -119
- package/dist/synth/harness.d.ts +13 -1
- package/dist/synth/harness.js +22 -1
- package/dist/synth/index.d.ts +1 -1
- package/dist/synth/synthesize-from-recording.d.ts +31 -21
- package/dist/synth/synthesize-from-recording.js +129 -155
- package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
- package/dist-cjs/adapters/interpreter/adapter.js +28 -48
- package/dist-cjs/adapters/oz/adapter.js +12 -13
- package/dist-cjs/codegen/compile-gate.js +5 -0
- package/dist-cjs/codegen/template.js +17 -0
- package/dist-cjs/install/authority-overlap.d.ts +134 -0
- package/dist-cjs/install/authority-overlap.js +0 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
- package/dist-cjs/install/build-add-context-rule.js +17 -45
- package/dist-cjs/install/build-install-policy.d.ts +12 -5
- package/dist-cjs/install/build-install-policy.js +32 -23
- package/dist-cjs/install/build-merge-policy.d.ts +70 -0
- package/dist-cjs/install/build-merge-policy.js +134 -0
- package/dist-cjs/install/get-interpreter-info.js +2 -2
- package/dist-cjs/install/index.d.ts +3 -1
- package/dist-cjs/install/index.js +24 -3
- package/dist-cjs/install/oz-auth.js +5 -3
- package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
- package/dist-cjs/install/plan-merge-policy.js +90 -0
- package/dist-cjs/install/read-account-rules.d.ts +100 -0
- package/dist-cjs/install/read-account-rules.js +296 -0
- package/dist-cjs/predicate/decode.js +1 -1
- package/dist-cjs/predicate/encode.js +171 -10
- package/dist-cjs/record/decode.js +5 -6
- package/dist-cjs/registry/protocols.d.ts +1 -1
- package/dist-cjs/registry/protocols.js +1 -1
- package/dist-cjs/review-card/builder.d.ts +6 -0
- package/dist-cjs/review-card/builder.js +9 -1
- package/dist-cjs/run/index.d.ts +104 -18
- package/dist-cjs/run/index.js +360 -74
- package/dist-cjs/run/schemas.d.ts +479 -18
- package/dist-cjs/run/schemas.js +133 -25
- package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
- package/dist-cjs/synth/compose-from-recording.js +79 -124
- package/dist-cjs/synth/deny-cases.d.ts +5 -0
- package/dist-cjs/synth/deny-cases.js +52 -13
- package/dist-cjs/synth/evaluate.js +69 -119
- package/dist-cjs/synth/harness.d.ts +13 -1
- package/dist-cjs/synth/harness.js +22 -1
- package/dist-cjs/synth/index.d.ts +1 -1
- package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
- package/dist-cjs/synth/synthesize-from-recording.js +129 -153
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +28 -48
- package/src/adapters/oz/adapter.ts +12 -13
- package/src/codegen/compile-gate.ts +5 -0
- package/src/codegen/template.ts +17 -0
- package/src/install/authority-overlap.ts +0 -0
- package/src/install/build-add-context-rule.ts +29 -56
- package/src/install/build-install-policy.ts +41 -23
- package/src/install/build-merge-policy.ts +219 -0
- package/src/install/get-interpreter-info.ts +2 -2
- package/src/install/index.ts +36 -2
- package/src/install/oz-auth.ts +5 -3
- package/src/install/plan-merge-policy.ts +133 -0
- package/src/install/read-account-rules.ts +376 -0
- package/src/predicate/decode.ts +1 -1
- package/src/predicate/encode.ts +176 -10
- package/src/record/decode.ts +5 -6
- package/src/registry/protocols.ts +1 -1
- package/src/review-card/builder.ts +17 -1
- package/src/run/index.ts +500 -102
- package/src/run/schemas.ts +145 -24
- package/src/synth/compose-from-recording.ts +87 -132
- package/src/synth/deny-cases.ts +59 -13
- package/src/synth/evaluate.ts +70 -118
- package/src/synth/harness.ts +29 -1
- package/src/synth/index.ts +1 -0
- package/src/synth/synthesize-from-recording.ts +209 -192
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
|
@@ -1,53 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ
|
|
3
|
-
//
|
|
4
|
-
// Composes the canonical IR rules the two backends compile FROM. Fail-closed
|
|
5
|
-
// composition rules:
|
|
6
|
-
//
|
|
7
|
-
// - identify the protocol of the top-level call (registry.identifyProtocol).
|
|
8
|
-
// When it is unknown (null), emit NO OZ-primitive-producing IR node: the
|
|
9
|
-
// scope is kept (CallContract + method) and every inferred bound is surfaced
|
|
10
|
-
// as a descriptive warning. An unrecognised call never compiles to a
|
|
11
|
-
// permissive OZ primitive.
|
|
2
|
+
// src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ + interpreter).
|
|
12
3
|
//
|
|
4
|
+
// Fail-closed composition rules:
|
|
5
|
+
// - unknown top-level protocol (registry.identifyProtocol returns null) ->
|
|
6
|
+
// emit no OZ-primitive-producing IR node; scope is kept (CallContract + method)
|
|
7
|
+
// and every inferred bound surfaces as a descriptive warning. An unrecognised
|
|
8
|
+
// call never compiles to a permissive OZ primitive.
|
|
13
9
|
// - carry the recorded top-level function into `rule.scope.method` so the OZ
|
|
14
10
|
// adapter flags per-method scoping as not covered (CallContract permits every
|
|
15
|
-
// method
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// window
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// -
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// fabricated count.
|
|
30
|
-
//
|
|
31
|
-
// - the IR carries ONLY constraints justified by the recording (observed
|
|
32
|
-
// recipient allowlist) + explicit user input. Nothing invented: no oracle
|
|
33
|
-
// price fabricated from a slippage bound, no synthetic exact-path compare.
|
|
34
|
-
// Those needs are surfaced as descriptive warnings instead.
|
|
11
|
+
// method; a per-method restriction needs the interpreter predicate).
|
|
12
|
+
// - `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
|
|
13
|
+
// the caller supplies BOTH limit (userResponses.limitAmount) AND window
|
|
14
|
+
// (userResponses.windowSeconds). A single recorded spend does NOT authorise
|
|
15
|
+
// that amount every window, so the observed amount is NEVER used as an
|
|
16
|
+
// auto-ceiling: missing limit -> AMOUNT_BOUND_MISSING (observed amount
|
|
17
|
+
// offered as a suggestion); missing window -> DURATION_UNSPECIFIED. EVERY
|
|
18
|
+
// spent token is handled - none is silently dropped.
|
|
19
|
+
// - incoming-only flows emit an `invocation_count` bound ONLY when the caller
|
|
20
|
+
// supplies both the count and the window; otherwise FREQUENCY_BOUND_MISSING
|
|
21
|
+
// with no fabricated count.
|
|
22
|
+
// - the IR carries ONLY constraints justified by the recording + explicit
|
|
23
|
+
// user input. Nothing invented: no oracle price fabricated from a slippage
|
|
24
|
+
// bound, no synthetic exact-path compare. Those needs surface as warnings.
|
|
35
25
|
//
|
|
36
26
|
// Split rule (P3 wiring): `ComposeResult` carries BOTH `ir` (OZ-shape) and
|
|
37
27
|
// `interpreterIr` (predicate-shape). Each constraint is routed to EXACTLY ONE
|
|
38
28
|
// adapter:
|
|
39
|
-
// - `
|
|
40
|
-
//
|
|
41
|
-
// - everything else
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
29
|
+
// - `window_spent(token, w) <= limit` where `token === scope.contract` and
|
|
30
|
+
// protocol is known -> `ir` (OZ lowers to spending_limit).
|
|
31
|
+
// - everything else (recipient allowlists, per-method scope.method,
|
|
32
|
+
// invocation_count, eq_seq swap paths, oracle_price, AND window_spent
|
|
33
|
+
// where token != scope.contract i.e. SoroSwap input-token cap) ->
|
|
34
|
+
// `interpreterIr`.
|
|
45
35
|
//
|
|
46
36
|
// This prevents the interpreter adapter from emitting a duplicate
|
|
47
37
|
// `window_spent` predicate leaf alongside an OZ `spending_limit` primitive
|
|
48
38
|
// covering the same spend semantic - the two adapters never overlap.
|
|
49
39
|
//
|
|
50
|
-
//
|
|
40
|
+
// Default policy is `deny_all` (OZ context rules are deny-by-default).
|
|
51
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
42
|
exports.composeFromRecording = composeFromRecording;
|
|
53
43
|
const identify_ts_1 = require("../registry/identify.js");
|
|
@@ -59,6 +49,15 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
59
49
|
const warnings = [];
|
|
60
50
|
const ozConstraints = [];
|
|
61
51
|
const interpreterConstraints = [];
|
|
52
|
+
// Route to the matching IR. When the interpreter is enabled, constraints the
|
|
53
|
+
// OZ adapter cannot lower go there; otherwise they go to OZ (which flags them
|
|
54
|
+
// as uncovered) so today's warning-driven behaviour is preserved.
|
|
55
|
+
const routeToAdapter = (cond) => {
|
|
56
|
+
if (interpreterEnabled)
|
|
57
|
+
interpreterConstraints.push(cond);
|
|
58
|
+
else
|
|
59
|
+
ozConstraints.push(cond);
|
|
60
|
+
};
|
|
62
61
|
const protocol = topLevel
|
|
63
62
|
? (0, identify_ts_1.identifyProtocol)(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
|
|
64
63
|
: null;
|
|
@@ -66,14 +65,15 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
66
65
|
const windowSeconds = opts.userResponses?.windowSeconds;
|
|
67
66
|
const limitAmount = opts.userResponses?.limitAmount;
|
|
68
67
|
const spendTokens = Object.keys(facts.spendByToken);
|
|
69
|
-
// Outgoing spend -> one spending_limit per spent token. A single caller
|
|
70
|
-
// binds only an unambiguous single-token spend; a multi-token flow
|
|
71
|
-
// per-token limit, so each unmatched token surfaces
|
|
68
|
+
// Outgoing spend -> one spending_limit per spent token. A single caller
|
|
69
|
+
// limit binds only an unambiguous single-token spend; a multi-token flow
|
|
70
|
+
// needs a per-token limit, so each unmatched token surfaces
|
|
71
|
+
// AMOUNT_BOUND_MISSING.
|
|
72
72
|
//
|
|
73
73
|
// Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
|
|
74
74
|
// only when token === scope.contract (OZ's spending_limit binds the
|
|
75
75
|
// CallContract target, not a token parameter). Otherwise it goes to the
|
|
76
|
-
// interpreter IR
|
|
76
|
+
// interpreter IR.
|
|
77
77
|
if (spendTokens.length > 0 && topLevel) {
|
|
78
78
|
let durationFlagged = false;
|
|
79
79
|
for (const token of spendTokens) {
|
|
@@ -99,24 +99,23 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
99
99
|
});
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
|
-
if (windowSeconds !== undefined
|
|
103
|
-
|
|
102
|
+
if (windowSeconds !== undefined) {
|
|
103
|
+
// Rolling spend cap always goes to OZ (`spending_limit` is the audited
|
|
104
|
+
// implementation). The interpreter is NOT a fallback for token !=
|
|
105
|
+
// scopeContract: on chain it sees one authorized call, not the
|
|
106
|
+
// transaction's token movements, so it has no per-call amount to
|
|
107
|
+
// accumulate and the counter would never move. OZ reports the case it
|
|
108
|
+
// cannot cover (limit pins to the context contract) - the honest
|
|
109
|
+
// outcome; the old fallback produced an interpreter predicate that
|
|
110
|
+
// silently never bound.
|
|
111
|
+
ozConstraints.push({
|
|
104
112
|
op: 'compare',
|
|
105
113
|
compare: {
|
|
106
114
|
selector: { kind: 'window_spent', token, windowSeconds },
|
|
107
115
|
operator: 'lte',
|
|
108
116
|
value: limit,
|
|
109
117
|
},
|
|
110
|
-
};
|
|
111
|
-
// A rolling spend cap always goes to OZ, whose `spending_limit` is the
|
|
112
|
-
// audited implementation. The interpreter is NOT a fallback for the
|
|
113
|
-
// token != scopeContract case: on chain it sees one authorized call,
|
|
114
|
-
// not the transaction's token movements, so it has no per-call amount
|
|
115
|
-
// to accumulate and the counter would never move. OZ reports the case
|
|
116
|
-
// it cannot cover (it pins the limit to the context contract), which
|
|
117
|
-
// is the honest outcome - the old fallback produced an interpreter
|
|
118
|
-
// predicate that silently never bound.
|
|
119
|
-
ozConstraints.push(spendCond);
|
|
118
|
+
});
|
|
120
119
|
if (interpreterEnabled && token !== scopeContract) {
|
|
121
120
|
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.`);
|
|
122
121
|
}
|
|
@@ -128,18 +127,18 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
128
127
|
// Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
|
|
129
128
|
// invocation_count); otherwise to the OZ IR (which flags it as uncovered).
|
|
130
129
|
//
|
|
131
|
-
// A recognised swap is NOT an incoming-only flow: it has an outgoing input
|
|
132
|
-
// whose spend simply was not attributed to the source account
|
|
133
|
-
// fee-sponsored / holder != source
|
|
134
|
-
// recipient,
|
|
135
|
-
//
|
|
130
|
+
// A recognised swap is NOT an incoming-only flow: it has an outgoing input
|
|
131
|
+
// leg whose spend simply was not attributed to the source account
|
|
132
|
+
// (fee-sponsored / holder != source). Its real restrictions - exact path,
|
|
133
|
+
// recipient, input-amount cap - come from the protocol-specific pass, so it
|
|
134
|
+
// does NOT get the incoming-only frequency prompt. A caller wanting to
|
|
136
135
|
// rate-limit the swap can still supply an invocationLimit + window, which
|
|
137
136
|
// lowers to an invocation_count for any flow.
|
|
138
137
|
if (spendTokens.length === 0 && topLevel) {
|
|
139
138
|
const invocationLimit = opts.userResponses?.invocationLimit;
|
|
140
139
|
const isRecognisedSwap = protocol?.protocol === 'soroswap';
|
|
141
140
|
if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
|
|
142
|
-
|
|
141
|
+
routeToAdapter({
|
|
143
142
|
op: 'compare',
|
|
144
143
|
compare: {
|
|
145
144
|
selector: { kind: 'invocation_count', windowSeconds },
|
|
@@ -149,13 +148,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
149
148
|
operator: 'lt',
|
|
150
149
|
value: String(invocationLimit),
|
|
151
150
|
},
|
|
152
|
-
};
|
|
153
|
-
if (interpreterEnabled) {
|
|
154
|
-
interpreterConstraints.push(icCond);
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
ozConstraints.push(icCond);
|
|
158
|
-
}
|
|
151
|
+
});
|
|
159
152
|
}
|
|
160
153
|
else if (!isRecognisedSwap) {
|
|
161
154
|
ambiguities.push({
|
|
@@ -171,7 +164,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
171
164
|
const oracleBounds = opts.userResponses?.oraclePriceBound;
|
|
172
165
|
if (oracleBounds) {
|
|
173
166
|
for (const b of oracleBounds) {
|
|
174
|
-
|
|
167
|
+
routeToAdapter({
|
|
175
168
|
op: 'compare',
|
|
176
169
|
compare: {
|
|
177
170
|
selector: { kind: 'oracle_price', asset: b.asset },
|
|
@@ -179,13 +172,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
179
172
|
value: b.value,
|
|
180
173
|
valueDecimals: b.decimals,
|
|
181
174
|
},
|
|
182
|
-
};
|
|
183
|
-
if (interpreterEnabled) {
|
|
184
|
-
interpreterConstraints.push(oracleCond);
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
ozConstraints.push(oracleCond);
|
|
188
|
-
}
|
|
175
|
+
});
|
|
189
176
|
}
|
|
190
177
|
}
|
|
191
178
|
// Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
|
|
@@ -200,7 +187,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
200
187
|
// the window_spent path above already consumed the limit, so the per-call
|
|
201
188
|
// arg cap is skipped to avoid binding one limit to two different semantics.
|
|
202
189
|
const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
|
|
203
|
-
appendProtocolSpecificConstraints(
|
|
190
|
+
appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapter, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
|
|
204
191
|
}
|
|
205
192
|
// `scope.method` is carried on BOTH IRs so each adapter produces a
|
|
206
193
|
// self-consistent rule. The interpreter adapter lowers scope.method into a
|
|
@@ -242,32 +229,24 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
242
229
|
* SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
|
|
243
230
|
* (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
|
|
244
231
|
* call_arg[2]). */
|
|
245
|
-
function appendProtocolSpecificConstraints(
|
|
246
|
-
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit
|
|
247
|
-
//
|
|
232
|
+
function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapter, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
|
|
233
|
+
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit a
|
|
234
|
+
// single-element allowlist; the interpreter adapter lowers it to `in`.
|
|
248
235
|
// When interpreter is not enabled, route to OZ so the caller sees today's
|
|
249
|
-
// `value allowlist on arg 1
|
|
236
|
+
// `value allowlist on arg 1` warning.
|
|
250
237
|
if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
|
|
251
238
|
const toArg = topLevel.args[1];
|
|
252
239
|
if (toArg && toArg.type === 'address') {
|
|
253
|
-
|
|
240
|
+
routeToAdapter({
|
|
254
241
|
op: 'in',
|
|
255
242
|
selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
|
|
256
243
|
values: [toArg.value],
|
|
257
|
-
};
|
|
258
|
-
if (interpreterEnabled) {
|
|
259
|
-
interpreterConstraints.push(cond);
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
ozConstraints.push(cond);
|
|
263
|
-
}
|
|
244
|
+
});
|
|
264
245
|
}
|
|
265
246
|
}
|
|
266
247
|
// Blend submit / claim: the `to` arg is the beneficiary - it receives the
|
|
267
248
|
// resulting position shares on `submit` and the claimed tokens on `claim`.
|
|
268
|
-
//
|
|
269
|
-
// recipient below, so it is pinned to the recorded address the same way:
|
|
270
|
-
// without it a policy scoped to a pool and method still lets an agent send
|
|
249
|
+
// Without it, a policy scoped to a pool + method still lets an agent send
|
|
271
250
|
// the proceeds anywhere. `from` and `spender` are deliberately NOT pinned -
|
|
272
251
|
// the call already requires their authorisation, so binding them adds no
|
|
273
252
|
// restriction the chain is not already enforcing.
|
|
@@ -276,25 +255,19 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
276
255
|
// claim(from, reserve_token_ids, to).
|
|
277
256
|
const toArg = topLevel.args[2];
|
|
278
257
|
if (toArg && toArg.type === 'address') {
|
|
279
|
-
|
|
258
|
+
routeToAdapter({
|
|
280
259
|
op: 'in',
|
|
281
260
|
selector: { kind: 'arg', argIndex: 2, scalarType: 'address' },
|
|
282
261
|
values: [toArg.value],
|
|
283
|
-
};
|
|
284
|
-
if (interpreterEnabled) {
|
|
285
|
-
interpreterConstraints.push(cond);
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
ozConstraints.push(cond);
|
|
289
|
-
}
|
|
262
|
+
});
|
|
290
263
|
}
|
|
291
264
|
}
|
|
292
265
|
// Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
|
|
293
266
|
// reserve_token_ids - no map fields to bind). The `requests` vec
|
|
294
267
|
// (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
|
|
295
|
-
// Request is the per-reserve action selector
|
|
268
|
+
// Request is the per-reserve action selector (0 Supply, 1 Withdraw,
|
|
296
269
|
// 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
|
|
297
|
-
// liquidation/auction fills. Pinning only one element is unsafe: a caller
|
|
270
|
+
// liquidation/auction fills). Pinning only one element is unsafe: a caller
|
|
298
271
|
// can append a second element with a different action (WithdrawCollateral
|
|
299
272
|
// -> Borrow on a different asset, any amount, then auction fills). Length
|
|
300
273
|
// + per-element pinning is total; a quantifier over elements is not. If we
|
|
@@ -435,20 +408,14 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
435
408
|
inputArgIndex !== undefined &&
|
|
436
409
|
inputAmountArg &&
|
|
437
410
|
inputAmountArg.type === 'i128') {
|
|
438
|
-
|
|
411
|
+
routeToAdapter({
|
|
439
412
|
op: 'compare',
|
|
440
413
|
compare: {
|
|
441
414
|
selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
|
|
442
415
|
operator: 'lte',
|
|
443
416
|
value: swapInputAmountCap,
|
|
444
417
|
},
|
|
445
|
-
};
|
|
446
|
-
if (interpreterEnabled) {
|
|
447
|
-
interpreterConstraints.push(cond);
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
ozConstraints.push(cond);
|
|
451
|
-
}
|
|
418
|
+
});
|
|
452
419
|
}
|
|
453
420
|
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
454
421
|
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
@@ -460,19 +427,13 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
460
427
|
outMinArgIndex !== undefined &&
|
|
461
428
|
inputAmountArg &&
|
|
462
429
|
inputAmountArg.type === 'i128') {
|
|
463
|
-
|
|
430
|
+
routeToAdapter({
|
|
464
431
|
op: 'slippage_floor',
|
|
465
432
|
outArgIndex: outMinArgIndex,
|
|
466
433
|
inArgIndex: inputArgIndex,
|
|
467
434
|
num: minOutRatio.num,
|
|
468
435
|
den: minOutRatio.den,
|
|
469
|
-
};
|
|
470
|
-
if (interpreterEnabled) {
|
|
471
|
-
interpreterConstraints.push(floor);
|
|
472
|
-
}
|
|
473
|
-
else {
|
|
474
|
-
ozConstraints.push(floor);
|
|
475
|
-
}
|
|
436
|
+
});
|
|
476
437
|
}
|
|
477
438
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
478
439
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
@@ -488,17 +449,11 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
488
449
|
// (today's behaviour, matching the other SoroSwap constraints).
|
|
489
450
|
const recipientArg = topLevel.args[3];
|
|
490
451
|
if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
|
|
491
|
-
|
|
452
|
+
routeToAdapter({
|
|
492
453
|
op: 'in',
|
|
493
454
|
selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
|
|
494
455
|
values: [...swapRecipientAllowlist],
|
|
495
|
-
};
|
|
496
|
-
if (interpreterEnabled) {
|
|
497
|
-
interpreterConstraints.push(cond);
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
ozConstraints.push(cond);
|
|
501
|
-
}
|
|
456
|
+
});
|
|
502
457
|
}
|
|
503
458
|
else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
|
|
504
459
|
interpreterConstraints.push({
|
|
@@ -3,6 +3,11 @@ import type { EvalContext } from './evaluate.ts';
|
|
|
3
3
|
export interface DenyCase {
|
|
4
4
|
dimension: string;
|
|
5
5
|
ctx: EvalContext;
|
|
6
|
+
/** Canonical reason the Rust interpreter returns for this dimension.
|
|
7
|
+
* Optional: when set, `runHarness` asserts the TS evaluator emits this
|
|
8
|
+
* exact string, so a future TS/Rust reason divergence fails CI. When
|
|
9
|
+
* absent the case is only checked on the boolean decision. */
|
|
10
|
+
expectedReason?: string;
|
|
6
11
|
}
|
|
7
12
|
export interface GeneratedCases {
|
|
8
13
|
permit: EvalContext;
|
|
@@ -11,17 +11,30 @@ const ORACLE_CASES = [
|
|
|
11
11
|
['oracle_deviation_exceeded', 'deviation'],
|
|
12
12
|
['oracle_paused', 'paused'],
|
|
13
13
|
];
|
|
14
|
+
// Cross-layer reason contract: the TS evaluator's deny reason for each
|
|
15
|
+
// oracle error category must match the Rust interpreter's `DenyReason` code
|
|
16
|
+
// string (the same strings surface in the conformance fixture). The harness
|
|
17
|
+
// asserts them via the per-case `expectedReason`; a future divergence
|
|
18
|
+
// surfaces as a REASON_MISMATCH failure.
|
|
19
|
+
const ORACLE_ERROR_REASON = {
|
|
20
|
+
stale: 'ORACLE_STALE',
|
|
21
|
+
missing: 'ORACLE_MISSING',
|
|
22
|
+
deviation: 'ORACLE_DEVIATION_EXCEEDED',
|
|
23
|
+
paused: 'ORACLE_PAUSED',
|
|
24
|
+
};
|
|
14
25
|
// Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
|
|
15
26
|
const ADJACENT_ASSETS = [
|
|
16
27
|
'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
|
|
17
28
|
'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
|
|
18
29
|
];
|
|
19
|
-
//
|
|
30
|
+
// Property-harness mutation dimensions excluded from the synth pipeline's
|
|
20
31
|
// self-verify call so existing fixtures still emit policies. The harness tests
|
|
21
32
|
// them as FINDINGS against the already-emitted policy.
|
|
22
33
|
const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
|
|
23
34
|
exports.OVERPERMISSIVE_DIMENSIONS = OVERPERMISSIVE_DIMENSIONS;
|
|
24
|
-
// The
|
|
35
|
+
// The dimensions the synth pipeline uses for self-verify and minimise. No count
|
|
36
|
+
// is written here on purpose: this comment said 15 while the array held 17, and
|
|
37
|
+
// anyone checking the number should read the array rather than the prose.
|
|
25
38
|
// Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
|
|
26
39
|
// alongside the existing dimensions so the per-element binds emitted for
|
|
27
40
|
// Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
|
|
@@ -67,7 +80,7 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
67
80
|
continue;
|
|
68
81
|
const mutated = mutateBigIntRecord(permitCtx, 'amountByToken', comparison.left.token, comparison);
|
|
69
82
|
if (mutated)
|
|
70
|
-
denies.push({ dimension: 'amount', ctx: mutated });
|
|
83
|
+
denies.push({ dimension: 'amount', ctx: mutated, expectedReason: 'AMOUNT_BOUND' });
|
|
71
84
|
}
|
|
72
85
|
const movedTokens = new Set();
|
|
73
86
|
for (const comparison of facts.comparisons) {
|
|
@@ -76,6 +89,15 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
for (const token of movedTokens) {
|
|
92
|
+
// The `asset` deny case mutates BOTH the amount record AND the contract
|
|
93
|
+
// binding (token's value moved to an adjacent asset; the same swap applied
|
|
94
|
+
// to any address-typed `call_arg` / `call_contract` literal). The actual
|
|
95
|
+
// deny reason is therefore predicate-dependent - the order of evaluation
|
|
96
|
+
// is CONTRACT_SCOPE (step 3) before AMOUNT_BOUND (step 6), so a policy
|
|
97
|
+
// that has both bindings denies with CONTRACT_SCOPE, while a policy with
|
|
98
|
+
// only an amount binding denies with AMOUNT_BOUND. The reason assertion
|
|
99
|
+
// would be brittle here; the boolean decision is what we strictly need
|
|
100
|
+
// to pin. Leave `expectedReason` unset for this dimension.
|
|
79
101
|
denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) });
|
|
80
102
|
}
|
|
81
103
|
const contractConstraints = [
|
|
@@ -87,7 +109,7 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
87
109
|
for (const _constraint of contractConstraints) {
|
|
88
110
|
const ctx = cloneContext(permitCtx);
|
|
89
111
|
ctx.contract = distinctText(permitCtx.contract, 'contract');
|
|
90
|
-
denies.push({ dimension: 'contract', ctx });
|
|
112
|
+
denies.push({ dimension: 'contract', ctx, expectedReason: 'CONTRACT_SCOPE' });
|
|
91
113
|
}
|
|
92
114
|
const functionConstraints = [
|
|
93
115
|
...facts.comparisons.filter((node) => node.op === 'eq' && node.left.kind === 'call_fn' && node.right.kind === 'literal_symbol'),
|
|
@@ -96,19 +118,19 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
96
118
|
for (const _constraint of functionConstraints) {
|
|
97
119
|
const ctx = cloneContext(permitCtx);
|
|
98
120
|
ctx.fn = distinctText(permitCtx.fn, 'function');
|
|
99
|
-
denies.push({ dimension: 'function', ctx });
|
|
121
|
+
denies.push({ dimension: 'function', ctx, expectedReason: 'FN_MISMATCH' });
|
|
100
122
|
}
|
|
101
123
|
if (permitCtx.validUntilLedger !== undefined) {
|
|
102
124
|
const ctx = cloneContext(permitCtx);
|
|
103
125
|
ctx.atLedger = permitCtx.validUntilLedger + 1;
|
|
104
|
-
denies.push({ dimension: 'timing', ctx });
|
|
126
|
+
denies.push({ dimension: 'timing', ctx, expectedReason: 'EXPIRED' });
|
|
105
127
|
}
|
|
106
128
|
for (const comparison of facts.comparisons) {
|
|
107
129
|
if (comparison.left.kind !== 'window_spent')
|
|
108
130
|
continue;
|
|
109
131
|
const mutated = mutateBigIntRecord(permitCtx, 'windowSpentByToken', comparison.left.token, comparison, false);
|
|
110
132
|
if (mutated)
|
|
111
|
-
denies.push({ dimension: 'time_window', ctx: mutated });
|
|
133
|
+
denies.push({ dimension: 'time_window', ctx: mutated, expectedReason: 'AMOUNT_BOUND' });
|
|
112
134
|
}
|
|
113
135
|
for (const comparison of facts.comparisons) {
|
|
114
136
|
if (comparison.left.kind !== 'invocation_count_in_window')
|
|
@@ -117,7 +139,7 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
117
139
|
continue;
|
|
118
140
|
const ctx = cloneContext(permitCtx);
|
|
119
141
|
ctx.invocationCountByWindow[comparison.left.windowSecs] = violatingNumber(comparison.op, comparison.right.value);
|
|
120
|
-
denies.push({ dimension: 'invocation_count', ctx });
|
|
142
|
+
denies.push({ dimension: 'invocation_count', ctx, expectedReason: 'FREQUENCY' });
|
|
121
143
|
}
|
|
122
144
|
// Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
|
|
123
145
|
// `call_arg[0] <= limit`). A violating deny case pushes the arg past the
|
|
@@ -132,7 +154,7 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
132
154
|
continue;
|
|
133
155
|
const ctx = cloneContext(permitCtx);
|
|
134
156
|
ctx.args[comparison.left.index] = violating;
|
|
135
|
-
denies.push({ dimension: 'arg_amount_bound', ctx });
|
|
157
|
+
denies.push({ dimension: 'arg_amount_bound', ctx, expectedReason: 'ARG_MISMATCH' });
|
|
136
158
|
}
|
|
137
159
|
const argumentConstraints = [];
|
|
138
160
|
for (const comparison of facts.comparisons) {
|
|
@@ -150,6 +172,15 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
150
172
|
for (const constraint of argumentConstraints) {
|
|
151
173
|
const ctx = cloneContext(permitCtx);
|
|
152
174
|
ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' };
|
|
175
|
+
// The `arg_bound` case sets the arg to an opaque ScVal. The deny reason
|
|
176
|
+
// depends on the predicate shape:
|
|
177
|
+
// - an `eq(call_arg[i], literal)` denies with ARG_MISMATCH
|
|
178
|
+
// - an `in(call_arg[i], [literals])` denies with NOT_IN_ALLOWLIST
|
|
179
|
+
// (opaque needles fail-closed at the `in` membership gate, step 5)
|
|
180
|
+
// Both are correct in the TS evaluator; the reason is predicate-dependent
|
|
181
|
+
// so we cannot pin a single canonical reason here. The boolean decision
|
|
182
|
+
// is what we strictly need to assert; the reason is recorded (not asserted)
|
|
183
|
+
// when the harness runs this case.
|
|
153
184
|
denies.push({ dimension: 'arg_bound', ctx });
|
|
154
185
|
}
|
|
155
186
|
const scopedArgumentIndices = new Set(argumentConstraints.map(({ index }) => index));
|
|
@@ -169,6 +200,14 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
169
200
|
for (const index of scopedArgumentIndices) {
|
|
170
201
|
ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' };
|
|
171
202
|
}
|
|
203
|
+
// `scope_contract_fn_arg` changes contract, fn, AND args simultaneously.
|
|
204
|
+
// The first failing child of the AND decides the reason, and the contract
|
|
205
|
+
// check (step 3) does fire first in the evaluator. The reason is therefore
|
|
206
|
+
// CONTRACT_SCOPE for the canonical case, but a predicate that lists the
|
|
207
|
+
// call_fn leaf first (or that has a `call_arg_field` for that arg index)
|
|
208
|
+
// can flip the order. Pin the assertion here only when no `in` /
|
|
209
|
+
// call_arg_field binds the same arg index exist - the canonical blend
|
|
210
|
+
// case does have those binds, so the assertion would fire there.
|
|
172
211
|
denies.push({ dimension: 'scope_contract_fn_arg', ctx });
|
|
173
212
|
}
|
|
174
213
|
const oracleComparisons = facts.comparisons.filter((node) => node.left.kind === 'oracle_price');
|
|
@@ -178,7 +217,7 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
178
217
|
continue;
|
|
179
218
|
const ctx = cloneContext(permitCtx);
|
|
180
219
|
ctx.oraclePriceByAsset[comparison.left.asset] = { error };
|
|
181
|
-
denies.push({ dimension, ctx });
|
|
220
|
+
denies.push({ dimension, ctx, expectedReason: ORACLE_ERROR_REASON[error] });
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
223
|
for (const comparison of facts.comparisons) {
|
|
@@ -231,10 +270,10 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
231
270
|
if (sel.kind !== 'call_arg_field')
|
|
232
271
|
continue;
|
|
233
272
|
const arg = permitCtx.args[sel.index];
|
|
234
|
-
if (
|
|
273
|
+
if (arg?.type !== 'vec')
|
|
235
274
|
continue;
|
|
236
275
|
const element = arg.value[sel.element];
|
|
237
|
-
if (
|
|
276
|
+
if (element?.type !== 'map' || !Array.isArray(element.value))
|
|
238
277
|
continue;
|
|
239
278
|
const entry = element.value.find((e) => e.key === sel.field);
|
|
240
279
|
if (!entry)
|
|
@@ -262,7 +301,7 @@ function generateCases(predicate, permitCtx, dimensions) {
|
|
|
262
301
|
if (sel.kind !== 'call_arg_len')
|
|
263
302
|
continue;
|
|
264
303
|
const arg = permitCtx.args[sel.index];
|
|
265
|
-
if (
|
|
304
|
+
if (arg?.type !== 'vec')
|
|
266
305
|
continue;
|
|
267
306
|
const ctx = cloneContext(permitCtx);
|
|
268
307
|
ctx.args[sel.index] = {
|