@crediolabs/policy-synth 0.4.0 → 0.5.1
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.d.ts +2 -2
- package/dist/adapters/interpreter/adapter.js +11 -3
- package/dist/errors.d.ts +6 -1
- package/dist/install/authority-overlap.js +12 -0
- package/dist/install/build-add-context-rule.d.ts +1 -1
- package/dist/install/read-account-rules.d.ts +79 -0
- package/dist/install/read-account-rules.js +241 -0
- package/dist/predicate/decode.js +22 -1
- package/dist/predicate/encode.js +52 -5
- package/dist/predicate/from-json.js +21 -1
- package/dist/review-card/builder.js +40 -0
- package/dist/review-card/cross-check.js +34 -0
- package/dist/review-card/render-leaf.d.ts +1 -1
- package/dist/review-card/render-leaf.js +7 -0
- package/dist/run/index.d.ts +2 -2
- package/dist/run/index.js +52 -9
- package/dist/run/schemas.d.ts +64 -14
- package/dist/run/schemas.js +70 -6
- package/dist/simulate/deny-cases.js +11 -0
- package/dist/simulate/evaluate.js +86 -5
- package/dist/synth/declare.d.ts +13 -0
- package/dist/synth/declare.js +34 -5
- package/dist/synth/synthesize-from-recording.js +1 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.js +1 -1
- package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
- package/dist-cjs/adapters/interpreter/adapter.js +11 -3
- package/dist-cjs/errors.d.ts +6 -1
- package/dist-cjs/install/authority-overlap.js +12 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
- package/dist-cjs/install/read-account-rules.d.ts +79 -0
- package/dist-cjs/install/read-account-rules.js +252 -0
- package/dist-cjs/predicate/decode.js +22 -1
- package/dist-cjs/predicate/encode.js +52 -5
- package/dist-cjs/predicate/from-json.js +21 -1
- package/dist-cjs/review-card/builder.js +40 -0
- package/dist-cjs/review-card/cross-check.js +34 -0
- package/dist-cjs/review-card/render-leaf.d.ts +1 -1
- package/dist-cjs/review-card/render-leaf.js +7 -0
- package/dist-cjs/run/index.d.ts +2 -2
- package/dist-cjs/run/index.js +54 -9
- package/dist-cjs/run/schemas.d.ts +64 -14
- package/dist-cjs/run/schemas.js +71 -7
- package/dist-cjs/simulate/deny-cases.js +11 -0
- package/dist-cjs/simulate/evaluate.js +86 -5
- package/dist-cjs/synth/declare.d.ts +13 -0
- package/dist-cjs/synth/declare.js +34 -5
- package/dist-cjs/synth/synthesize-from-recording.js +1 -1
- package/dist-cjs/types.d.ts +21 -1
- package/dist-cjs/types.js +1 -1
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +13 -5
- package/src/errors.ts +5 -0
- package/src/install/authority-overlap.ts +11 -0
- package/src/install/read-account-rules.ts +313 -0
- package/src/predicate/decode.ts +22 -1
- package/src/predicate/encode.ts +55 -5
- package/src/predicate/from-json.ts +21 -1
- package/src/review-card/builder.ts +45 -2
- package/src/review-card/cross-check.ts +35 -1
- package/src/review-card/render-leaf.ts +8 -1
- package/src/run/index.ts +57 -8
- package/src/run/schemas.ts +83 -6
- package/src/simulate/deny-cases.ts +12 -1
- package/src/simulate/evaluate.ts +101 -9
- package/src/synth/declare.ts +54 -5
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +16 -1
|
@@ -26,9 +26,13 @@ export function jsonToAst(value: unknown): PredicateNode {
|
|
|
26
26
|
}
|
|
27
27
|
switch (v.op) {
|
|
28
28
|
case 'and':
|
|
29
|
-
|
|
29
|
+
case 'or':
|
|
30
|
+
return { op: v.op, children: arrayOf(v.children, jsonToAst) }
|
|
30
31
|
case 'eq':
|
|
32
|
+
case 'lt':
|
|
31
33
|
case 'lte':
|
|
34
|
+
case 'gt':
|
|
35
|
+
case 'gte':
|
|
32
36
|
return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) }
|
|
33
37
|
case 'in':
|
|
34
38
|
return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) }
|
|
@@ -53,6 +57,22 @@ function jsonToLeaf(value: unknown): PredicateLeaf {
|
|
|
53
57
|
return { kind: 'call_arg', index: numberField(v, 'index') }
|
|
54
58
|
case 'call_arg_len':
|
|
55
59
|
return { kind: 'call_arg_len', index: numberField(v, 'index') }
|
|
60
|
+
case 'call_arg_field':
|
|
61
|
+
return {
|
|
62
|
+
kind: 'call_arg_field',
|
|
63
|
+
index: numberField(v, 'index'),
|
|
64
|
+
element: numberField(v, 'element'),
|
|
65
|
+
field: stringField(v, 'field'),
|
|
66
|
+
}
|
|
67
|
+
case 'call_arg_scaled':
|
|
68
|
+
// num/den stay decimal STRINGS: an i128 ratio does not survive a JS
|
|
69
|
+
// number, and silently rounding one would change the floor.
|
|
70
|
+
return {
|
|
71
|
+
kind: 'call_arg_scaled',
|
|
72
|
+
index: numberField(v, 'index'),
|
|
73
|
+
num: stringField(v, 'num'),
|
|
74
|
+
den: stringField(v, 'den'),
|
|
75
|
+
}
|
|
56
76
|
case 'literal_address':
|
|
57
77
|
return { kind: 'literal_address', value: stringField(v, 'value') }
|
|
58
78
|
case 'literal_i128':
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// hash. There is no clock; the hash never includes a timestamp.
|
|
21
21
|
|
|
22
22
|
import { createHash } from 'node:crypto'
|
|
23
|
-
import type { ContextRuleDraft, PredicateNode } from '../types.ts'
|
|
23
|
+
import type { ContextRuleDraft, PredicateLeaf, PredicateNode } from '../types.ts'
|
|
24
24
|
import { comparisonOpText, renderHaystackElement, renderVecElement } from './render-leaf.ts'
|
|
25
25
|
|
|
26
26
|
export interface ReviewCardSummary {
|
|
@@ -105,16 +105,33 @@ function walkPredicate(node: PredicateNode, visit: (node: PredicateNode) => void
|
|
|
105
105
|
case 'and':
|
|
106
106
|
for (const child of node.children) walkPredicate(child, visit)
|
|
107
107
|
return
|
|
108
|
+
// NOT descended into. Every line the card emits reads as a requirement,
|
|
109
|
+
// and `and` is what makes that true. Listing an `or`'s branches as
|
|
110
|
+
// separate lines would state the opposite of what the policy means, so
|
|
111
|
+
// the whole disjunction is rendered as ONE line instead.
|
|
112
|
+
case 'or':
|
|
113
|
+
visit(node)
|
|
114
|
+
return
|
|
108
115
|
case 'in':
|
|
109
116
|
visit(node)
|
|
110
117
|
return
|
|
111
118
|
case 'eq':
|
|
119
|
+
case 'lt':
|
|
112
120
|
case 'lte':
|
|
121
|
+
case 'gt':
|
|
122
|
+
case 'gte':
|
|
113
123
|
visit(node)
|
|
114
124
|
return
|
|
115
125
|
}
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
/** Argument index of a `call_arg` leaf, for the scaled-comparison line. Any
|
|
129
|
+
* other leaf renders as its kind so the line stays readable rather than
|
|
130
|
+
* claiming an index that does not exist. */
|
|
131
|
+
function leftArgLabel(leaf: PredicateLeaf): string {
|
|
132
|
+
return leaf.kind === 'call_arg' ? String(leaf.index) : `<${leaf.kind}>`
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
/** Render ONE constraint sentence for ONE interpreter predicate node. The
|
|
119
136
|
* shape of the output is pinned by Task 7b so the test suite can assert
|
|
120
137
|
* byte-for-byte equality. Returns `null` when the node is a structural
|
|
@@ -123,15 +140,41 @@ function renderConstraint(node: PredicateNode): string | null {
|
|
|
123
140
|
switch (node.op) {
|
|
124
141
|
case 'and':
|
|
125
142
|
return null
|
|
143
|
+
case 'or': {
|
|
144
|
+
// One line for the whole disjunction. If any branch is a shape the
|
|
145
|
+
// card cannot render, the entire line is withheld rather than shown
|
|
146
|
+
// with a branch missing - a disjunction with a branch dropped reads
|
|
147
|
+
// as STRICTER than it is, which is the dangerous direction.
|
|
148
|
+
const parts = node.children.map(renderConstraint)
|
|
149
|
+
if (parts.some((p) => p === null)) return null
|
|
150
|
+
return `Either: ${parts.join(' OR ')}`
|
|
151
|
+
}
|
|
126
152
|
case 'eq':
|
|
153
|
+
case 'lt':
|
|
127
154
|
case 'lte':
|
|
155
|
+
case 'gt':
|
|
156
|
+
case 'gte':
|
|
128
157
|
return renderComparison(node)
|
|
129
158
|
case 'in':
|
|
130
159
|
return renderMembership(node)
|
|
131
160
|
}
|
|
132
161
|
}
|
|
133
162
|
|
|
134
|
-
function renderComparison(
|
|
163
|
+
function renderComparison(
|
|
164
|
+
node: Extract<PredicateNode, { op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte' }>
|
|
165
|
+
): string | null {
|
|
166
|
+
// The slippage floor: OP(call_arg[out], call_arg_scaled(in, num, den)).
|
|
167
|
+
// Rendered explicitly because the human approving the signature has to see
|
|
168
|
+
// that the bound is a RATIO of another argument, not a fixed amount.
|
|
169
|
+
if (node.right.kind === 'call_arg_scaled') {
|
|
170
|
+
const s = node.right
|
|
171
|
+
return `arg[${leftArgLabel(node.left)}] ${comparisonOpText(node.op)} arg[${s.index}] * ${s.num}/${s.den}`
|
|
172
|
+
}
|
|
173
|
+
if (node.left.kind === 'call_arg_scaled') {
|
|
174
|
+
const s = node.left
|
|
175
|
+
return `arg[${s.index}] * ${s.num}/${s.den} ${comparisonOpText(node.op)} arg[${leftArgLabel(node.right)}]`
|
|
176
|
+
}
|
|
177
|
+
|
|
135
178
|
const left = node.left
|
|
136
179
|
const right = node.right
|
|
137
180
|
|
|
@@ -46,8 +46,28 @@ function collect(node: PredicateNode, out: string[]): void {
|
|
|
46
46
|
case 'and':
|
|
47
47
|
for (const child of node.children) collect(child, out)
|
|
48
48
|
return
|
|
49
|
+
// ONE line for the whole disjunction, mirroring the builder. Emitting a
|
|
50
|
+
// line per branch would claim every branch is required, which is the
|
|
51
|
+
// opposite of what `or` means. If any branch renders to nothing the whole
|
|
52
|
+
// line is withheld, again mirroring the builder - a disjunction missing a
|
|
53
|
+
// branch reads STRICTER than it is.
|
|
54
|
+
case 'or': {
|
|
55
|
+
const parts: string[] = []
|
|
56
|
+
for (const child of node.children) {
|
|
57
|
+
const childOut: string[] = []
|
|
58
|
+
collect(child, childOut)
|
|
59
|
+
if (childOut.length !== 1) return
|
|
60
|
+
parts.push(childOut[0] as string)
|
|
61
|
+
}
|
|
62
|
+
if (parts.length === 0) return
|
|
63
|
+
out.push(`Either: ${parts.join(' OR ')}`)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
49
66
|
case 'eq':
|
|
67
|
+
case 'lt':
|
|
50
68
|
case 'lte':
|
|
69
|
+
case 'gt':
|
|
70
|
+
case 'gte':
|
|
51
71
|
pushComparison(node.left, node.right, node.op, out)
|
|
52
72
|
return
|
|
53
73
|
case 'in':
|
|
@@ -59,9 +79,23 @@ function collect(node: PredicateNode, out: string[]): void {
|
|
|
59
79
|
function pushComparison(
|
|
60
80
|
left: PredicateLeaf,
|
|
61
81
|
right: PredicateLeaf,
|
|
62
|
-
op: 'eq' | 'lte',
|
|
82
|
+
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
|
|
63
83
|
out: string[]
|
|
64
84
|
): void {
|
|
85
|
+
// Slippage floor, mirroring the builder. The human has to see that the
|
|
86
|
+
// bound is a RATIO of another argument, not a fixed amount.
|
|
87
|
+
if (right.kind === 'call_arg_scaled') {
|
|
88
|
+
const label = left.kind === 'call_arg' ? String(left.index) : `<${left.kind}>`
|
|
89
|
+
out.push(
|
|
90
|
+
`arg[${label}] ${comparisonOpText(op)} arg[${right.index}] * ${right.num}/${right.den}`
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (left.kind === 'call_arg_scaled') {
|
|
95
|
+
const label = right.kind === 'call_arg' ? String(right.index) : `<${right.kind}>`
|
|
96
|
+
out.push(`arg[${left.index}] * ${left.num}/${left.den} ${comparisonOpText(op)} arg[${label}]`)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
65
99
|
if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
|
|
66
100
|
out.push(`Contract must be ${right.value}`)
|
|
67
101
|
return
|
|
@@ -23,6 +23,7 @@ export function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
23
23
|
case 'call_arg':
|
|
24
24
|
case 'call_arg_len':
|
|
25
25
|
case 'call_arg_field':
|
|
26
|
+
case 'call_arg_scaled':
|
|
26
27
|
return `<${leaf.kind}>`
|
|
27
28
|
}
|
|
28
29
|
}
|
|
@@ -38,10 +39,16 @@ export function renderHaystackElement(leaf: PredicateLeaf): string {
|
|
|
38
39
|
return `<${leaf.kind}>`
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export function comparisonOpText(op: 'eq' | 'lte'): string {
|
|
42
|
+
export function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string {
|
|
42
43
|
switch (op) {
|
|
44
|
+
case 'lt':
|
|
45
|
+
return '<'
|
|
43
46
|
case 'lte':
|
|
44
47
|
return '<='
|
|
48
|
+
case 'gt':
|
|
49
|
+
return '>'
|
|
50
|
+
case 'gte':
|
|
51
|
+
return '>='
|
|
45
52
|
case 'eq':
|
|
46
53
|
return '=='
|
|
47
54
|
}
|
package/src/run/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
rpcClientFromServer,
|
|
48
48
|
} from '../install/build-install-policy.ts'
|
|
49
49
|
import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
|
|
50
|
+
import { accountRuleReaderFromServer, collectObservedRules } from '../install/read-account-rules.ts'
|
|
50
51
|
import { decodePredicate } from '../predicate/decode.ts'
|
|
51
52
|
import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
|
|
52
53
|
import {
|
|
@@ -76,6 +77,7 @@ export type {
|
|
|
76
77
|
DeclarePolicyInput,
|
|
77
78
|
GetInterpreterInfoInput,
|
|
78
79
|
InstallPolicyInput,
|
|
80
|
+
OzBuiltinPolicy,
|
|
79
81
|
RecordTransactionInput,
|
|
80
82
|
RevokePolicyInput,
|
|
81
83
|
SimulatePolicyInput,
|
|
@@ -99,6 +101,8 @@ export {
|
|
|
99
101
|
PINNED_INTERPRETER_MAINNET_ADDRESS,
|
|
100
102
|
PINNED_INTERPRETER_TESTNET_ADDRESS,
|
|
101
103
|
PINNED_INTERPRETER_WASM_SHA256,
|
|
104
|
+
PINNED_OZ_POLICY_ADDRESS_BY_NETWORK,
|
|
105
|
+
PINNED_OZ_POLICY_WASM_SHA256,
|
|
102
106
|
PredicateLeafSchema,
|
|
103
107
|
PredicateNodeSchema,
|
|
104
108
|
RecordedTransactionSchema,
|
|
@@ -267,12 +271,19 @@ export async function runInstallPolicy(
|
|
|
267
271
|
rpc: rpcClient,
|
|
268
272
|
...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
|
|
269
273
|
})
|
|
270
|
-
// Cross-rule scan
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
+
// Cross-rule scan. The caller may supply `existingRules` (useful offline,
|
|
275
|
+
// and for testing); otherwise the account is READ, so the answer describes
|
|
276
|
+
// what is actually installed rather than what the caller happened to
|
|
277
|
+
// mention.
|
|
278
|
+
//
|
|
279
|
+
// `null` means NOT CHECKED and is returned whenever the scan cannot be
|
|
280
|
+
// trusted to be complete - the read failed, or it stopped before
|
|
281
|
+
// accounting for every live rule. An empty list would say "checked,
|
|
282
|
+
// nothing found", and a partial scan that reported `[]` would be claiming
|
|
283
|
+
// a safety it never established.
|
|
284
|
+
const observed = await resolveExistingRules(input, network, expectedInterpreter)
|
|
274
285
|
const authorityScan =
|
|
275
|
-
|
|
286
|
+
observed === null
|
|
276
287
|
? null
|
|
277
288
|
: findAuthorityOverlaps({
|
|
278
289
|
intended: {
|
|
@@ -284,9 +295,7 @@ export async function runInstallPolicy(
|
|
|
284
295
|
signers: input.rule.signers,
|
|
285
296
|
predicate: decodePredicate(encodedPredicate),
|
|
286
297
|
},
|
|
287
|
-
|
|
288
|
-
// PredicateNodeSchema); the shape is already validated.
|
|
289
|
-
existing: input.existingRules as ObservedRule[],
|
|
298
|
+
existing: observed,
|
|
290
299
|
})
|
|
291
300
|
return { ok: true, data: { ...result, authorityScan } }
|
|
292
301
|
} catch (e) {
|
|
@@ -453,6 +462,7 @@ export function runDeclarePolicy(raw: unknown): ToolResponse<{
|
|
|
453
462
|
...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
|
|
454
463
|
...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
|
|
455
464
|
...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
|
|
465
|
+
...(d.minOutputRatio !== undefined ? { minOutputRatio: d.minOutputRatio } : {}),
|
|
456
466
|
})
|
|
457
467
|
const { encodedPredicate, predicateHash } = encodePredicate(predicate)
|
|
458
468
|
return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } }
|
|
@@ -560,6 +570,45 @@ export async function runGetInterpreterInfo(
|
|
|
560
570
|
* network, falling back to the pinned RPC for the network. The caller
|
|
561
571
|
* has already been gated against the pinned URL elsewhere, so the
|
|
562
572
|
* fallback here only ever picks from a finite, audited pair. */
|
|
573
|
+
/** The account's other context rules, or `null` when they could not be
|
|
574
|
+
* established completely.
|
|
575
|
+
*
|
|
576
|
+
* Caller-supplied `existingRules` win: they let the scan run offline, and a
|
|
577
|
+
* caller who passes them has said what to compare against. Otherwise the
|
|
578
|
+
* account is read over RPC.
|
|
579
|
+
*
|
|
580
|
+
* Every failure path returns `null` rather than a short list. A read that
|
|
581
|
+
* threw, or one that stopped before accounting for every live rule, has not
|
|
582
|
+
* ruled anything out - and reporting `[]` there would turn "we could not
|
|
583
|
+
* check" into "there is nothing to worry about". */
|
|
584
|
+
async function resolveExistingRules(
|
|
585
|
+
input: InstallPolicyInput,
|
|
586
|
+
network: Network,
|
|
587
|
+
interpreterAddress: string
|
|
588
|
+
): Promise<ObservedRule[] | null> {
|
|
589
|
+
if (input.existingRules !== undefined) {
|
|
590
|
+
// The schema types `predicate` loosely (it is the shared
|
|
591
|
+
// PredicateNodeSchema); the shape is already validated.
|
|
592
|
+
return input.existingRules as ObservedRule[]
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
const url = input.rpcUrl ?? RPC_URL_BY_NETWORK[network]
|
|
596
|
+
const server = new rpc.Server(url, { allowHttp: false })
|
|
597
|
+
const collected = await collectObservedRules({
|
|
598
|
+
reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
|
|
599
|
+
smartAccount: input.smartAccount,
|
|
600
|
+
interpreterAddress,
|
|
601
|
+
})
|
|
602
|
+
if (collected.incomplete) return null
|
|
603
|
+
return collected.rules
|
|
604
|
+
} catch {
|
|
605
|
+
// The install itself is unaffected: the scan is advisory, so a failed
|
|
606
|
+
// read must not block a policy the user asked for. It just cannot be
|
|
607
|
+
// reported as a clean scan.
|
|
608
|
+
return null
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
563
612
|
function buildRpcClientFromInput(
|
|
564
613
|
urlOverride: string | undefined,
|
|
565
614
|
network: Network
|
package/src/run/schemas.ts
CHANGED
|
@@ -201,6 +201,15 @@ export const PredicateLeafSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
|
201
201
|
element: z.number().int().nonnegative(),
|
|
202
202
|
field: z.string(),
|
|
203
203
|
}),
|
|
204
|
+
// num/den are i128 decimal strings, matching `literal_i128`. The regex
|
|
205
|
+
// is the boundary guard; the ratio's SIGN is checked at encode, where
|
|
206
|
+
// the message can explain that a negative ratio inverts the comparison.
|
|
207
|
+
z.object({
|
|
208
|
+
kind: z.literal('call_arg_scaled'),
|
|
209
|
+
index: z.number().int().nonnegative(),
|
|
210
|
+
num: z.string().regex(/^-?[0-9]+$/),
|
|
211
|
+
den: z.string().regex(/^-?[0-9]+$/),
|
|
212
|
+
}),
|
|
204
213
|
z.object({ kind: z.literal('literal_address'), value: z.string() }),
|
|
205
214
|
z.object({ kind: z.literal('literal_i128'), value: z.string().regex(/^-?[0-9]+$/) }),
|
|
206
215
|
z.object({ kind: z.literal('literal_symbol'), value: z.string() }),
|
|
@@ -222,16 +231,32 @@ export const PredicateLeafSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
|
222
231
|
export const PredicateNodeSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
223
232
|
z.union([
|
|
224
233
|
z.object({ op: z.literal('and'), children: z.array(PredicateNodeSchema) }),
|
|
234
|
+
z.object({ op: z.literal('or'), children: z.array(PredicateNodeSchema) }),
|
|
225
235
|
z.object({
|
|
226
236
|
op: z.literal('eq'),
|
|
227
237
|
left: PredicateLeafSchema,
|
|
228
238
|
right: PredicateLeafSchema,
|
|
229
239
|
}),
|
|
240
|
+
z.object({
|
|
241
|
+
op: z.literal('lt'),
|
|
242
|
+
left: PredicateLeafSchema,
|
|
243
|
+
right: PredicateLeafSchema,
|
|
244
|
+
}),
|
|
230
245
|
z.object({
|
|
231
246
|
op: z.literal('lte'),
|
|
232
247
|
left: PredicateLeafSchema,
|
|
233
248
|
right: PredicateLeafSchema,
|
|
234
249
|
}),
|
|
250
|
+
z.object({
|
|
251
|
+
op: z.literal('gt'),
|
|
252
|
+
left: PredicateLeafSchema,
|
|
253
|
+
right: PredicateLeafSchema,
|
|
254
|
+
}),
|
|
255
|
+
z.object({
|
|
256
|
+
op: z.literal('gte'),
|
|
257
|
+
left: PredicateLeafSchema,
|
|
258
|
+
right: PredicateLeafSchema,
|
|
259
|
+
}),
|
|
235
260
|
z.object({
|
|
236
261
|
op: z.literal('in'),
|
|
237
262
|
needle: PredicateLeafSchema,
|
|
@@ -348,12 +373,12 @@ const ContextRuleDraftSchema = z
|
|
|
348
373
|
/** Pinned interpreter address (testnet).
|
|
349
374
|
* Single source for the MCP layer; do not embed elsewhere. */
|
|
350
375
|
export const PINNED_INTERPRETER_TESTNET_ADDRESS =
|
|
351
|
-
'
|
|
376
|
+
'CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5'
|
|
352
377
|
|
|
353
|
-
/** Pinned interpreter address (mainnet), redeployed 2026-08-22 from a reproducible build. The mainnet
|
|
378
|
+
/** Pinned interpreter address (mainnet), redeployed 2026-08-22 for grammar 4, from a reproducible build. The mainnet
|
|
354
379
|
* interpreter IS the binary exercised on testnet - both instances were created
|
|
355
380
|
* from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
|
|
356
|
-
* both were read back with `grammar_version()` returning
|
|
381
|
+
* both were read back with `grammar_version()` returning 4. The address differs
|
|
357
382
|
* because instance ids are network-scoped. UNAUDITED at the time of writing.
|
|
358
383
|
*
|
|
359
384
|
* These four constants move together or not at all. The grammar version and
|
|
@@ -361,15 +386,15 @@ export const PINNED_INTERPRETER_TESTNET_ADDRESS =
|
|
|
361
386
|
* alone would have the builder emit a version the other network refuses - with
|
|
362
387
|
* a green test run, since `grammar-version-parity.test.ts` would then pass. */
|
|
363
388
|
export const PINNED_INTERPRETER_MAINNET_ADDRESS =
|
|
364
|
-
'
|
|
389
|
+
'CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52'
|
|
365
390
|
|
|
366
391
|
/** Pinned interpreter wasm sha256 (hex). */
|
|
367
392
|
export const PINNED_INTERPRETER_WASM_SHA256 =
|
|
368
|
-
'
|
|
393
|
+
'b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae'
|
|
369
394
|
|
|
370
395
|
/** The grammar version the interpreter enforces (matches SELF_VERSION in
|
|
371
396
|
* contracts/policy-interpreter/src/version.rs). */
|
|
372
|
-
export const PINNED_INTERPRETER_GRAMMAR_VERSION =
|
|
397
|
+
export const PINNED_INTERPRETER_GRAMMAR_VERSION = 4
|
|
373
398
|
|
|
374
399
|
/** Default Soroban RPC for the install / revoke / info tools. The recorder
|
|
375
400
|
* keeps its own copy in record/rpc.ts because it hands back a fetcher rather
|
|
@@ -396,6 +421,46 @@ export const RPC_URL_BY_NETWORK: Record<Network, string> = {
|
|
|
396
421
|
mainnet: MAINNET_RPC_URL,
|
|
397
422
|
}
|
|
398
423
|
|
|
424
|
+
/** The OpenZeppelin built-in policies we deployed instances of. These are OZ
|
|
425
|
+
* EXAMPLE contracts, built by us from `OpenZeppelin/stellar-contracts` at tag
|
|
426
|
+
* v0.7.2 and deployed by us. We have NOT audited them and upstream ships an
|
|
427
|
+
* "experimental software ... as is" disclaimer; anything surfacing one of
|
|
428
|
+
* these to a user must say so rather than implying we vouch for the code.
|
|
429
|
+
* Provenance detail in `docs/audit/README.md` finding 7. */
|
|
430
|
+
export type OzBuiltinPolicy = 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
|
|
431
|
+
|
|
432
|
+
/** Instance addresses per network. Exported so consumers import the pin
|
|
433
|
+
* instead of copying a literal - a copied address is how a testnet id ends up
|
|
434
|
+
* being queried against mainnet, which returns `Error(Storage, MissingValue)`
|
|
435
|
+
* and reads exactly like "nothing is deployed there".
|
|
436
|
+
*
|
|
437
|
+
* Instance ids are network-scoped, so the addresses differ while the wasm
|
|
438
|
+
* hash does not: each pair below was created from the same uploaded wasm (see
|
|
439
|
+
* `PINNED_OZ_POLICY_WASM_SHA256`), verified by fetching the deployed bytes
|
|
440
|
+
* back from both networks. */
|
|
441
|
+
export const PINNED_OZ_POLICY_ADDRESS_BY_NETWORK: Record<
|
|
442
|
+
Network,
|
|
443
|
+
Record<OzBuiltinPolicy, string>
|
|
444
|
+
> = {
|
|
445
|
+
testnet: {
|
|
446
|
+
spending_limit: 'CDH4KOBRUEZI6TTZ72YXR5YUIODB6RH3AF75KX56Z73DELRCA5TWFISP',
|
|
447
|
+
simple_threshold: 'CAYTIVQOEZDOQI4GC3XBXEEYHQUANQQJHPJVMXVRBREGSAP6TCN3DID6',
|
|
448
|
+
weighted_threshold: 'CCTNRFZCL45GTJICA3Z2KFQO3VEGBHGCVBLHQ3GLJKAGACQIJMYJS7T2',
|
|
449
|
+
},
|
|
450
|
+
mainnet: {
|
|
451
|
+
spending_limit: 'CA7IBD266HIHFDUIBZLPIAITJUA3DVY4JAG6K3QMGBKLZCXXLP5E2F7A',
|
|
452
|
+
simple_threshold: 'CDOGPGUFGGUDG25P3TG6XIXJKRRYOZ3PXUZIEPVH74KXRZIDKZ5HYEOS',
|
|
453
|
+
weighted_threshold: 'CDWPZ4YZ3YIJ64XSHRMERRF2L2H7XD6SPUZDP3KI56T7QXQCICC25V3J',
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** sha256 of each deployed policy wasm, identical across both networks. */
|
|
458
|
+
export const PINNED_OZ_POLICY_WASM_SHA256: Record<OzBuiltinPolicy, string> = {
|
|
459
|
+
spending_limit: '9ce30ea1fe5c2dc5c9c49cf3462adb32e2c11d7dfadb15ef43a51ba56568de2b',
|
|
460
|
+
simple_threshold: '01c0be09eb6fb288cab2e878b4e890f7a38f75afab99aeb197861f44e2e2dfe6',
|
|
461
|
+
weighted_threshold: '78030272b06afb09d2949ab8877c9a8ae1ab9025b48f4edafd5816cc44f76eaa',
|
|
462
|
+
}
|
|
463
|
+
|
|
399
464
|
/** Stellar network passphrases. Pinned here so the XDR envelope uses the
|
|
400
465
|
* matching passphrase when the wallet signs (a mismatch yields invalid
|
|
401
466
|
* hashes). */
|
|
@@ -438,6 +503,18 @@ export const DeclarePolicyInputSchema = z
|
|
|
438
503
|
recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
|
|
439
504
|
recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
|
|
440
505
|
allowZeroCap: z.boolean().optional(),
|
|
506
|
+
/** Minimum output as a ratio of the call's own input. num/den are decimal
|
|
507
|
+
* STRINGS for the same reason maxAmount is: an i128 ratio does not
|
|
508
|
+
* survive a JS number. */
|
|
509
|
+
minOutputRatio: z
|
|
510
|
+
.object({
|
|
511
|
+
num: z.string().regex(/^[0-9]+$/, 'num must be an unsigned integer'),
|
|
512
|
+
den: z.string().regex(/^[0-9]+$/, 'den must be an unsigned integer'),
|
|
513
|
+
inputArgIndex: z.number().int().nonnegative().max(U32_MAX),
|
|
514
|
+
outputArgIndex: z.number().int().nonnegative().max(U32_MAX),
|
|
515
|
+
})
|
|
516
|
+
.strict()
|
|
517
|
+
.optional(),
|
|
441
518
|
})
|
|
442
519
|
.strict()
|
|
443
520
|
export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>
|
|
@@ -24,7 +24,7 @@ export interface GeneratedCases {
|
|
|
24
24
|
* the wrong reason. */
|
|
25
25
|
const OTHER_CONTRACT = Address.contract(Buffer.alloc(32, 0x5a)).toString()
|
|
26
26
|
|
|
27
|
-
type ComparisonOperator = 'eq' | 'lte'
|
|
27
|
+
type ComparisonOperator = 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
|
|
28
28
|
|
|
29
29
|
type ComparisonNode = {
|
|
30
30
|
op: ComparisonOperator
|
|
@@ -248,11 +248,22 @@ function visit(node: PredicateNode, facts: PredicateFacts): void {
|
|
|
248
248
|
case 'and':
|
|
249
249
|
for (const child of node.children) visit(child, facts)
|
|
250
250
|
return
|
|
251
|
+
// NOT descended into. A deny case works by violating ONE constraint and
|
|
252
|
+
// asserting the predicate refuses the call. Violating one branch of an
|
|
253
|
+
// `or` proves nothing, because another branch can still permit, so the
|
|
254
|
+
// generated case would either fail or pass for the wrong reason. A sound
|
|
255
|
+
// deny case for a disjunction must violate EVERY branch at once, which
|
|
256
|
+
// this generator does not construct - so it emits none.
|
|
257
|
+
case 'or':
|
|
258
|
+
return
|
|
251
259
|
case 'in':
|
|
252
260
|
facts.memberships.push(node)
|
|
253
261
|
return
|
|
254
262
|
case 'eq':
|
|
263
|
+
case 'lt':
|
|
255
264
|
case 'lte':
|
|
265
|
+
case 'gt':
|
|
266
|
+
case 'gte':
|
|
256
267
|
facts.comparisons.push(node)
|
|
257
268
|
}
|
|
258
269
|
}
|
package/src/simulate/evaluate.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/simulate/evaluate.ts - TypeScript reference evaluator for grammar version
|
|
1
|
+
// src/simulate/evaluate.ts - TypeScript reference evaluator for grammar version 4.
|
|
2
2
|
//
|
|
3
3
|
// Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
|
|
4
4
|
// no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
// 3. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
|
|
9
9
|
// 4. otherwise permit.
|
|
10
10
|
//
|
|
11
|
-
// Grammar version
|
|
12
|
-
// Grammar version
|
|
11
|
+
// Grammar version 4 nodes: and, or, eq, lt, lte, gt, gte, in
|
|
12
|
+
// Grammar version 4 leaves: call_contract, call_fn, call_arg(i),
|
|
13
13
|
// call_arg_len(i), call_arg_field(i, element, field),
|
|
14
|
+
// call_arg_scaled(i, num, den),
|
|
14
15
|
// literal_address, literal_i128, literal_symbol, literal_u32, literal_vec
|
|
15
|
-
// Grammar version
|
|
16
|
-
// UNSUPPORTED_NODE, NOT_IN_ALLOWLIST
|
|
16
|
+
// Grammar version 4 deny reasons: ARG_MISMATCH, CONTRACT_SCOPE,
|
|
17
|
+
// ARITHMETIC_OVERFLOW, UNSUPPORTED_NODE, NOT_IN_ALLOWLIST, SLIPPAGE_FLOOR
|
|
17
18
|
|
|
18
19
|
import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
|
|
19
20
|
|
|
@@ -28,6 +29,9 @@ export interface EvalContext {
|
|
|
28
29
|
|
|
29
30
|
export type EvalResult = { permit: true } | { permit: false; reason: string }
|
|
30
31
|
|
|
32
|
+
/** The comparison operators grammar 4 carries. */
|
|
33
|
+
type CompareOpName = 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
|
|
34
|
+
|
|
31
35
|
/** Evaluate a `PredicateNode` against the candidate call described by `ctx`.
|
|
32
36
|
* Pure function. Returns `{ permit: true }` or `{ permit: false; reason }`. */
|
|
33
37
|
export function evaluate(predicate: PredicateNode, ctx: EvalContext): EvalResult {
|
|
@@ -47,8 +51,23 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
|
|
|
47
51
|
}
|
|
48
52
|
return lastDeny ?? { permit: true }
|
|
49
53
|
}
|
|
54
|
+
// Permits on the first branch that holds; when none does, reports the
|
|
55
|
+
// FIRST branch's reason. Mirrors `Node::Or` in the Rust evaluator, which
|
|
56
|
+
// the conformance suite pins.
|
|
57
|
+
case 'or': {
|
|
58
|
+
let firstDeny: EvalResult | null = null
|
|
59
|
+
for (const child of node.children) {
|
|
60
|
+
const r = walk(child, ctx)
|
|
61
|
+
if (r.permit) return r
|
|
62
|
+
if (firstDeny === null) firstDeny = r
|
|
63
|
+
}
|
|
64
|
+
return firstDeny ?? { permit: false, reason: 'UNSUPPORTED_NODE' }
|
|
65
|
+
}
|
|
50
66
|
case 'eq':
|
|
67
|
+
case 'lt':
|
|
51
68
|
case 'lte':
|
|
69
|
+
case 'gt':
|
|
70
|
+
case 'gte':
|
|
52
71
|
return evalCompare(node.op, node.left, node.right, ctx)
|
|
53
72
|
case 'in':
|
|
54
73
|
return evalIn(node.needle, node.haystack, ctx)
|
|
@@ -57,11 +76,22 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
|
|
|
57
76
|
|
|
58
77
|
/** Comparison leaf evaluation. */
|
|
59
78
|
function evalCompare(
|
|
60
|
-
op:
|
|
79
|
+
op: CompareOpName,
|
|
61
80
|
left: PredicateLeaf,
|
|
62
81
|
right: PredicateLeaf,
|
|
63
82
|
ctx: EvalContext
|
|
64
83
|
): EvalResult {
|
|
84
|
+
// Scaled operands first, so the dedicated reasons reach the caller instead
|
|
85
|
+
// of a generic mismatch. Right-hand dispatch leads because
|
|
86
|
+
// `out >= in * num / den` is the canonical swap form. Mirrors the order in
|
|
87
|
+
// `eval_compare` on the Rust side.
|
|
88
|
+
if (right.kind === 'call_arg_scaled') {
|
|
89
|
+
return evalScaledCompare(op, left, right, true, ctx)
|
|
90
|
+
}
|
|
91
|
+
if (left.kind === 'call_arg_scaled') {
|
|
92
|
+
return evalScaledCompare(op, right, left, false, ctx)
|
|
93
|
+
}
|
|
94
|
+
|
|
65
95
|
// CONTRACT_SCOPE on call_contract eq
|
|
66
96
|
if (left.kind === 'call_contract' && op === 'eq') {
|
|
67
97
|
if (right.kind !== 'literal_address') return { permit: false, reason: 'CONTRACT_SCOPE' }
|
|
@@ -112,10 +142,66 @@ function evalCompare(
|
|
|
112
142
|
return { permit: false, reason: 'UNSUPPORTED_NODE' }
|
|
113
143
|
}
|
|
114
144
|
|
|
145
|
+
/** i128 bounds. The contract computes in i128 and denies on overflow, so the
|
|
146
|
+
* reference has to draw the same line or the two layers disagree on inputs
|
|
147
|
+
* near the boundary. */
|
|
148
|
+
const I128_MIN = -(2n ** 127n)
|
|
149
|
+
const I128_MAX = 2n ** 127n - 1n
|
|
150
|
+
|
|
151
|
+
/** Comparison where one side is `call_arg_scaled`. Mirrors
|
|
152
|
+
* `eval_scaled_arg_compare`: `args[index] * num / den` truncating toward
|
|
153
|
+
* zero, ARITHMETIC_OVERFLOW on arithmetic that does not fit or a zero
|
|
154
|
+
* denominator, SLIPPAGE_FLOOR on a comparison that simply fails. */
|
|
155
|
+
function evalScaledCompare(
|
|
156
|
+
op: CompareOpName,
|
|
157
|
+
other: PredicateLeaf,
|
|
158
|
+
scaled: Extract<PredicateLeaf, { kind: 'call_arg_scaled' }>,
|
|
159
|
+
scaledOnRight: boolean,
|
|
160
|
+
ctx: EvalContext
|
|
161
|
+
): EvalResult {
|
|
162
|
+
// Chaining two computed operands has no meaning a review card could state.
|
|
163
|
+
if (other.kind === 'call_arg_scaled') return { permit: false, reason: 'UNSUPPORTED_NODE' }
|
|
164
|
+
|
|
165
|
+
// Could not READ the operand is a different failure from read-and-missed.
|
|
166
|
+
const input = argNumericBigInt(ctx.args[scaled.index])
|
|
167
|
+
if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
168
|
+
|
|
169
|
+
let num: bigint
|
|
170
|
+
let den: bigint
|
|
171
|
+
try {
|
|
172
|
+
num = BigInt(scaled.num)
|
|
173
|
+
den = BigInt(scaled.den)
|
|
174
|
+
} catch {
|
|
175
|
+
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
176
|
+
}
|
|
177
|
+
if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
178
|
+
|
|
179
|
+
const product = input * num
|
|
180
|
+
if (product < I128_MIN || product > I128_MAX) {
|
|
181
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
182
|
+
}
|
|
183
|
+
// BigInt division already truncates toward zero, matching i128 semantics.
|
|
184
|
+
const quotient = product / den
|
|
185
|
+
if (quotient < I128_MIN || quotient > I128_MAX) {
|
|
186
|
+
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const otherVal =
|
|
190
|
+
other.kind === 'call_arg'
|
|
191
|
+
? argNumericBigInt(ctx.args[other.index])
|
|
192
|
+
: literalNumericBigInt(other)
|
|
193
|
+
if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
194
|
+
|
|
195
|
+
const [a, b] = scaledOnRight ? [otherVal, quotient] : [quotient, otherVal]
|
|
196
|
+
return bigintCmp(op, a.toString(), b.toString())
|
|
197
|
+
? { permit: true }
|
|
198
|
+
: { permit: false, reason: 'SLIPPAGE_FLOOR' }
|
|
199
|
+
}
|
|
200
|
+
|
|
115
201
|
/** Per-ScVal equality. Handles literal_vec as an EXACT ordered sequence:
|
|
116
202
|
* compare element-by-element in order; deny if length or any element differs.
|
|
117
203
|
* Opaque args (`type: 'other'`) fail closed. */
|
|
118
|
-
function evalArgEq(op:
|
|
204
|
+
function evalArgEq(op: CompareOpName, actual: ScVal | undefined, right: PredicateLeaf): EvalResult {
|
|
119
205
|
// eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
|
|
120
206
|
if (op === 'eq' && right.kind === 'literal_vec') {
|
|
121
207
|
if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
@@ -163,7 +249,7 @@ function evalArgEq(op: 'eq' | 'lte', actual: ScVal | undefined, right: Predicate
|
|
|
163
249
|
* to a numeric literal via BigInt. A non-numeric arg or a non-numeric literal
|
|
164
250
|
* fails closed (ARG_MISMATCH) rather than permitting an undecidable bound. */
|
|
165
251
|
function evalArgOrderedCompare(
|
|
166
|
-
op:
|
|
252
|
+
op: CompareOpName,
|
|
167
253
|
actual: ScVal | undefined,
|
|
168
254
|
right: PredicateLeaf
|
|
169
255
|
): EvalResult {
|
|
@@ -272,13 +358,19 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
|
|
|
272
358
|
}
|
|
273
359
|
|
|
274
360
|
/** BigInt compare helper. */
|
|
275
|
-
function bigintCmp(op:
|
|
361
|
+
function bigintCmp(op: CompareOpName, aStr: string, bStr: string): boolean {
|
|
276
362
|
const a = BigInt(aStr)
|
|
277
363
|
const b = BigInt(bStr)
|
|
278
364
|
switch (op) {
|
|
279
365
|
case 'eq':
|
|
280
366
|
return a === b
|
|
367
|
+
case 'lt':
|
|
368
|
+
return a < b
|
|
281
369
|
case 'lte':
|
|
282
370
|
return a <= b
|
|
371
|
+
case 'gt':
|
|
372
|
+
return a > b
|
|
373
|
+
case 'gte':
|
|
374
|
+
return a >= b
|
|
283
375
|
}
|
|
284
376
|
}
|