@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
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { ScVal } from '../types.ts'
|
|
21
21
|
|
|
22
|
-
export type ProtocolId = 'sep41' | 'blend' | 'soroswap'
|
|
22
|
+
export type ProtocolId = 'sep41' | 'blend' | 'soroswap' | 'oz_account'
|
|
23
23
|
|
|
24
24
|
/** ScVal subset type vocabulary the ABI uses. Mirrors `ScVal['type']` minus
|
|
25
25
|
* `other` (which by definition cannot be matched against an ABI arg). */
|
|
@@ -165,10 +165,99 @@ export const SOROSWAP_ABI: ProtocolAbi = {
|
|
|
165
165
|
},
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** OpenZeppelin smart-account ABI subset.
|
|
169
|
+
*
|
|
170
|
+
* Why this lives in the recogniser: every user's smart account has a
|
|
171
|
+
* different C-address, so a per-deployment pin would never cover them.
|
|
172
|
+
* What is the same across every OpenZeppelin multisig account is the
|
|
173
|
+
* small set of public context-rule entrypoints. A recording that
|
|
174
|
+
* invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
|
|
175
|
+
* is overwhelmingly likely to be calling the user's own OpenZeppelin
|
|
176
|
+
* smart account (the canonical reason this product exists); we
|
|
177
|
+
* recognise that pattern by interface, the same way SEP-41 is
|
|
178
|
+
* recognised by interface.
|
|
179
|
+
*
|
|
180
|
+
* SECURITY PROPERTY (load-bearing):
|
|
181
|
+
* - Recognition claims the call TARGETS a particular protocol. We
|
|
182
|
+
* only claim `oz_account` when the (fn, args) shape uniquely matches
|
|
183
|
+
* one of the three entrypoints below AND the call looks like a real
|
|
184
|
+
* context-rule operation. The match is exact: off-by-one arg count,
|
|
185
|
+
* off-by-one arg type, or a method name close-but-wrong returns null
|
|
186
|
+
* (fail-closed). A contract that incidentally happens to expose a
|
|
187
|
+
* single matching fn is not sufficient - the recorder requires ALL
|
|
188
|
+
* invocations to individually match, so a hostile contract with
|
|
189
|
+
* exactly one matching fn would still contribute to the unknown
|
|
190
|
+
* bucket for any other calls.
|
|
191
|
+
* - We do NOT claim `oz_account` for unknown calls, for arg-shape
|
|
192
|
+
* mismatches, or for calls where the recorder could not pin down
|
|
193
|
+
* the (fn, args) shape. The freshness gate then runs at the same
|
|
194
|
+
* 1.0 threshold - lowering the gate for unknown protocols remains
|
|
195
|
+
* a separate, opt-in production override (see RecordInput below).
|
|
196
|
+
*
|
|
197
|
+
* ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
|
|
198
|
+
* pinned from the OpenZeppelin Reloaded `multisig_account_example`
|
|
199
|
+
* contract (commit ef82b65, fetched 2026-07-28).
|
|
200
|
+
*
|
|
201
|
+
* SCOPE NOTE: this ABI only covers the three entrypoints whose args
|
|
202
|
+
* map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
|
|
203
|
+
* ...). `add_context_rule` - the install path - takes `(Symbol,
|
|
204
|
+
* String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
|
|
205
|
+
* `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
|
|
206
|
+
* not in the AbiArgType set today (the recogniser's `argsMatchAbi`
|
|
207
|
+
* helper rejects `other` deliberately so a close-but-wrong call does
|
|
208
|
+
* not slip through). Excluding `add_context_rule` is the right
|
|
209
|
+
* trade-off for now - it never blocks OZ smart-account recognition,
|
|
210
|
+
* because the install tx always follows a recognised
|
|
211
|
+
* `batch_add_signer` (the demo's first-pass tx), so the
|
|
212
|
+
* contract still ends up in `knownContracts`. Updating the Arg type
|
|
213
|
+
* vocabulary to cover these shapes is a separate concern (and would
|
|
214
|
+
* also lift the SEP-41 / Blend recognition in the same change).
|
|
215
|
+
*/
|
|
216
|
+
export const OZ_ACCOUNT_ABI: ProtocolAbi = {
|
|
217
|
+
batch_add_signer: {
|
|
218
|
+
args: [
|
|
219
|
+
{
|
|
220
|
+
name: 'context_rule_id',
|
|
221
|
+
type: 'u32',
|
|
222
|
+
meaning: 'OZ context rule id this signer is added to',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'signers',
|
|
226
|
+
type: 'vec',
|
|
227
|
+
meaning: 'vec<SignerKey> signers to add (Delegated G-address or Account C-address)',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
batch_remove_signer: {
|
|
232
|
+
args: [
|
|
233
|
+
{
|
|
234
|
+
name: 'context_rule_id',
|
|
235
|
+
type: 'u32',
|
|
236
|
+
meaning: 'OZ context rule id to remove signers from',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'signers',
|
|
240
|
+
type: 'vec',
|
|
241
|
+
meaning: 'vec<SignerKey> signers to remove from the rule',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
remove_context_rule: {
|
|
246
|
+
args: [
|
|
247
|
+
{
|
|
248
|
+
name: 'context_rule_id',
|
|
249
|
+
type: 'u32',
|
|
250
|
+
meaning: 'OZ context rule id to delete',
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
168
256
|
export const PROTOCOL_ABIS: Record<ProtocolId, ProtocolAbi> = {
|
|
169
257
|
sep41: SEP41_ABI,
|
|
170
258
|
blend: BLEND_ABI,
|
|
171
259
|
soroswap: SOROSWAP_ABI,
|
|
260
|
+
oz_account: OZ_ACCOUNT_ABI,
|
|
172
261
|
}
|
|
173
262
|
|
|
174
263
|
export function getAbi(protocol: ProtocolId): ProtocolAbi {
|
|
@@ -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
|
|
@@ -107,6 +107,23 @@ function renderOzPrimitive(ref: PolicyRef): string | null {
|
|
|
107
107
|
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
108
108
|
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
109
109
|
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
110
|
+
/** Every constraint sentence for one predicate, in walk order.
|
|
111
|
+
*
|
|
112
|
+
* `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
|
|
113
|
+
* and needs the refs / context rule / simulation to do it. This renders a
|
|
114
|
+
* predicate on its own, which is what a caller holding a policy already
|
|
115
|
+
* INSTALLED on chain has: it can read the document out of the interpreter's
|
|
116
|
+
* storage and decode it, but there is no proposal around it. Same renderers,
|
|
117
|
+
* so an installed rule reads exactly as it did on the review card. */
|
|
118
|
+
export function describePredicate(predicate: PredicateNode): string[] {
|
|
119
|
+
const constraints: string[] = []
|
|
120
|
+
walkPredicate(predicate, (node) => {
|
|
121
|
+
const line = renderConstraint(node)
|
|
122
|
+
if (line !== null) constraints.push(line)
|
|
123
|
+
})
|
|
124
|
+
return constraints
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
function walkPredicate(node: PredicateNode, visit: (node: PredicateNode) => void): void {
|
|
111
128
|
switch (node.op) {
|
|
112
129
|
case 'and':
|
|
@@ -197,9 +214,39 @@ function renderComparison(
|
|
|
197
214
|
}
|
|
198
215
|
}
|
|
199
216
|
|
|
200
|
-
//
|
|
217
|
+
// The bound is compared against the calls ALREADY made in the window, so
|
|
218
|
+
// `< N` permits N of them and `<= N` permits one more. Report how many
|
|
219
|
+
// calls the rule allows rather than restating the comparison.
|
|
201
220
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
202
|
-
|
|
221
|
+
const allowed = node.op === 'lt' ? right.value : right.value + 1
|
|
222
|
+
return `At most ${allowed} calls per ${left.windowSecs} seconds`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
226
|
+
//
|
|
227
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
228
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
229
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
230
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
231
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
232
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
233
|
+
// silently understated the policy.
|
|
234
|
+
//
|
|
235
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
236
|
+
// reads as a path rather than as a comparison.
|
|
237
|
+
if (left.kind === 'call_arg') {
|
|
238
|
+
const head = `arg[${left.index}]`
|
|
239
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
|
|
240
|
+
if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
|
|
241
|
+
if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
|
|
242
|
+
if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
|
|
243
|
+
if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
|
|
244
|
+
if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
|
|
245
|
+
if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
|
|
246
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
247
|
+
if (right.kind === 'call_arg_scaled') {
|
|
248
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`
|
|
249
|
+
}
|
|
203
250
|
}
|
|
204
251
|
|
|
205
252
|
// amount <= v -> Amount <= v
|
|
@@ -208,8 +255,8 @@ function renderComparison(
|
|
|
208
255
|
}
|
|
209
256
|
|
|
210
257
|
// oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
211
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
212
|
-
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`
|
|
258
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
259
|
+
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value} (${right.decimals} dp)`
|
|
213
260
|
}
|
|
214
261
|
|
|
215
262
|
// Any other comparison shape is a structural fail-closed: do not surface
|
|
@@ -250,6 +297,7 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
250
297
|
case 'call_arg':
|
|
251
298
|
case 'call_arg_len':
|
|
252
299
|
case 'call_arg_field':
|
|
300
|
+
case 'call_arg_scaled':
|
|
253
301
|
case 'amount':
|
|
254
302
|
case 'window_spent':
|
|
255
303
|
case 'now':
|
|
@@ -257,6 +305,11 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
257
305
|
case 'invocation_count_in_window':
|
|
258
306
|
case 'oracle_price':
|
|
259
307
|
return `<${leaf.kind}>`
|
|
308
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
309
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
310
|
+
// card is where a human has to be able to see it.
|
|
311
|
+
case 'oracle_threshold':
|
|
312
|
+
return `${leaf.value} (${leaf.decimals} dp)`
|
|
260
313
|
}
|
|
261
314
|
}
|
|
262
315
|
|
|
@@ -118,16 +118,43 @@ function pushComparison(
|
|
|
118
118
|
return
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
// Mirrors the builder's wording byte-for-byte: how many calls the rule
|
|
122
|
+
// permits, since the bound counts the calls already made.
|
|
121
123
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
122
|
-
|
|
124
|
+
const allowed = op === 'lt' ? right.value : right.value + 1
|
|
125
|
+
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`)
|
|
123
126
|
return
|
|
124
127
|
}
|
|
128
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
129
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
130
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
131
|
+
if (left.kind === 'call_arg') {
|
|
132
|
+
const head = `arg[${left.index}]`
|
|
133
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op)
|
|
134
|
+
if (
|
|
135
|
+
right.kind === 'literal_i128' ||
|
|
136
|
+
right.kind === 'literal_u64' ||
|
|
137
|
+
right.kind === 'literal_u32' ||
|
|
138
|
+
right.kind === 'literal_address' ||
|
|
139
|
+
right.kind === 'literal_symbol' ||
|
|
140
|
+
right.kind === 'literal_bytes'
|
|
141
|
+
) {
|
|
142
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (right.kind === 'call_arg_scaled') {
|
|
146
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
}
|
|
125
150
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
126
151
|
out.push(`Amount <= ${right.value}`)
|
|
127
152
|
return
|
|
128
153
|
}
|
|
129
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
130
|
-
out.push(
|
|
154
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
155
|
+
out.push(
|
|
156
|
+
`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`
|
|
157
|
+
)
|
|
131
158
|
return
|
|
132
159
|
}
|
|
133
160
|
}
|
|
@@ -159,6 +186,7 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
159
186
|
case 'call_arg':
|
|
160
187
|
case 'call_arg_len':
|
|
161
188
|
case 'call_arg_field':
|
|
189
|
+
case 'call_arg_scaled':
|
|
162
190
|
case 'amount':
|
|
163
191
|
case 'window_spent':
|
|
164
192
|
case 'now':
|
|
@@ -166,6 +194,11 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
166
194
|
case 'invocation_count_in_window':
|
|
167
195
|
case 'oracle_price':
|
|
168
196
|
return `<${leaf.kind}>`
|
|
197
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
198
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
199
|
+
// card is where a human has to be able to see it.
|
|
200
|
+
case 'oracle_threshold':
|
|
201
|
+
return `${leaf.value} (${leaf.decimals} dp)`
|
|
169
202
|
}
|
|
170
203
|
}
|
|
171
204
|
|
package/src/review-card/index.ts
CHANGED
|
@@ -60,6 +60,10 @@ export interface OraclePriceBound {
|
|
|
60
60
|
asset: string
|
|
61
61
|
operator: IRCompOp
|
|
62
62
|
value: string
|
|
63
|
+
/** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
|
|
64
|
+
* 9 dp, and a threshold silently assumed to share that basis is what let a
|
|
65
|
+
* raw 14-dp bound permit everything. The author states it; we convert. */
|
|
66
|
+
decimals: number
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
|
|
@@ -80,6 +84,17 @@ export interface ComposeUserResponses {
|
|
|
80
84
|
* `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
|
|
81
85
|
* entries on the same asset emit multiple leaves. */
|
|
82
86
|
oraclePriceBound?: OraclePriceBound[]
|
|
87
|
+
/** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
|
|
88
|
+
* `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
|
|
89
|
+
* slippage floor bounding the output arg against the input arg of the same
|
|
90
|
+
* call.
|
|
91
|
+
*
|
|
92
|
+
* REQUIRED to be supplied by the caller: it is never derived from the
|
|
93
|
+
* recording. The recorded in/out pair is a price at one moment, and
|
|
94
|
+
* freezing it as policy would deny ordinary trades as soon as the rate
|
|
95
|
+
* moves. Absent, no floor is emitted and the existing unbounded-output
|
|
96
|
+
* warning stands. */
|
|
97
|
+
swapMinOutRatio?: { num: string; den: string }
|
|
83
98
|
/** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
|
|
84
99
|
* swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
|
|
85
100
|
* pin. Absent -> the recipient is pinned to the recorded value (mirroring
|
|
@@ -184,10 +199,19 @@ export function composeFromRecording(
|
|
|
184
199
|
value: limit,
|
|
185
200
|
},
|
|
186
201
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
// A rolling spend cap always goes to OZ, whose `spending_limit` is the
|
|
203
|
+
// audited implementation. The interpreter is NOT a fallback for the
|
|
204
|
+
// token != scopeContract case: on chain it sees one authorized call,
|
|
205
|
+
// not the transaction's token movements, so it has no per-call amount
|
|
206
|
+
// to accumulate and the counter would never move. OZ reports the case
|
|
207
|
+
// it cannot cover (it pins the limit to the context contract), which
|
|
208
|
+
// is the honest outcome - the old fallback produced an interpreter
|
|
209
|
+
// predicate that silently never bound.
|
|
210
|
+
ozConstraints.push(spendCond)
|
|
211
|
+
if (interpreterEnabled && token !== scopeContract) {
|
|
212
|
+
warnings.push(
|
|
213
|
+
`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.`
|
|
214
|
+
)
|
|
191
215
|
}
|
|
192
216
|
}
|
|
193
217
|
}
|
|
@@ -213,7 +237,10 @@ export function composeFromRecording(
|
|
|
213
237
|
op: 'compare',
|
|
214
238
|
compare: {
|
|
215
239
|
selector: { kind: 'invocation_count', windowSeconds },
|
|
216
|
-
|
|
240
|
+
// `lt`, not `lte`: the leaf reports the calls ALREADY made in the
|
|
241
|
+
// window, so `< N` is what permits N of them. With `lte` a limit
|
|
242
|
+
// of N let an N+1th call through.
|
|
243
|
+
operator: 'lt',
|
|
217
244
|
value: String(invocationLimit),
|
|
218
245
|
},
|
|
219
246
|
}
|
|
@@ -245,6 +272,7 @@ export function composeFromRecording(
|
|
|
245
272
|
selector: { kind: 'oracle_price', asset: b.asset },
|
|
246
273
|
operator: b.operator,
|
|
247
274
|
value: b.value,
|
|
275
|
+
valueDecimals: b.decimals,
|
|
248
276
|
},
|
|
249
277
|
}
|
|
250
278
|
if (interpreterEnabled) {
|
|
@@ -277,6 +305,7 @@ export function composeFromRecording(
|
|
|
277
305
|
protocol,
|
|
278
306
|
opts.userResponses?.swapRecipientAllowlist,
|
|
279
307
|
swapInputAmountCap,
|
|
308
|
+
opts.userResponses?.swapMinOutRatio,
|
|
280
309
|
interpreterEnabled
|
|
281
310
|
)
|
|
282
311
|
}
|
|
@@ -333,6 +362,7 @@ function appendProtocolSpecificConstraints(
|
|
|
333
362
|
protocol: IdentifiedProtocol,
|
|
334
363
|
swapRecipientAllowlist: string[] | undefined,
|
|
335
364
|
swapInputAmountCap: string | undefined,
|
|
365
|
+
swapMinOutRatio: { num: string; den: string } | undefined,
|
|
336
366
|
interpreterEnabled: boolean
|
|
337
367
|
): void {
|
|
338
368
|
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
@@ -405,7 +435,17 @@ function appendProtocolSpecificConstraints(
|
|
|
405
435
|
})
|
|
406
436
|
for (let i = 0; i < elements.length; i++) {
|
|
407
437
|
const element = elements[i]
|
|
408
|
-
|
|
438
|
+
// An element we cannot bind leaves that request's action, asset and
|
|
439
|
+
// amount free while the length pin makes the policy LOOK total. The
|
|
440
|
+
// length pin still stays (it is a real restriction, and dropping it
|
|
441
|
+
// would only widen the policy) - what must not happen is dropping the
|
|
442
|
+
// element quietly.
|
|
443
|
+
if (element?.type !== 'map') {
|
|
444
|
+
warnings.push(
|
|
445
|
+
`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`
|
|
446
|
+
)
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
409
449
|
const fields = element.value
|
|
410
450
|
const fieldValue = (
|
|
411
451
|
name: string,
|
|
@@ -421,7 +461,19 @@ function appendProtocolSpecificConstraints(
|
|
|
421
461
|
const address = fieldValue('address', 'address')
|
|
422
462
|
const amount = fieldValue('amount', 'i128')
|
|
423
463
|
const requestType = fieldValue('request_type', 'u32')
|
|
424
|
-
if (address === null || amount === null || requestType === null)
|
|
464
|
+
if (address === null || amount === null || requestType === null) {
|
|
465
|
+
const missing = [
|
|
466
|
+
requestType === null ? 'request_type (u32)' : null,
|
|
467
|
+
address === null ? 'address' : null,
|
|
468
|
+
amount === null ? 'amount (i128)' : null,
|
|
469
|
+
].filter((f): f is string => f !== null)
|
|
470
|
+
// Partial pins are deliberately NOT emitted (see the note above): a
|
|
471
|
+
// half-pinned request reads as a bound one on the review card.
|
|
472
|
+
warnings.push(
|
|
473
|
+
`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`
|
|
474
|
+
)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
425
477
|
interpreterConstraints.push({
|
|
426
478
|
op: 'compare',
|
|
427
479
|
compare: {
|
|
@@ -528,6 +580,32 @@ function appendProtocolSpecificConstraints(
|
|
|
528
580
|
}
|
|
529
581
|
}
|
|
530
582
|
|
|
583
|
+
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
584
|
+
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
585
|
+
// which is the case the unbounded-swap warning above describes.
|
|
586
|
+
const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn)
|
|
587
|
+
const minOutRatio = swapMinOutRatio
|
|
588
|
+
if (
|
|
589
|
+
minOutRatio !== undefined &&
|
|
590
|
+
inputArgIndex !== undefined &&
|
|
591
|
+
outMinArgIndex !== undefined &&
|
|
592
|
+
inputAmountArg &&
|
|
593
|
+
inputAmountArg.type === 'i128'
|
|
594
|
+
) {
|
|
595
|
+
const floor: IRCondition = {
|
|
596
|
+
op: 'slippage_floor',
|
|
597
|
+
outArgIndex: outMinArgIndex,
|
|
598
|
+
inArgIndex: inputArgIndex,
|
|
599
|
+
num: minOutRatio.num,
|
|
600
|
+
den: minOutRatio.den,
|
|
601
|
+
}
|
|
602
|
+
if (interpreterEnabled) {
|
|
603
|
+
interpreterConstraints.push(floor)
|
|
604
|
+
} else {
|
|
605
|
+
ozConstraints.push(floor)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
531
609
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
532
610
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
533
611
|
// arg. When absent, PIN the recipient to the recorded value - mirroring the
|
|
@@ -571,6 +649,22 @@ function appendProtocolSpecificConstraints(
|
|
|
571
649
|
* the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
|
|
572
650
|
* input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
|
|
573
651
|
* function has no positional input-amount argument -> undefined (no cap bound). */
|
|
652
|
+
/** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
|
|
653
|
+
*
|
|
654
|
+
* Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
|
|
655
|
+
* fixes the output and varies the input, so its output needs no floor (its
|
|
656
|
+
* arg[1] is `amount_in_max`, already bounded by the input cap). Returning
|
|
657
|
+
* undefined there keeps a floor from being pinned to the wrong argument. */
|
|
658
|
+
function soroswapMinOutArgIndex(fn: string): number | undefined {
|
|
659
|
+
switch (fn) {
|
|
660
|
+
case 'swap_exact_tokens_for_tokens':
|
|
661
|
+
case 'swap_exact_in_for_tokens':
|
|
662
|
+
return 1
|
|
663
|
+
default:
|
|
664
|
+
return undefined
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
574
668
|
function soroswapInputAmountArgIndex(fn: string): number | undefined {
|
|
575
669
|
switch (fn) {
|
|
576
670
|
case 'swap_exact_tokens_for_tokens':
|
package/src/synth/deny-cases.ts
CHANGED
|
@@ -566,7 +566,9 @@ function cloneContext(ctx: EvalContext): EvalContext {
|
|
|
566
566
|
return cloned
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
-
|
|
569
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
570
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
571
|
+
export function cloneScVal(value: ScVal, depth = 0): ScVal {
|
|
570
572
|
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
571
573
|
// payload cannot RangeError the JS stack during deny-case mutation. Over-depth
|
|
572
574
|
// throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
|
package/src/synth/evaluate.ts
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
|
|
33
33
|
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
34
34
|
|
|
35
|
+
/** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
|
|
36
|
+
* in oracle.rs. A threshold on any other basis must say so. */
|
|
37
|
+
const NORMALISED_DECIMALS = 9
|
|
38
|
+
/** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
|
|
39
|
+
const MAX_ORACLE_THRESHOLD_DECIMALS = 18
|
|
40
|
+
|
|
35
41
|
export interface EvalContext {
|
|
36
42
|
/** Contract the interpreter is asked to enforce against. */
|
|
37
43
|
contract: string
|
|
@@ -206,6 +212,13 @@ function evalCompare(
|
|
|
206
212
|
|
|
207
213
|
// --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
|
|
208
214
|
if (left.kind === 'call_arg') {
|
|
215
|
+
// The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
|
|
216
|
+
// Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
|
|
217
|
+
// -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
|
|
218
|
+
// generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
|
|
219
|
+
if (right.kind === 'call_arg_scaled') {
|
|
220
|
+
return evalScaledArgCompare(op, left, right, ctx)
|
|
221
|
+
}
|
|
209
222
|
const actual = ctx.args[left.index]
|
|
210
223
|
// An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
|
|
211
224
|
// compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
|
|
@@ -216,6 +229,12 @@ function evalCompare(
|
|
|
216
229
|
return evalArgEq(op, actual, right, ctx)
|
|
217
230
|
}
|
|
218
231
|
|
|
232
|
+
// --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
|
|
233
|
+
// Symmetric form so a policy that phrases the floor either way works.
|
|
234
|
+
if (left.kind === 'call_arg_scaled') {
|
|
235
|
+
return evalScaledArgCompare(op, right, left, ctx)
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
// --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
|
|
220
239
|
// Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
|
|
221
240
|
if (left.kind === 'call_arg_len') {
|
|
@@ -337,6 +356,100 @@ function evalArgEq(
|
|
|
337
356
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
338
357
|
}
|
|
339
358
|
|
|
359
|
+
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
|
|
360
|
+
* path: the scaled leaf is `args[index] * num / den` (truncating toward
|
|
361
|
+
* zero). On `checked_mul` / `checked_div` failure (overflow or
|
|
362
|
+
* divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
|
|
363
|
+
* failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
|
|
364
|
+
* rather than the generic `ARG_MISMATCH`.
|
|
365
|
+
*
|
|
366
|
+
* `left` is whichever side of the compare is NOT the scaled leaf. A
|
|
367
|
+
* scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
|
|
368
|
+
* leaves has no definable semantics). The other operand must be a
|
|
369
|
+
* numeric shape (`call_arg` carrying a number, or a numeric literal);
|
|
370
|
+
* anything else is `ARG_MISMATCH`. */
|
|
371
|
+
function evalScaledArgCompare(
|
|
372
|
+
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
|
|
373
|
+
left: PredicateLeaf,
|
|
374
|
+
right: PredicateLeaf,
|
|
375
|
+
ctx: EvalContext
|
|
376
|
+
): EvalResult {
|
|
377
|
+
// Identify which side is scaled, and pull the other side as a number.
|
|
378
|
+
let scaled: { index: number; num: string; den: string }
|
|
379
|
+
let other: PredicateLeaf
|
|
380
|
+
let scaledOnRight: boolean
|
|
381
|
+
if (left.kind === 'call_arg_scaled') {
|
|
382
|
+
scaled = left
|
|
383
|
+
other = right
|
|
384
|
+
scaledOnRight = false
|
|
385
|
+
} else if (right.kind === 'call_arg_scaled') {
|
|
386
|
+
scaled = right
|
|
387
|
+
other = left
|
|
388
|
+
scaledOnRight = true
|
|
389
|
+
} else {
|
|
390
|
+
// Neither side is scaled - this is a programming error in the
|
|
391
|
+
// dispatcher; the contract returns ARG_MISMATCH to fail closed.
|
|
392
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
393
|
+
}
|
|
394
|
+
// Source `args[index]` -> BigInt. An out-of-bounds index or a
|
|
395
|
+
// non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
|
|
396
|
+
// a violated floor is the wrong code when the operand itself could
|
|
397
|
+
// not be read.
|
|
398
|
+
if (scaled.index >= ctx.args.length) {
|
|
399
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
400
|
+
}
|
|
401
|
+
const input = argNumericBigInt(ctx.args[scaled.index])
|
|
402
|
+
if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
403
|
+
let num: bigint
|
|
404
|
+
let den: bigint
|
|
405
|
+
try {
|
|
406
|
+
num = BigInt(scaled.num)
|
|
407
|
+
den = BigInt(scaled.den)
|
|
408
|
+
} catch {
|
|
409
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
410
|
+
}
|
|
411
|
+
// Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
|
|
412
|
+
// check is a defensive belt-and-braces - a future validator
|
|
413
|
+
// regression cannot panic the frame on a divide-by-zero.
|
|
414
|
+
if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
415
|
+
const product = input * num
|
|
416
|
+
// BigInt overflow is silent in JS (it does not throw on multiplication).
|
|
417
|
+
// We can detect over-range by dividing and checking the result against
|
|
418
|
+
// i128 bounds, but the more practical check is whether the result
|
|
419
|
+
// divides cleanly. We rely on BigInt's arbitrary precision and then
|
|
420
|
+
// surface ARITHMETIC_OVERFLOW if the result is outside i128.
|
|
421
|
+
let scaledValue: bigint
|
|
422
|
+
try {
|
|
423
|
+
// BigInt division truncates toward zero (matches Rust `i128::checked_div`).
|
|
424
|
+
scaledValue = product / den
|
|
425
|
+
} catch {
|
|
426
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
427
|
+
}
|
|
428
|
+
// i128 range check: if the scaled value is outside i128, the contract
|
|
429
|
+
// would have wrapped on i128 arithmetic; surface the same deny.
|
|
430
|
+
const I128_MAX = (1n << 127n) - 1n
|
|
431
|
+
const I128_MIN = -(1n << 127n)
|
|
432
|
+
if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
|
|
433
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
434
|
+
}
|
|
435
|
+
// Resolving the operand when the scaled side is on the right: the
|
|
436
|
+
// operand is `left`; the scaled value is the RHS. The AST order is
|
|
437
|
+
// `left <op> scaled`, so the comparator applies to `other <op> scaled`.
|
|
438
|
+
// When the scaled side is on the left, the order is `scaled <op> other`,
|
|
439
|
+
// i.e. `scaled <op> other_val`.
|
|
440
|
+
let otherVal: bigint | null
|
|
441
|
+
if (other.kind === 'call_arg') {
|
|
442
|
+
otherVal = argNumericBigInt(ctx.args[other.index])
|
|
443
|
+
} else {
|
|
444
|
+
otherVal = literalNumericBigInt(other)
|
|
445
|
+
}
|
|
446
|
+
if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
447
|
+
const pass = scaledOnRight
|
|
448
|
+
? bigintCmp(op, otherVal.toString(), scaledValue.toString())
|
|
449
|
+
: bigintCmp(op, scaledValue.toString(), otherVal.toString())
|
|
450
|
+
return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' }
|
|
451
|
+
}
|
|
452
|
+
|
|
340
453
|
/** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
|
|
341
454
|
* reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
|
|
342
455
|
* surface) and compares it to a numeric literal via BigInt. A non-numeric arg
|
|
@@ -430,6 +543,7 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
|
|
|
430
543
|
case 'invocation_count_in_window':
|
|
431
544
|
case 'now':
|
|
432
545
|
case 'valid_until':
|
|
546
|
+
case 'call_arg_scaled':
|
|
433
547
|
return undefined // selector leaves with no ScVal projection
|
|
434
548
|
case 'literal_address':
|
|
435
549
|
return { type: 'address', value: leaf.value }
|
|
@@ -507,9 +621,22 @@ function evalOracleCompare(
|
|
|
507
621
|
if (!mapped) throw new OracleError('ORACLE_STALE')
|
|
508
622
|
throw new OracleError(mapped)
|
|
509
623
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
624
|
+
// Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
|
|
625
|
+
// decimal basis: prices are on the normalised 9-dp basis, and assuming a
|
|
626
|
+
// bare literal shares it is what let a raw 14-dp threshold permit
|
|
627
|
+
// everything. A bare literal is refused rather than assumed.
|
|
628
|
+
if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
|
|
629
|
+
if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
|
|
630
|
+
throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
|
|
631
|
+
}
|
|
632
|
+
// Scale BOTH sides up to the wider basis rather than dividing the threshold
|
|
633
|
+
// down: dividing truncates, and a truncated bound moves the permit boundary.
|
|
634
|
+
const decimals = BigInt(right.decimals)
|
|
635
|
+
const normalised = BigInt(NORMALISED_DECIMALS)
|
|
636
|
+
const common = decimals > normalised ? decimals : normalised
|
|
637
|
+
const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
|
|
638
|
+
const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
|
|
639
|
+
return bigintCmp(op, String(priceScaled), String(literalScaled))
|
|
513
640
|
? { permit: true }
|
|
514
641
|
: { permit: false, reason: 'FN_MISMATCH' }
|
|
515
642
|
}
|