@crediolabs/policy-synth 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/interpreter/adapter.js +62 -11
- package/dist/errors.d.ts +1 -1
- package/dist/install/build-add-context-rule.d.ts +48 -0
- package/dist/install/build-add-context-rule.js +304 -0
- package/dist/ir/types.d.ts +4 -0
- package/dist/predicate/decode.d.ts +11 -0
- package/dist/predicate/decode.js +234 -0
- package/dist/predicate/encode.js +79 -14
- package/dist/predicate/from-json.d.ts +4 -0
- package/dist/predicate/from-json.js +113 -0
- package/dist/predicate/index.d.ts +2 -0
- package/dist/predicate/index.js +5 -1
- package/dist/registry/identify.js +21 -2
- package/dist/registry/protocols.d.ts +50 -1
- package/dist/registry/protocols.js +88 -0
- package/dist/review-card/builder.d.ts +13 -0
- package/dist/review-card/builder.js +31 -5
- package/dist/review-card/cross-check.js +12 -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 +4 -0
- package/dist/synth/compose-from-recording.js +34 -8
- package/dist/synth/deny-cases.d.ts +4 -1
- package/dist/synth/deny-cases.js +3 -1
- package/dist/synth/evaluate.js +130 -3
- package/dist/synth/permit-context.d.ts +15 -0
- package/dist/synth/permit-context.js +116 -0
- package/dist/synth/synthesize-from-recording.js +14 -2
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +62 -11
- package/dist-cjs/errors.d.ts +1 -1
- package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
- package/dist-cjs/install/build-add-context-rule.js +308 -0
- package/dist-cjs/ir/types.d.ts +4 -0
- package/dist-cjs/predicate/decode.d.ts +11 -0
- package/dist-cjs/predicate/decode.js +239 -0
- package/dist-cjs/predicate/encode.js +79 -14
- package/dist-cjs/predicate/from-json.d.ts +4 -0
- package/dist-cjs/predicate/from-json.js +116 -0
- package/dist-cjs/predicate/index.d.ts +2 -0
- package/dist-cjs/predicate/index.js +10 -2
- package/dist-cjs/registry/identify.js +20 -1
- package/dist-cjs/registry/protocols.d.ts +50 -1
- package/dist-cjs/registry/protocols.js +89 -1
- package/dist-cjs/review-card/builder.d.ts +13 -0
- package/dist-cjs/review-card/builder.js +32 -5
- package/dist-cjs/review-card/cross-check.js +12 -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 +4 -0
- package/dist-cjs/synth/compose-from-recording.js +34 -8
- package/dist-cjs/synth/deny-cases.d.ts +4 -1
- package/dist-cjs/synth/deny-cases.js +3 -0
- package/dist-cjs/synth/evaluate.js +130 -3
- package/dist-cjs/synth/permit-context.d.ts +15 -0
- package/dist-cjs/synth/permit-context.js +119 -0
- package/dist-cjs/synth/synthesize-from-recording.js +14 -2
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +72 -11
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
- package/src/errors.ts +13 -0
- package/src/install/build-add-context-rule.ts +429 -0
- package/src/ir/types.ts +4 -0
- package/src/predicate/decode.ts +242 -0
- package/src/predicate/encode.ts +110 -13
- package/src/predicate/from-json.ts +124 -0
- package/src/predicate/index.ts +5 -1
- package/src/registry/identify.ts +22 -2
- package/src/registry/protocols.ts +90 -1
- package/src/review-card/builder.ts +31 -5
- package/src/review-card/cross-check.ts +12 -3
- package/src/review-card/index.ts +1 -0
- package/src/synth/compose-from-recording.ts +46 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +131 -3
- package/src/synth/permit-context.ts +137 -0
- package/src/synth/synthesize-from-recording.ts +13 -2
- package/src/types.ts +12 -0
|
@@ -60,6 +60,10 @@ export interface OraclePriceBound {
|
|
|
60
60
|
asset: string
|
|
61
61
|
operator: IRCompOp
|
|
62
62
|
value: string
|
|
63
|
+
/** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
|
|
64
|
+
* 9 dp, and a threshold silently assumed to share that basis is what let a
|
|
65
|
+
* raw 14-dp bound permit everything. The author states it; we convert. */
|
|
66
|
+
decimals: number
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
|
|
@@ -184,10 +188,19 @@ export function composeFromRecording(
|
|
|
184
188
|
value: limit,
|
|
185
189
|
},
|
|
186
190
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
// A rolling spend cap always goes to OZ, whose `spending_limit` is the
|
|
192
|
+
// audited implementation. The interpreter is NOT a fallback for the
|
|
193
|
+
// token != scopeContract case: on chain it sees one authorized call,
|
|
194
|
+
// not the transaction's token movements, so it has no per-call amount
|
|
195
|
+
// to accumulate and the counter would never move. OZ reports the case
|
|
196
|
+
// it cannot cover (it pins the limit to the context contract), which
|
|
197
|
+
// is the honest outcome - the old fallback produced an interpreter
|
|
198
|
+
// predicate that silently never bound.
|
|
199
|
+
ozConstraints.push(spendCond)
|
|
200
|
+
if (interpreterEnabled && token !== scopeContract) {
|
|
201
|
+
warnings.push(
|
|
202
|
+
`rolling spend cap on ${token} cannot be enforced on chain: OZ spending_limit pins the limit to the context contract, and the interpreter cannot observe token movements. Bound the per-call value with an argument cap plus an invocation-count limit instead.`
|
|
203
|
+
)
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
206
|
}
|
|
@@ -213,7 +226,10 @@ export function composeFromRecording(
|
|
|
213
226
|
op: 'compare',
|
|
214
227
|
compare: {
|
|
215
228
|
selector: { kind: 'invocation_count', windowSeconds },
|
|
216
|
-
|
|
229
|
+
// `lt`, not `lte`: the leaf reports the calls ALREADY made in the
|
|
230
|
+
// window, so `< N` is what permits N of them. With `lte` a limit
|
|
231
|
+
// of N let an N+1th call through.
|
|
232
|
+
operator: 'lt',
|
|
217
233
|
value: String(invocationLimit),
|
|
218
234
|
},
|
|
219
235
|
}
|
|
@@ -245,6 +261,7 @@ export function composeFromRecording(
|
|
|
245
261
|
selector: { kind: 'oracle_price', asset: b.asset },
|
|
246
262
|
operator: b.operator,
|
|
247
263
|
value: b.value,
|
|
264
|
+
valueDecimals: b.decimals,
|
|
248
265
|
},
|
|
249
266
|
}
|
|
250
267
|
if (interpreterEnabled) {
|
|
@@ -405,7 +422,17 @@ function appendProtocolSpecificConstraints(
|
|
|
405
422
|
})
|
|
406
423
|
for (let i = 0; i < elements.length; i++) {
|
|
407
424
|
const element = elements[i]
|
|
408
|
-
|
|
425
|
+
// An element we cannot bind leaves that request's action, asset and
|
|
426
|
+
// amount free while the length pin makes the policy LOOK total. The
|
|
427
|
+
// length pin still stays (it is a real restriction, and dropping it
|
|
428
|
+
// would only widen the policy) - what must not happen is dropping the
|
|
429
|
+
// element quietly.
|
|
430
|
+
if (element?.type !== 'map') {
|
|
431
|
+
warnings.push(
|
|
432
|
+
`Blend submit requests element ${i} is not a Request map (recorded as ${element?.type ?? 'nothing'}): its request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`
|
|
433
|
+
)
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
409
436
|
const fields = element.value
|
|
410
437
|
const fieldValue = (
|
|
411
438
|
name: string,
|
|
@@ -421,7 +448,19 @@ function appendProtocolSpecificConstraints(
|
|
|
421
448
|
const address = fieldValue('address', 'address')
|
|
422
449
|
const amount = fieldValue('amount', 'i128')
|
|
423
450
|
const requestType = fieldValue('request_type', 'u32')
|
|
424
|
-
if (address === null || amount === null || requestType === null)
|
|
451
|
+
if (address === null || amount === null || requestType === null) {
|
|
452
|
+
const missing = [
|
|
453
|
+
requestType === null ? 'request_type (u32)' : null,
|
|
454
|
+
address === null ? 'address' : null,
|
|
455
|
+
amount === null ? 'amount (i128)' : null,
|
|
456
|
+
].filter((f): f is string => f !== null)
|
|
457
|
+
// Partial pins are deliberately NOT emitted (see the note above): a
|
|
458
|
+
// half-pinned request reads as a bound one on the review card.
|
|
459
|
+
warnings.push(
|
|
460
|
+
`Blend submit requests element ${i} could not be bound: ${missing.join(', ')} missing or of an unexpected type. That request's request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
425
464
|
interpreterConstraints.push({
|
|
426
465
|
op: 'compare',
|
|
427
466
|
compare: {
|
package/src/synth/deny-cases.ts
CHANGED
|
@@ -566,7 +566,9 @@ function cloneContext(ctx: EvalContext): EvalContext {
|
|
|
566
566
|
return cloned
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
-
|
|
569
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
570
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
571
|
+
export function cloneScVal(value: ScVal, depth = 0): ScVal {
|
|
570
572
|
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
571
573
|
// payload cannot RangeError the JS stack during deny-case mutation. Over-depth
|
|
572
574
|
// throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
|
package/src/synth/evaluate.ts
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
|
|
33
33
|
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
34
34
|
|
|
35
|
+
/** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
|
|
36
|
+
* in oracle.rs. A threshold on any other basis must say so. */
|
|
37
|
+
const NORMALISED_DECIMALS = 9
|
|
38
|
+
/** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
|
|
39
|
+
const MAX_ORACLE_THRESHOLD_DECIMALS = 18
|
|
40
|
+
|
|
41
|
+
|
|
35
42
|
export interface EvalContext {
|
|
36
43
|
/** Contract the interpreter is asked to enforce against. */
|
|
37
44
|
contract: string
|
|
@@ -206,6 +213,13 @@ function evalCompare(
|
|
|
206
213
|
|
|
207
214
|
// --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
|
|
208
215
|
if (left.kind === 'call_arg') {
|
|
216
|
+
// The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
|
|
217
|
+
// Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
|
|
218
|
+
// -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
|
|
219
|
+
// generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
|
|
220
|
+
if (right.kind === 'call_arg_scaled') {
|
|
221
|
+
return evalScaledArgCompare(op, left, right, ctx)
|
|
222
|
+
}
|
|
209
223
|
const actual = ctx.args[left.index]
|
|
210
224
|
// An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
|
|
211
225
|
// compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
|
|
@@ -216,6 +230,12 @@ function evalCompare(
|
|
|
216
230
|
return evalArgEq(op, actual, right, ctx)
|
|
217
231
|
}
|
|
218
232
|
|
|
233
|
+
// --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
|
|
234
|
+
// Symmetric form so a policy that phrases the floor either way works.
|
|
235
|
+
if (left.kind === 'call_arg_scaled') {
|
|
236
|
+
return evalScaledArgCompare(op, right, left, ctx)
|
|
237
|
+
}
|
|
238
|
+
|
|
219
239
|
// --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
|
|
220
240
|
// Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
|
|
221
241
|
if (left.kind === 'call_arg_len') {
|
|
@@ -337,6 +357,100 @@ function evalArgEq(
|
|
|
337
357
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
338
358
|
}
|
|
339
359
|
|
|
360
|
+
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
|
|
361
|
+
* path: the scaled leaf is `args[index] * num / den` (truncating toward
|
|
362
|
+
* zero). On `checked_mul` / `checked_div` failure (overflow or
|
|
363
|
+
* divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
|
|
364
|
+
* failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
|
|
365
|
+
* rather than the generic `ARG_MISMATCH`.
|
|
366
|
+
*
|
|
367
|
+
* `left` is whichever side of the compare is NOT the scaled leaf. A
|
|
368
|
+
* scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
|
|
369
|
+
* leaves has no definable semantics). The other operand must be a
|
|
370
|
+
* numeric shape (`call_arg` carrying a number, or a numeric literal);
|
|
371
|
+
* anything else is `ARG_MISMATCH`. */
|
|
372
|
+
function evalScaledArgCompare(
|
|
373
|
+
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
|
|
374
|
+
left: PredicateLeaf,
|
|
375
|
+
right: PredicateLeaf,
|
|
376
|
+
ctx: EvalContext
|
|
377
|
+
): EvalResult {
|
|
378
|
+
// Identify which side is scaled, and pull the other side as a number.
|
|
379
|
+
let scaled: { index: number; num: string; den: string }
|
|
380
|
+
let other: PredicateLeaf
|
|
381
|
+
let scaledOnRight: boolean
|
|
382
|
+
if (left.kind === 'call_arg_scaled') {
|
|
383
|
+
scaled = left
|
|
384
|
+
other = right
|
|
385
|
+
scaledOnRight = false
|
|
386
|
+
} else if (right.kind === 'call_arg_scaled') {
|
|
387
|
+
scaled = right
|
|
388
|
+
other = left
|
|
389
|
+
scaledOnRight = true
|
|
390
|
+
} else {
|
|
391
|
+
// Neither side is scaled - this is a programming error in the
|
|
392
|
+
// dispatcher; the contract returns ARG_MISMATCH to fail closed.
|
|
393
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
394
|
+
}
|
|
395
|
+
// Source `args[index]` -> BigInt. An out-of-bounds index or a
|
|
396
|
+
// non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
|
|
397
|
+
// a violated floor is the wrong code when the operand itself could
|
|
398
|
+
// not be read.
|
|
399
|
+
if (scaled.index >= ctx.args.length) {
|
|
400
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
401
|
+
}
|
|
402
|
+
const input = argNumericBigInt(ctx.args[scaled.index])
|
|
403
|
+
if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
404
|
+
let num: bigint
|
|
405
|
+
let den: bigint
|
|
406
|
+
try {
|
|
407
|
+
num = BigInt(scaled.num)
|
|
408
|
+
den = BigInt(scaled.den)
|
|
409
|
+
} catch {
|
|
410
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
411
|
+
}
|
|
412
|
+
// Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
|
|
413
|
+
// check is a defensive belt-and-braces - a future validator
|
|
414
|
+
// regression cannot panic the frame on a divide-by-zero.
|
|
415
|
+
if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
416
|
+
const product = input * num
|
|
417
|
+
// BigInt overflow is silent in JS (it does not throw on multiplication).
|
|
418
|
+
// We can detect over-range by dividing and checking the result against
|
|
419
|
+
// i128 bounds, but the more practical check is whether the result
|
|
420
|
+
// divides cleanly. We rely on BigInt's arbitrary precision and then
|
|
421
|
+
// surface ARITHMETIC_OVERFLOW if the result is outside i128.
|
|
422
|
+
let scaledValue: bigint
|
|
423
|
+
try {
|
|
424
|
+
// BigInt division truncates toward zero (matches Rust `i128::checked_div`).
|
|
425
|
+
scaledValue = product / den
|
|
426
|
+
} catch {
|
|
427
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
428
|
+
}
|
|
429
|
+
// i128 range check: if the scaled value is outside i128, the contract
|
|
430
|
+
// would have wrapped on i128 arithmetic; surface the same deny.
|
|
431
|
+
const I128_MAX = (1n << 127n) - 1n
|
|
432
|
+
const I128_MIN = -(1n << 127n)
|
|
433
|
+
if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
|
|
434
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
435
|
+
}
|
|
436
|
+
// Resolving the operand when the scaled side is on the right: the
|
|
437
|
+
// operand is `left`; the scaled value is the RHS. The AST order is
|
|
438
|
+
// `left <op> scaled`, so the comparator applies to `other <op> scaled`.
|
|
439
|
+
// When the scaled side is on the left, the order is `scaled <op> other`,
|
|
440
|
+
// i.e. `scaled <op> other_val`.
|
|
441
|
+
let otherVal: bigint | null
|
|
442
|
+
if (other.kind === 'call_arg') {
|
|
443
|
+
otherVal = argNumericBigInt(ctx.args[other.index])
|
|
444
|
+
} else {
|
|
445
|
+
otherVal = literalNumericBigInt(other)
|
|
446
|
+
}
|
|
447
|
+
if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
448
|
+
const pass = scaledOnRight
|
|
449
|
+
? bigintCmp(op, otherVal.toString(), scaledValue.toString())
|
|
450
|
+
: bigintCmp(op, scaledValue.toString(), otherVal.toString())
|
|
451
|
+
return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' }
|
|
452
|
+
}
|
|
453
|
+
|
|
340
454
|
/** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
|
|
341
455
|
* reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
|
|
342
456
|
* surface) and compares it to a numeric literal via BigInt. A non-numeric arg
|
|
@@ -430,6 +544,7 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
|
|
|
430
544
|
case 'invocation_count_in_window':
|
|
431
545
|
case 'now':
|
|
432
546
|
case 'valid_until':
|
|
547
|
+
case 'call_arg_scaled':
|
|
433
548
|
return undefined // selector leaves with no ScVal projection
|
|
434
549
|
case 'literal_address':
|
|
435
550
|
return { type: 'address', value: leaf.value }
|
|
@@ -507,9 +622,22 @@ function evalOracleCompare(
|
|
|
507
622
|
if (!mapped) throw new OracleError('ORACLE_STALE')
|
|
508
623
|
throw new OracleError(mapped)
|
|
509
624
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
625
|
+
// Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
|
|
626
|
+
// decimal basis: prices are on the normalised 9-dp basis, and assuming a
|
|
627
|
+
// bare literal shares it is what let a raw 14-dp threshold permit
|
|
628
|
+
// everything. A bare literal is refused rather than assumed.
|
|
629
|
+
if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
|
|
630
|
+
if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
|
|
631
|
+
throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
|
|
632
|
+
}
|
|
633
|
+
// Scale BOTH sides up to the wider basis rather than dividing the threshold
|
|
634
|
+
// down: dividing truncates, and a truncated bound moves the permit boundary.
|
|
635
|
+
const decimals = BigInt(right.decimals)
|
|
636
|
+
const normalised = BigInt(NORMALISED_DECIMALS)
|
|
637
|
+
const common = decimals > normalised ? decimals : normalised
|
|
638
|
+
const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
|
|
639
|
+
const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
|
|
640
|
+
return bigintCmp(op, String(priceScaled), String(literalScaled))
|
|
513
641
|
? { permit: true }
|
|
514
642
|
: { permit: false, reason: 'FN_MISMATCH' }
|
|
515
643
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/synth/permit-context.ts - build the EvalContext that represents the
|
|
2
|
+
// recorded call being replayed.
|
|
3
|
+
//
|
|
4
|
+
// Extracted from the over-permissiveness harness so the same construction
|
|
5
|
+
// feeds both the in-process battery and the on-chain replay
|
|
6
|
+
// (scripts/verify-mutations-testnet.ts). Two callers building this
|
|
7
|
+
// separately would be two chances to disagree about what "the recorded
|
|
8
|
+
// call" means, which is the one thing both must share.
|
|
9
|
+
|
|
10
|
+
import type { PredicateLeaf, PredicateNode, RecordedTransaction } from '../types.ts'
|
|
11
|
+
import { cloneScVal } from './deny-cases.ts'
|
|
12
|
+
import type { EvalContext } from './evaluate.ts'
|
|
13
|
+
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
14
|
+
|
|
15
|
+
export interface PermitContextResponses {
|
|
16
|
+
windowSeconds: number
|
|
17
|
+
invocationLimit?: number
|
|
18
|
+
limitAmount?: string
|
|
19
|
+
validUntilLedger: number
|
|
20
|
+
oraclePriceBound?: Array<{ asset: string; operator: string; value: string; decimals: number }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildPermitContext(
|
|
24
|
+
tx: RecordedTransaction,
|
|
25
|
+
responses: PermitContextResponses,
|
|
26
|
+
predicate: PredicateNode
|
|
27
|
+
): EvalContext {
|
|
28
|
+
const amountByToken: Record<string, string> = {}
|
|
29
|
+
const totals = new Map<string, bigint>()
|
|
30
|
+
for (const m of tx.tokenMovements) {
|
|
31
|
+
const current = totals.get(m.token) ?? 0n
|
|
32
|
+
totals.set(m.token, current + BigInt(m.amount))
|
|
33
|
+
}
|
|
34
|
+
for (const [token, total] of totals) {
|
|
35
|
+
amountByToken[token] = total.toString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const topLevel = tx.invocations[0]
|
|
39
|
+
const scopeContract = topLevel?.contract ?? ''
|
|
40
|
+
|
|
41
|
+
const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = {}
|
|
42
|
+
visitOracleLeaves(predicate, (asset, op, bound) => {
|
|
43
|
+
let price: bigint
|
|
44
|
+
switch (op) {
|
|
45
|
+
case 'lt':
|
|
46
|
+
price = bound - 1n
|
|
47
|
+
break
|
|
48
|
+
case 'gt':
|
|
49
|
+
price = bound + 1n
|
|
50
|
+
break
|
|
51
|
+
default:
|
|
52
|
+
price = bound
|
|
53
|
+
}
|
|
54
|
+
if (price < 0n) price = 0n
|
|
55
|
+
oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const ctx: EvalContext = {
|
|
59
|
+
contract: scopeContract,
|
|
60
|
+
fn: topLevel?.fn ?? '',
|
|
61
|
+
args: (topLevel?.args ?? []).map(cloneScVal),
|
|
62
|
+
atLedger: tx.ledgerSequence,
|
|
63
|
+
nowSeconds: tx.fetchedAt,
|
|
64
|
+
amountByToken,
|
|
65
|
+
windowSpentByToken: {},
|
|
66
|
+
invocationCountByWindow: {},
|
|
67
|
+
oraclePriceByAsset,
|
|
68
|
+
}
|
|
69
|
+
if (responses.validUntilLedger !== undefined) {
|
|
70
|
+
ctx.validUntilLedger = responses.validUntilLedger
|
|
71
|
+
}
|
|
72
|
+
return ctx
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function visitOracleLeaves(
|
|
76
|
+
node: PredicateNode,
|
|
77
|
+
visit: (asset: string, op: string, bound: bigint) => void
|
|
78
|
+
): void {
|
|
79
|
+
switch (node.op) {
|
|
80
|
+
case 'and':
|
|
81
|
+
case 'or':
|
|
82
|
+
for (const child of node.children) visitOracleLeaves(child, visit)
|
|
83
|
+
return
|
|
84
|
+
case 'not':
|
|
85
|
+
visitOracleLeaves(node.child, visit)
|
|
86
|
+
return
|
|
87
|
+
case 'eq':
|
|
88
|
+
case 'lt':
|
|
89
|
+
case 'lte':
|
|
90
|
+
case 'gt':
|
|
91
|
+
case 'gte': {
|
|
92
|
+
const leftIsOracle = node.left.kind === 'oracle_price'
|
|
93
|
+
const rightIsOracle = node.right.kind === 'oracle_price'
|
|
94
|
+
let asset: string | undefined
|
|
95
|
+
let literal: bigint | undefined
|
|
96
|
+
if (leftIsOracle) {
|
|
97
|
+
asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined
|
|
98
|
+
literal = oracleThresholdNormalised(node.right)
|
|
99
|
+
} else if (rightIsOracle) {
|
|
100
|
+
asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined
|
|
101
|
+
literal = oracleThresholdNormalised(node.left)
|
|
102
|
+
}
|
|
103
|
+
if (asset !== undefined && literal !== undefined) {
|
|
104
|
+
visit(asset, node.op, literal)
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
case 'in':
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
|
|
114
|
+
* oracle.rs. */
|
|
115
|
+
const NORMALISED_DECIMALS = 9n
|
|
116
|
+
|
|
117
|
+
/** A threshold restated on the normalised basis, so a derived permit price is
|
|
118
|
+
* comparable to it. Thresholds carry their own basis, so the conversion has
|
|
119
|
+
* to happen here - reading the digits raw would build a context off by a
|
|
120
|
+
* factor of 10^(decimals-9). Returns undefined for any other leaf. */
|
|
121
|
+
function oracleThresholdNormalised(leaf: PredicateLeaf): bigint | undefined {
|
|
122
|
+
if (leaf.kind !== 'oracle_threshold') return undefined
|
|
123
|
+
let value: bigint
|
|
124
|
+
try {
|
|
125
|
+
value = BigInt(leaf.value)
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
const decimals = BigInt(leaf.decimals)
|
|
130
|
+
if (decimals <= NORMALISED_DECIMALS) {
|
|
131
|
+
return value * 10n ** (NORMALISED_DECIMALS - decimals)
|
|
132
|
+
}
|
|
133
|
+
// Floor: a finer-grained threshold has no exact 9-dp representation. The
|
|
134
|
+
// caller only needs a price that lands on the right side of the bound, and
|
|
135
|
+
// it offsets by one from here.
|
|
136
|
+
return value / 10n ** (decimals - NORMALISED_DECIMALS)
|
|
137
|
+
}
|
|
@@ -1004,11 +1004,22 @@ function visitOracleLeaves(
|
|
|
1004
1004
|
}
|
|
1005
1005
|
}
|
|
1006
1006
|
|
|
1007
|
+
/** An oracle threshold restated on the normalised 9-dp basis prices use.
|
|
1008
|
+
* Thresholds carry their own basis, so reading the digits raw would build a
|
|
1009
|
+
* permit context off by 10^(decimals-9) and the intended call would not
|
|
1010
|
+
* satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
|
|
1007
1011
|
function oracleLiteralFromLeaf(leaf: PredicateLeaf): bigint | undefined {
|
|
1008
|
-
if (leaf.kind !== '
|
|
1012
|
+
if (leaf.kind !== 'oracle_threshold') return undefined
|
|
1013
|
+
let value: bigint
|
|
1009
1014
|
try {
|
|
1010
|
-
|
|
1015
|
+
value = BigInt(leaf.value)
|
|
1011
1016
|
} catch {
|
|
1012
1017
|
return undefined
|
|
1013
1018
|
}
|
|
1019
|
+
const normalised = 9n
|
|
1020
|
+
const decimals = BigInt(leaf.decimals)
|
|
1021
|
+
if (decimals <= normalised) return value * 10n ** (normalised - decimals)
|
|
1022
|
+
// Floor: a finer-grained threshold has no exact 9-dp representation; the
|
|
1023
|
+
// caller offsets by one from here to land on the right side of the bound.
|
|
1024
|
+
return value / 10n ** (decimals - normalised)
|
|
1014
1025
|
}
|
package/src/types.ts
CHANGED
|
@@ -155,6 +155,13 @@ export type PredicateLeaf =
|
|
|
155
155
|
// recorded ScVal type. A non-vec arg, missing element, missing field, or
|
|
156
156
|
// type mismatch all DENY (fail-closed).
|
|
157
157
|
| { kind: 'call_arg_field'; index: number; element: number; field: string }
|
|
158
|
+
// `call_arg_scaled(index, num, den)` evaluates to `args[index] * num / den`
|
|
159
|
+
// (truncating toward zero). The slippage-floor leaf: a swap policy
|
|
160
|
+
// expresses `call_arg[out] >= call_arg_scaled(in, num, den)` as "the
|
|
161
|
+
// minimum output must be at least this ratio of the input". `num`/`den`
|
|
162
|
+
// are decimal strings on i128 (mirrors `literal_i128`); the contract
|
|
163
|
+
// refuses `den == 0` and `num <= 0` / `den <= 0` at install.
|
|
164
|
+
| { kind: 'call_arg_scaled'; index: number; num: string; den: string }
|
|
158
165
|
// `call_sub_invocation[i]` is NOT in v1 grammar. OZ `Policy::enforce`
|
|
159
166
|
// receives one `Context`, not a sub-invocation tree; sub-invocations live under
|
|
160
167
|
// `InvokerContractAuthEntry::Contract(SubContractInvocation)`, not in the
|
|
@@ -166,6 +173,11 @@ export type PredicateLeaf =
|
|
|
166
173
|
| { kind: 'valid_until' }
|
|
167
174
|
| { kind: 'invocation_count_in_window'; windowSecs: number }
|
|
168
175
|
| { kind: 'oracle_price'; asset: string } // Stellar Address (SAC address for SEP-41; XLM uses its network SAC)
|
|
176
|
+
// The right-hand side of an oracle comparison. It carries its own decimal
|
|
177
|
+
// basis because the contract cannot recover it: prices normalise to 9 dp, so
|
|
178
|
+
// a raw 14-dp threshold is ~10^5 too large and `price <= threshold` becomes
|
|
179
|
+
// trivially true. `value` is a decimal string on i128, like `literal_i128`.
|
|
180
|
+
| { kind: 'oracle_threshold'; value: string; decimals: number }
|
|
169
181
|
// Literal leaves: bare ScVal on the wire (no selector-tuple wrapper). Right-hand side of
|
|
170
182
|
// comparisons, elements of `in` haystacks, and operands to future arithmetic nodes.
|
|
171
183
|
| { kind: 'literal_address'; value: string }
|