@crediolabs/policy-synth 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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.d.ts +101 -0
- package/dist/install/authority-overlap.js +239 -0
- package/dist/install/build-add-context-rule.d.ts +1 -1
- package/dist/install/index.d.ts +1 -0
- package/dist/install/index.js +4 -0
- 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 +14 -1
- package/dist/record/index.d.ts +10 -0
- package/dist/record/index.js +32 -1
- package/dist/record/rpc.d.ts +4 -0
- package/dist/record/rpc.js +4 -1
- package/dist/registry/identify.d.ts +10 -1
- package/dist/registry/identify.js +4 -1
- package/dist/registry/on-chain-spec.d.ts +37 -0
- package/dist/registry/on-chain-spec.js +152 -0
- 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 +25 -4
- package/dist/run/index.js +108 -4
- package/dist/run/schemas.d.ts +350 -6
- package/dist/run/schemas.js +95 -6
- package/dist/simulate/deny-cases.js +11 -0
- package/dist/simulate/evaluate.js +86 -5
- package/dist/synth/declare.d.ts +43 -0
- package/dist/synth/declare.js +127 -0
- package/dist/synth/index.d.ts +1 -0
- package/dist/synth/index.js +1 -0
- 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.d.ts +101 -0
- package/dist-cjs/install/authority-overlap.js +248 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
- package/dist-cjs/install/index.d.ts +1 -0
- package/dist-cjs/install/index.js +13 -2
- 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 +14 -1
- package/dist-cjs/record/index.d.ts +10 -0
- package/dist-cjs/record/index.js +31 -0
- package/dist-cjs/record/rpc.d.ts +4 -0
- package/dist-cjs/record/rpc.js +7 -3
- package/dist-cjs/registry/identify.d.ts +10 -1
- package/dist-cjs/registry/identify.js +4 -0
- package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
- package/dist-cjs/registry/on-chain-spec.js +159 -0
- 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 +25 -4
- package/dist-cjs/run/index.js +108 -2
- package/dist-cjs/run/schemas.d.ts +350 -6
- package/dist-cjs/run/schemas.js +96 -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 +43 -0
- package/dist-cjs/synth/declare.js +130 -0
- package/dist-cjs/synth/index.d.ts +1 -0
- package/dist-cjs/synth/index.js +3 -1
- 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 +323 -0
- package/src/install/index.ts +20 -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 +14 -1
- package/src/record/index.ts +59 -2
- package/src/record/rpc.ts +4 -1
- package/src/registry/identify.ts +4 -1
- package/src/registry/on-chain-spec.ts +168 -0
- 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 +125 -2
- package/src/run/schemas.ts +100 -6
- package/src/simulate/deny-cases.ts +12 -1
- package/src/simulate/evaluate.ts +101 -9
- package/src/synth/declare.ts +206 -0
- package/src/synth/index.ts +5 -0
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +16 -1
package/src/run/index.ts
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
import { createHash } from 'node:crypto'
|
|
21
21
|
import { rpc } from '@stellar/stellar-sdk'
|
|
22
22
|
import {
|
|
23
|
+
declarePredicate,
|
|
23
24
|
type ErrorCode,
|
|
25
|
+
encodePredicate,
|
|
24
26
|
type Network,
|
|
25
27
|
type PredicateNode,
|
|
26
28
|
type ProposedPolicy,
|
|
@@ -31,6 +33,11 @@ import {
|
|
|
31
33
|
type ToolError,
|
|
32
34
|
type ToolResponse,
|
|
33
35
|
} from '../index.ts'
|
|
36
|
+
import {
|
|
37
|
+
type AuthorityOverlap,
|
|
38
|
+
findAuthorityOverlaps,
|
|
39
|
+
type ObservedRule,
|
|
40
|
+
} from '../install/authority-overlap.ts'
|
|
34
41
|
import {
|
|
35
42
|
type BuildInstallPolicyResult,
|
|
36
43
|
type BuildRevokePolicyResult,
|
|
@@ -40,8 +47,11 @@ import {
|
|
|
40
47
|
rpcClientFromServer,
|
|
41
48
|
} from '../install/build-install-policy.ts'
|
|
42
49
|
import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
|
|
50
|
+
import { accountRuleReaderFromServer, collectObservedRules } from '../install/read-account-rules.ts'
|
|
51
|
+
import { decodePredicate } from '../predicate/decode.ts'
|
|
43
52
|
import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
|
|
44
53
|
import {
|
|
54
|
+
DeclarePolicyInputSchema,
|
|
45
55
|
type GetInterpreterInfoInput,
|
|
46
56
|
GetInterpreterInfoInputSchema,
|
|
47
57
|
type InstallPolicyInput,
|
|
@@ -64,6 +74,7 @@ import {
|
|
|
64
74
|
} from './schemas.ts'
|
|
65
75
|
|
|
66
76
|
export type {
|
|
77
|
+
DeclarePolicyInput,
|
|
67
78
|
GetInterpreterInfoInput,
|
|
68
79
|
InstallPolicyInput,
|
|
69
80
|
RecordTransactionInput,
|
|
@@ -78,6 +89,7 @@ export type {
|
|
|
78
89
|
// truth - MCP tool shapes are derived from them.
|
|
79
90
|
export {
|
|
80
91
|
ComposeUserResponsesSchema,
|
|
92
|
+
DeclarePolicyInputSchema,
|
|
81
93
|
GetInterpreterInfoInputSchema,
|
|
82
94
|
InstallPolicyInputSchema,
|
|
83
95
|
InterpreterOptionsSchema,
|
|
@@ -107,6 +119,7 @@ export type RunVerifyPolicyInput = VerifyPolicyInput
|
|
|
107
119
|
type RunToolName =
|
|
108
120
|
| 'record_transaction'
|
|
109
121
|
| 'synthesize_policy'
|
|
122
|
+
| 'declare_policy'
|
|
110
123
|
| 'simulate_policy'
|
|
111
124
|
| 'verify_policy'
|
|
112
125
|
| 'install_policy'
|
|
@@ -119,6 +132,7 @@ type RunToolName =
|
|
|
119
132
|
const TOOL_ERROR_CODE: Record<RunToolName, ErrorCode> = {
|
|
120
133
|
record_transaction: 'RECORDING_FAILED',
|
|
121
134
|
synthesize_policy: 'SYNTHESIS_ERROR',
|
|
135
|
+
declare_policy: 'SYNTHESIS_ERROR',
|
|
122
136
|
simulate_policy: 'SIMULATION_ERROR',
|
|
123
137
|
verify_policy: 'VERIFICATION_FAILED',
|
|
124
138
|
install_policy: 'INSTALL_BUILD_FAILED',
|
|
@@ -203,7 +217,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
|
203
217
|
|
|
204
218
|
export async function runInstallPolicy(
|
|
205
219
|
raw: unknown
|
|
206
|
-
): Promise<ToolResponse<BuildInstallPolicyResult>> {
|
|
220
|
+
): Promise<ToolResponse<BuildInstallPolicyResult & { authorityScan: AuthorityOverlap[] | null }>> {
|
|
207
221
|
const parsed = InstallPolicyInputSchema.safeParse(raw)
|
|
208
222
|
if (!parsed.success) {
|
|
209
223
|
return { ok: false, error: validationError('install_policy', parsed.error.issues) }
|
|
@@ -254,7 +268,33 @@ export async function runInstallPolicy(
|
|
|
254
268
|
rpc: rpcClient,
|
|
255
269
|
...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
|
|
256
270
|
})
|
|
257
|
-
|
|
271
|
+
// Cross-rule scan. The caller may supply `existingRules` (useful offline,
|
|
272
|
+
// and for testing); otherwise the account is READ, so the answer describes
|
|
273
|
+
// what is actually installed rather than what the caller happened to
|
|
274
|
+
// mention.
|
|
275
|
+
//
|
|
276
|
+
// `null` means NOT CHECKED and is returned whenever the scan cannot be
|
|
277
|
+
// trusted to be complete - the read failed, or it stopped before
|
|
278
|
+
// accounting for every live rule. An empty list would say "checked,
|
|
279
|
+
// nothing found", and a partial scan that reported `[]` would be claiming
|
|
280
|
+
// a safety it never established.
|
|
281
|
+
const observed = await resolveExistingRules(input, network, expectedInterpreter)
|
|
282
|
+
const authorityScan =
|
|
283
|
+
observed === null
|
|
284
|
+
? null
|
|
285
|
+
: findAuthorityOverlaps({
|
|
286
|
+
intended: {
|
|
287
|
+
// `add_context_rule` gets its id FROM the account, so there is
|
|
288
|
+
// no existing rule this install replaces. A sentinel no real id
|
|
289
|
+
// can equal keeps every observed rule in scope.
|
|
290
|
+
ruleId: -1,
|
|
291
|
+
contextType: input.rule.contextRuleType,
|
|
292
|
+
signers: input.rule.signers,
|
|
293
|
+
predicate: decodePredicate(encodedPredicate),
|
|
294
|
+
},
|
|
295
|
+
existing: observed,
|
|
296
|
+
})
|
|
297
|
+
return { ok: true, data: { ...result, authorityScan } }
|
|
258
298
|
} catch (e) {
|
|
259
299
|
return toolFailure('install_policy', e)
|
|
260
300
|
}
|
|
@@ -384,6 +424,50 @@ export function runSimulatePolicy(raw: unknown): ToolResponse<{
|
|
|
384
424
|
}
|
|
385
425
|
}
|
|
386
426
|
|
|
427
|
+
/** `declare_policy` body - the DECLARATIVE front-end.
|
|
428
|
+
*
|
|
429
|
+
* `synthesize_policy` infers a predicate from a transaction that happened;
|
|
430
|
+
* this takes the constraint stated outright. No RPC, no decoding and no
|
|
431
|
+
* parseConfidence, so nothing here can be refused for a contract the registry
|
|
432
|
+
* does not recognise - which is most of the point of having it.
|
|
433
|
+
*
|
|
434
|
+
* The returned `warnings` are load-bearing, not decoration. An argument index
|
|
435
|
+
* the caller did not supply is DEFAULTED to the SEP-41 position, and a bound
|
|
436
|
+
* on the wrong argument constrains something the caller did not mean without
|
|
437
|
+
* ever announcing itself, so a caller that ignores warnings can install a
|
|
438
|
+
* predicate that reads correctly and binds nothing. */
|
|
439
|
+
export function runDeclarePolicy(raw: unknown): ToolResponse<{
|
|
440
|
+
predicate: PredicateNode
|
|
441
|
+
encodedPredicate: string
|
|
442
|
+
predicateHash: string
|
|
443
|
+
warnings: string[]
|
|
444
|
+
}> {
|
|
445
|
+
const parsed = DeclarePolicyInputSchema.safeParse(raw)
|
|
446
|
+
if (!parsed.success) {
|
|
447
|
+
return { ok: false, error: validationError('declare_policy', parsed.error.issues) }
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
// Rebuilt field-by-field rather than passed through: the schema's
|
|
451
|
+
// optionals are `T | undefined` and `PolicyDeclaration`'s are absent-or-T,
|
|
452
|
+
// which `exactOptionalPropertyTypes` treats as different.
|
|
453
|
+
const d = parsed.data
|
|
454
|
+
const { predicate, warnings } = declarePredicate({
|
|
455
|
+
fn: d.fn,
|
|
456
|
+
...(d.contract !== undefined ? { contract: d.contract } : {}),
|
|
457
|
+
...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
|
|
458
|
+
...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
|
|
459
|
+
...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
|
|
460
|
+
...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
|
|
461
|
+
...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
|
|
462
|
+
...(d.minOutputRatio !== undefined ? { minOutputRatio: d.minOutputRatio } : {}),
|
|
463
|
+
})
|
|
464
|
+
const { encodedPredicate, predicateHash } = encodePredicate(predicate)
|
|
465
|
+
return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } }
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return toolFailure('declare_policy', e)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
387
471
|
/** `verify_policy` body - the permit case plus a generated deny case per
|
|
388
472
|
* dimension.
|
|
389
473
|
*
|
|
@@ -483,6 +567,45 @@ export async function runGetInterpreterInfo(
|
|
|
483
567
|
* network, falling back to the pinned RPC for the network. The caller
|
|
484
568
|
* has already been gated against the pinned URL elsewhere, so the
|
|
485
569
|
* fallback here only ever picks from a finite, audited pair. */
|
|
570
|
+
/** The account's other context rules, or `null` when they could not be
|
|
571
|
+
* established completely.
|
|
572
|
+
*
|
|
573
|
+
* Caller-supplied `existingRules` win: they let the scan run offline, and a
|
|
574
|
+
* caller who passes them has said what to compare against. Otherwise the
|
|
575
|
+
* account is read over RPC.
|
|
576
|
+
*
|
|
577
|
+
* Every failure path returns `null` rather than a short list. A read that
|
|
578
|
+
* threw, or one that stopped before accounting for every live rule, has not
|
|
579
|
+
* ruled anything out - and reporting `[]` there would turn "we could not
|
|
580
|
+
* check" into "there is nothing to worry about". */
|
|
581
|
+
async function resolveExistingRules(
|
|
582
|
+
input: InstallPolicyInput,
|
|
583
|
+
network: Network,
|
|
584
|
+
interpreterAddress: string
|
|
585
|
+
): Promise<ObservedRule[] | null> {
|
|
586
|
+
if (input.existingRules !== undefined) {
|
|
587
|
+
// The schema types `predicate` loosely (it is the shared
|
|
588
|
+
// PredicateNodeSchema); the shape is already validated.
|
|
589
|
+
return input.existingRules as ObservedRule[]
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const url = input.rpcUrl ?? RPC_URL_BY_NETWORK[network]
|
|
593
|
+
const server = new rpc.Server(url, { allowHttp: false })
|
|
594
|
+
const collected = await collectObservedRules({
|
|
595
|
+
reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
|
|
596
|
+
smartAccount: input.smartAccount,
|
|
597
|
+
interpreterAddress,
|
|
598
|
+
})
|
|
599
|
+
if (collected.incomplete) return null
|
|
600
|
+
return collected.rules
|
|
601
|
+
} catch {
|
|
602
|
+
// The install itself is unaffected: the scan is advisory, so a failed
|
|
603
|
+
// read must not block a policy the user asked for. It just cannot be
|
|
604
|
+
// reported as a clean scan.
|
|
605
|
+
return null
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
486
609
|
function buildRpcClientFromInput(
|
|
487
610
|
urlOverride: string | undefined,
|
|
488
611
|
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,
|
|
@@ -281,6 +306,29 @@ export type VerifyPolicyInput = z.infer<typeof VerifyPolicyInputSchema>
|
|
|
281
306
|
const MAX_SIGNERS_PER_RULE = 15
|
|
282
307
|
const MAX_POLICIES_PER_RULE = 5
|
|
283
308
|
|
|
309
|
+
const SignerDraftSchema = z.discriminatedUnion('kind', [
|
|
310
|
+
z.object({ kind: z.literal('delegated'), address: z.string() }),
|
|
311
|
+
z.object({ kind: z.literal('external'), verifier: z.string(), keyBytes: z.string() }),
|
|
312
|
+
])
|
|
313
|
+
|
|
314
|
+
const ContextTypeSchema = z.discriminatedUnion('kind', [
|
|
315
|
+
z.object({ kind: z.literal('default') }),
|
|
316
|
+
z.object({ kind: z.literal('call_contract'), contract: z.string() }),
|
|
317
|
+
z.object({ kind: z.literal('create_contract'), wasmHash: z.string() }),
|
|
318
|
+
])
|
|
319
|
+
|
|
320
|
+
/** A rule ALREADY on the account, as the caller observed it. Supplying these
|
|
321
|
+
* turns on the cross-rule authority scan: a signer belonging to several rules
|
|
322
|
+
* picks which one applies, so a predicate only constrains a key when the
|
|
323
|
+
* policed rule is the only rule that key is on. */
|
|
324
|
+
export const ObservedRuleSchema = z.object({
|
|
325
|
+
id: z.number().int().nonnegative(),
|
|
326
|
+
contextType: ContextTypeSchema,
|
|
327
|
+
signers: z.array(SignerDraftSchema),
|
|
328
|
+
policyAddresses: z.array(z.string()),
|
|
329
|
+
predicate: PredicateNodeSchema.optional(),
|
|
330
|
+
})
|
|
331
|
+
|
|
284
332
|
const ContextRuleDraftSchema = z
|
|
285
333
|
.object({
|
|
286
334
|
contextRuleType: z.discriminatedUnion('kind', [
|
|
@@ -325,12 +373,12 @@ const ContextRuleDraftSchema = z
|
|
|
325
373
|
/** Pinned interpreter address (testnet).
|
|
326
374
|
* Single source for the MCP layer; do not embed elsewhere. */
|
|
327
375
|
export const PINNED_INTERPRETER_TESTNET_ADDRESS =
|
|
328
|
-
'
|
|
376
|
+
'CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5'
|
|
329
377
|
|
|
330
|
-
/** 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
|
|
331
379
|
* interpreter IS the binary exercised on testnet - both instances were created
|
|
332
380
|
* from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
|
|
333
|
-
* both were read back with `grammar_version()` returning
|
|
381
|
+
* both were read back with `grammar_version()` returning 4. The address differs
|
|
334
382
|
* because instance ids are network-scoped. UNAUDITED at the time of writing.
|
|
335
383
|
*
|
|
336
384
|
* These four constants move together or not at all. The grammar version and
|
|
@@ -338,15 +386,15 @@ export const PINNED_INTERPRETER_TESTNET_ADDRESS =
|
|
|
338
386
|
* alone would have the builder emit a version the other network refuses - with
|
|
339
387
|
* a green test run, since `grammar-version-parity.test.ts` would then pass. */
|
|
340
388
|
export const PINNED_INTERPRETER_MAINNET_ADDRESS =
|
|
341
|
-
'
|
|
389
|
+
'CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52'
|
|
342
390
|
|
|
343
391
|
/** Pinned interpreter wasm sha256 (hex). */
|
|
344
392
|
export const PINNED_INTERPRETER_WASM_SHA256 =
|
|
345
|
-
'
|
|
393
|
+
'b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae'
|
|
346
394
|
|
|
347
395
|
/** The grammar version the interpreter enforces (matches SELF_VERSION in
|
|
348
396
|
* contracts/policy-interpreter/src/version.rs). */
|
|
349
|
-
export const PINNED_INTERPRETER_GRAMMAR_VERSION =
|
|
397
|
+
export const PINNED_INTERPRETER_GRAMMAR_VERSION = 4
|
|
350
398
|
|
|
351
399
|
/** Default Soroban RPC for the install / revoke / info tools. The recorder
|
|
352
400
|
* keeps its own copy in record/rpc.ts because it hands back a fetcher rather
|
|
@@ -391,8 +439,54 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
|
|
|
391
439
|
const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
|
|
392
440
|
const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
|
|
393
441
|
|
|
442
|
+
// ===== declare_policy =====
|
|
443
|
+
//
|
|
444
|
+
// The declarative front-end: the constraint stated outright, with no
|
|
445
|
+
// transaction to decode. Deliberately NOT a revival of the removed
|
|
446
|
+
// `MandateSpec` - that carried a rolling `spendingLimit` the interpreter
|
|
447
|
+
// cannot evaluate and an `approvalThreshold` needing OZ primitives nobody
|
|
448
|
+
// deployed. Only fields grammar 3 can actually enforce appear here.
|
|
449
|
+
export const DeclarePolicyInputSchema = z
|
|
450
|
+
.object({
|
|
451
|
+
fn: z.string().min(1, 'fn must name the method to pin'),
|
|
452
|
+
contract: z
|
|
453
|
+
.string()
|
|
454
|
+
.regex(STELLAR_CONTRACT_ADDRESS, 'contract must be a Stellar contract address (C...)')
|
|
455
|
+
.optional(),
|
|
456
|
+
/** Smallest unit, unsigned decimal STRING - an i128 is wider than
|
|
457
|
+
* Number.MAX_SAFE_INTEGER, so a number here would silently round. */
|
|
458
|
+
maxAmount: z
|
|
459
|
+
.string()
|
|
460
|
+
.regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
|
|
461
|
+
.optional(),
|
|
462
|
+
amountArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
|
|
463
|
+
recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
|
|
464
|
+
recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
|
|
465
|
+
allowZeroCap: z.boolean().optional(),
|
|
466
|
+
/** Minimum output as a ratio of the call's own input. num/den are decimal
|
|
467
|
+
* STRINGS for the same reason maxAmount is: an i128 ratio does not
|
|
468
|
+
* survive a JS number. */
|
|
469
|
+
minOutputRatio: z
|
|
470
|
+
.object({
|
|
471
|
+
num: z.string().regex(/^[0-9]+$/, 'num must be an unsigned integer'),
|
|
472
|
+
den: z.string().regex(/^[0-9]+$/, 'den must be an unsigned integer'),
|
|
473
|
+
inputArgIndex: z.number().int().nonnegative().max(U32_MAX),
|
|
474
|
+
outputArgIndex: z.number().int().nonnegative().max(U32_MAX),
|
|
475
|
+
})
|
|
476
|
+
.strict()
|
|
477
|
+
.optional(),
|
|
478
|
+
})
|
|
479
|
+
.strict()
|
|
480
|
+
export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>
|
|
481
|
+
|
|
394
482
|
export const InstallPolicyInputSchema = z
|
|
395
483
|
.object({
|
|
484
|
+
/** Rules already on the account. Supplying them turns on the cross-rule
|
|
485
|
+
* authority scan, which reports every existing rule a signer of this
|
|
486
|
+
* install could name INSTEAD - including an unpoliced one, against which
|
|
487
|
+
* the predicate never runs. Absent means the scan is skipped, and the
|
|
488
|
+
* result says so rather than reporting "no overlaps found". */
|
|
489
|
+
existingRules: z.array(ObservedRuleSchema).optional(),
|
|
396
490
|
/** The smart account contract address (C...) that will receive the rule. */
|
|
397
491
|
smartAccount: z
|
|
398
492
|
.string()
|
|
@@ -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
|
}
|