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