@crediolabs/policy-synth 0.1.11 → 0.1.13
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 +21 -0
- package/dist/adapters/oz/adapter.js +4 -0
- package/dist/install/build-install-policy.d.ts +218 -0
- package/dist/install/build-install-policy.js +427 -0
- package/dist/install/get-interpreter-info.d.ts +29 -0
- package/dist/install/get-interpreter-info.js +41 -0
- package/dist/ir/types.d.ts +19 -0
- package/dist/predicate/index.d.ts +1 -1
- package/dist/predicate/index.js +1 -1
- package/dist/review-card/builder.js +32 -0
- package/dist/review-card/cross-check.js +20 -0
- package/dist/run/index.d.ts +49 -4
- package/dist/run/index.js +324 -4
- package/dist/run/schemas.d.ts +1500 -4
- package/dist/run/schemas.js +288 -0
- package/dist/synth/compose-from-recording.d.ts +14 -0
- package/dist/synth/compose-from-recording.js +41 -2
- package/dist-cjs/adapters/interpreter/adapter.js +21 -0
- package/dist-cjs/adapters/oz/adapter.js +4 -0
- package/dist-cjs/install/build-install-policy.d.ts +218 -0
- package/dist-cjs/install/build-install-policy.js +431 -0
- package/dist-cjs/install/get-interpreter-info.d.ts +29 -0
- package/dist-cjs/install/get-interpreter-info.js +44 -0
- package/dist-cjs/ir/types.d.ts +19 -0
- package/dist-cjs/predicate/index.d.ts +1 -1
- package/dist-cjs/predicate/index.js +3 -3
- package/dist-cjs/review-card/builder.js +32 -0
- package/dist-cjs/review-card/cross-check.js +20 -0
- package/dist-cjs/run/index.d.ts +49 -4
- package/dist-cjs/run/index.js +339 -3
- package/dist-cjs/run/schemas.d.ts +1500 -4
- package/dist-cjs/run/schemas.js +289 -1
- package/dist-cjs/synth/compose-from-recording.d.ts +14 -0
- package/dist-cjs/synth/compose-from-recording.js +41 -2
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +21 -0
- package/src/adapters/oz/adapter.ts +4 -0
- package/src/install/build-install-policy.ts +686 -0
- package/src/install/get-interpreter-info.ts +68 -0
- package/src/ir/types.ts +19 -0
- package/src/predicate/index.ts +1 -1
- package/src/review-card/builder.ts +27 -0
- package/src/review-card/cross-check.ts +25 -1
- package/src/run/index.ts +405 -5
- package/src/run/schemas.ts +326 -0
- package/src/synth/compose-from-recording.ts +55 -0
- package/src/synth/evaluate.ts +0 -1
- package/src/synth/permit-context.ts +0 -1
package/dist/run/schemas.js
CHANGED
|
@@ -219,6 +219,294 @@ export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
|
219
219
|
SynthesizePolicyMandateInputSchema,
|
|
220
220
|
SynthesizePolicyRecordingInputSchema,
|
|
221
221
|
]);
|
|
222
|
+
// ===== PredicateNode / PredicateLeaf =====
|
|
223
|
+
//
|
|
224
|
+
// Hand-written zod mirrors of the predicate grammar in types.ts:132-180.
|
|
225
|
+
// Recursive (`and`/`or`/`not` nest), so the schemas use the same
|
|
226
|
+
// `z.lazy` + explicit `z.ZodType<unknown>` annotation that ScValSchema
|
|
227
|
+
// already uses - without the annotation TS's circular type inference
|
|
228
|
+
// breaks the recursive reference. i128-shaped numerics (`num`, `den`)
|
|
229
|
+
// are decimal STRINGS, never JS numbers, matching the on-chain i128
|
|
230
|
+
// convention used everywhere else in this file. The grammar is the
|
|
231
|
+
// single source of truth (types.ts); a drift test in this package
|
|
232
|
+
// asserts the two stay in step.
|
|
233
|
+
/** PredicateLeaf mirrors the core `PredicateLeaf` union. Every variant
|
|
234
|
+
* is a `kind` discriminator; literals carry their primitive value inline.
|
|
235
|
+
* `literal_vec.elements` is recursive (a vec may contain literals that
|
|
236
|
+
* themselves nest) so the leaf schema is lazily self-referential. */
|
|
237
|
+
export const PredicateLeafSchema = z.lazy(() => z.union([
|
|
238
|
+
z.object({ kind: z.literal('call_contract') }),
|
|
239
|
+
z.object({ kind: z.literal('call_fn') }),
|
|
240
|
+
z.object({ kind: z.literal('call_arg'), index: z.number().int().nonnegative() }),
|
|
241
|
+
z.object({ kind: z.literal('call_arg_len'), index: z.number().int().nonnegative() }),
|
|
242
|
+
z.object({
|
|
243
|
+
kind: z.literal('call_arg_field'),
|
|
244
|
+
index: z.number().int().nonnegative(),
|
|
245
|
+
element: z.number().int().nonnegative(),
|
|
246
|
+
field: z.string(),
|
|
247
|
+
}),
|
|
248
|
+
// num/den are i128 on chain; JSON numbers lose precision past 2^53,
|
|
249
|
+
// so the wire is a decimal string. The contract refuses `den == 0` and
|
|
250
|
+
// `num <= 0` / `den <= 0` at install (types.ts:162-163 + the install
|
|
251
|
+
// gate); mirroring that bound here closes the gap where simulate /
|
|
252
|
+
// verify green-light a policy the install gate will reject. The
|
|
253
|
+
// positive-only regex is the cheapest unambiguous check.
|
|
254
|
+
z.object({
|
|
255
|
+
kind: z.literal('call_arg_scaled'),
|
|
256
|
+
index: z.number().int().nonnegative(),
|
|
257
|
+
num: z.string().regex(/^[1-9][0-9]*$/),
|
|
258
|
+
den: z.string().regex(/^[1-9][0-9]*$/),
|
|
259
|
+
}),
|
|
260
|
+
z.object({ kind: z.literal('amount'), token: z.string() }),
|
|
261
|
+
z.object({
|
|
262
|
+
kind: z.literal('window_spent'),
|
|
263
|
+
token: z.string(),
|
|
264
|
+
windowSeconds: z.number().int().positive(),
|
|
265
|
+
}),
|
|
266
|
+
z.object({ kind: z.literal('now') }),
|
|
267
|
+
z.object({ kind: z.literal('valid_until') }),
|
|
268
|
+
z.object({
|
|
269
|
+
kind: z.literal('invocation_count_in_window'),
|
|
270
|
+
windowSecs: z.number().int().positive(),
|
|
271
|
+
}),
|
|
272
|
+
z.object({ kind: z.literal('oracle_price'), asset: z.string() }),
|
|
273
|
+
z.object({
|
|
274
|
+
kind: z.literal('oracle_threshold'),
|
|
275
|
+
value: z.string().regex(/^-?[0-9]+$/),
|
|
276
|
+
decimals: z.number().int().nonnegative(),
|
|
277
|
+
}),
|
|
278
|
+
z.object({ kind: z.literal('literal_address'), value: z.string() }),
|
|
279
|
+
z.object({ kind: z.literal('literal_i128'), value: z.string().regex(/^-?[0-9]+$/) }),
|
|
280
|
+
z.object({ kind: z.literal('literal_symbol'), value: z.string() }),
|
|
281
|
+
// `literal_u32` is u32 on chain. A negative value would wrap, and a
|
|
282
|
+
// value above U32_MAX would be refused at the encode boundary
|
|
283
|
+
// anyway. Bound at the schema so the wire cannot carry a value the
|
|
284
|
+
// contract will reject.
|
|
285
|
+
z.object({
|
|
286
|
+
kind: z.literal('literal_u32'),
|
|
287
|
+
value: z.number().int().nonnegative().max(U32_MAX),
|
|
288
|
+
}),
|
|
289
|
+
z.object({ kind: z.literal('literal_u64'), value: z.string().regex(/^[0-9]+$/) }),
|
|
290
|
+
// `literal_bytes` is hex on chain. `Buffer.from(value, 'hex')` silently
|
|
291
|
+
// drops non-hex chars AND yields an empty buffer when the whole input
|
|
292
|
+
// is non-hex - so a user passing 'zzzz' would get a predicate that
|
|
293
|
+
// compares against empty bytes instead of being rejected. The
|
|
294
|
+
// strict even-length hex regex closes that gap at the boundary.
|
|
295
|
+
z.object({
|
|
296
|
+
kind: z.literal('literal_bytes'),
|
|
297
|
+
value: z
|
|
298
|
+
.string()
|
|
299
|
+
.regex(/^[0-9a-fA-F]*$/)
|
|
300
|
+
.refine((v) => v.length % 2 === 0, 'must be even-length hex'),
|
|
301
|
+
}),
|
|
302
|
+
// elements are themselves PredicateLeaf; lazy to break the cycle.
|
|
303
|
+
z.object({ kind: z.literal('literal_vec'), elements: z.array(PredicateLeafSchema) }),
|
|
304
|
+
]));
|
|
305
|
+
/** PredicateNode mirrors the core `PredicateNode` union. Recursive through
|
|
306
|
+
* `and`/`or`/`not`; the lazy + annotation pattern keeps the recursion
|
|
307
|
+
* type-safe. */
|
|
308
|
+
export const PredicateNodeSchema = z.lazy(() => z.union([
|
|
309
|
+
z.object({ op: z.literal('and'), children: z.array(PredicateNodeSchema) }),
|
|
310
|
+
z.object({ op: z.literal('or'), children: z.array(PredicateNodeSchema) }),
|
|
311
|
+
z.object({ op: z.literal('not'), child: PredicateNodeSchema }),
|
|
312
|
+
z.object({
|
|
313
|
+
op: z.literal('eq'),
|
|
314
|
+
left: PredicateLeafSchema,
|
|
315
|
+
right: PredicateLeafSchema,
|
|
316
|
+
}),
|
|
317
|
+
z.object({
|
|
318
|
+
op: z.enum(['lt', 'lte', 'gt', 'gte']),
|
|
319
|
+
left: PredicateLeafSchema,
|
|
320
|
+
right: PredicateLeafSchema,
|
|
321
|
+
}),
|
|
322
|
+
z.object({
|
|
323
|
+
op: z.literal('in'),
|
|
324
|
+
needle: PredicateLeafSchema,
|
|
325
|
+
haystack: z.array(PredicateLeafSchema),
|
|
326
|
+
}),
|
|
327
|
+
]));
|
|
328
|
+
// ===== simulate_policy / verify_policy =====
|
|
329
|
+
//
|
|
330
|
+
// The oracle fixture shape mirrors `SimulateOptions` / `VerifyOptions`
|
|
331
|
+
// in packages/policy-synth/src/verify/{simulate,verify}.ts - the same
|
|
332
|
+
// sorted set of error enum values is used by both engines. u32
|
|
333
|
+
// `validUntilLedger` is bounded at the boundary so a hand-crafted
|
|
334
|
+
// payload cannot leak a u32 overflow into the engine's permit context.
|
|
335
|
+
/** Oracle price fixture - mirror of the entry type in `SimulateOptions` /
|
|
336
|
+
* `VerifyOptions`. Discriminated by presence of `error` vs `price`. */
|
|
337
|
+
export const OraclePriceFixtureSchema = z.union([
|
|
338
|
+
z.object({
|
|
339
|
+
price: z.string().regex(/^[0-9]+$/),
|
|
340
|
+
timestampSeconds: z.number().int().nonnegative(),
|
|
341
|
+
}),
|
|
342
|
+
z.object({
|
|
343
|
+
error: z.enum(['stale', 'missing', 'deviation', 'paused', 'decimals', 'fingerprint']),
|
|
344
|
+
}),
|
|
345
|
+
]);
|
|
346
|
+
export const SimulatePolicyInputSchema = z.object({
|
|
347
|
+
// simulatePolicy accepts a null predicate (OZ-only policy); verifyPolicy
|
|
348
|
+
// does not. The asymmetry is mirrored at the schema boundary.
|
|
349
|
+
predicate: PredicateNodeSchema.nullable(),
|
|
350
|
+
permitTx: RecordedTransactionSchema,
|
|
351
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
|
|
352
|
+
oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
|
|
353
|
+
});
|
|
354
|
+
export const VerifyPolicyInputSchema = z.object({
|
|
355
|
+
// verifyPolicy requires a non-null predicate; the engine refuses
|
|
356
|
+
// `null` outright. The schema mirrors that contract.
|
|
357
|
+
predicate: PredicateNodeSchema,
|
|
358
|
+
permitTx: RecordedTransactionSchema,
|
|
359
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
|
|
360
|
+
oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
|
|
361
|
+
});
|
|
362
|
+
// ===== install_policy / revoke_policy / get_interpreter_info =====
|
|
363
|
+
//
|
|
364
|
+
// The install/revoke pair drives the OZ smart-account admin flow. Both surface
|
|
365
|
+
// unsigned Soroban transaction XDRs (base64) and the tool body NEVER signs:
|
|
366
|
+
// the wallet signature IS the user-confirmation step. See `run/index.ts` for
|
|
367
|
+
// the rationale (no `action_id` two-call pair; the MCP server is stateless).
|
|
368
|
+
//
|
|
369
|
+
// `get_interpreter_info` is a read-only fingerprint lookup. It returns the
|
|
370
|
+
// pinned address + grammar version + wasm hash AND, when requested, runs an
|
|
371
|
+
// optional `grammar_version()` RPC call to check whether the deployed contract
|
|
372
|
+
// matches the pin. A mismatch is worth MORE than a fabricated audit field.
|
|
373
|
+
//
|
|
374
|
+
// All three input schemas live BELOW the MandateSpecSchemaForRule declaration
|
|
375
|
+
// so the const reference there is not in the temporal dead zone (no JS hoisting
|
|
376
|
+
// for `const`).
|
|
377
|
+
/** Minimal rule-draft schema for the install input. Mirrors ContextRuleDraft
|
|
378
|
+
* from the core (`types.ts`); we keep this hand-rolled + flat rather than
|
|
379
|
+
* importing the recursive discriminated union because the wire shape is
|
|
380
|
+
* fixed (the user supplies one rule, not a nested AST). Declared BEFORE
|
|
381
|
+
* InstallPolicyInputSchema below so the const reference there is not in
|
|
382
|
+
* the temporal dead zone (no JS hoisting for `const`). */
|
|
383
|
+
const MAX_SIGNERS_PER_RULE = 15;
|
|
384
|
+
const MAX_POLICIES_PER_RULE = 5;
|
|
385
|
+
const MandateSpecSchemaForRule = z
|
|
386
|
+
.object({
|
|
387
|
+
contextRuleType: z.discriminatedUnion('kind', [
|
|
388
|
+
z.object({ kind: z.literal('default') }),
|
|
389
|
+
z.object({ kind: z.literal('call_contract'), contract: z.string() }),
|
|
390
|
+
z.object({ kind: z.literal('create_contract'), wasmHash: z.string() }),
|
|
391
|
+
]),
|
|
392
|
+
name: z.string().min(1),
|
|
393
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).nullable(),
|
|
394
|
+
signers: z
|
|
395
|
+
.array(z.discriminatedUnion('kind', [
|
|
396
|
+
z.object({ kind: z.literal('delegated'), address: z.string() }),
|
|
397
|
+
z.object({
|
|
398
|
+
kind: z.literal('external'),
|
|
399
|
+
verifier: z.string(),
|
|
400
|
+
keyBytes: z.string(),
|
|
401
|
+
}),
|
|
402
|
+
]))
|
|
403
|
+
.max(MAX_SIGNERS_PER_RULE),
|
|
404
|
+
policies: z
|
|
405
|
+
.array(z.discriminatedUnion('kind', [
|
|
406
|
+
z.object({
|
|
407
|
+
kind: z.literal('interpreter'),
|
|
408
|
+
interpreterAddress: z.string(),
|
|
409
|
+
predicateBlobBase64: z.string().min(1),
|
|
410
|
+
}),
|
|
411
|
+
z.object({
|
|
412
|
+
kind: z.literal('oz_builtin'),
|
|
413
|
+
primitive: z.object({
|
|
414
|
+
primitive: z.enum(['spending_limit', 'simple_threshold', 'weighted_threshold']),
|
|
415
|
+
params: z.record(z.unknown()),
|
|
416
|
+
}),
|
|
417
|
+
instanceAddress: z.string(),
|
|
418
|
+
}),
|
|
419
|
+
]))
|
|
420
|
+
.max(MAX_POLICIES_PER_RULE),
|
|
421
|
+
})
|
|
422
|
+
.passthrough()
|
|
423
|
+
.refine((v) => !(v.signers.length === 0 && v.policies.length === 0), 'a rule with no signers and no policies is refused at install (NoSignersAndPolicies)');
|
|
424
|
+
// install/revoke/get_info schemas live HERE (after the helper) so the const
|
|
425
|
+
// reference resolves at module init. They are the new tools on top of the
|
|
426
|
+
// existing four.
|
|
427
|
+
/** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:156-157.
|
|
428
|
+
* Single source for the MCP layer; do not embed elsewhere. */
|
|
429
|
+
export const PINNED_INTERPRETER_TESTNET_ADDRESS = 'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP';
|
|
430
|
+
/** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
|
|
431
|
+
export const PINNED_INTERPRETER_WASM_SHA256 = '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443';
|
|
432
|
+
/** The grammar version the interpreter enforces (matches SELF_VERSION in
|
|
433
|
+
* packages/policy-interpreter/src/version.rs). */
|
|
434
|
+
export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1;
|
|
435
|
+
/** Default Soroban RPC for the install / revoke / info tools. The recorder
|
|
436
|
+
* keeps its own copy in record/rpc.ts because it hands back a fetcher rather
|
|
437
|
+
* than a Server; the two are deliberately different surfaces, so this is
|
|
438
|
+
* pinned beside the addresses it is used with rather than shared. */
|
|
439
|
+
export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org';
|
|
440
|
+
/** Soroban `valid_until` window (ledgers) added to the latest ledger when
|
|
441
|
+
* building the auth entry. ~25 minutes at 5s/ledger. */
|
|
442
|
+
export const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
|
|
443
|
+
/** Stellar network passphrases. Pinned here so the XDR envelope uses the
|
|
444
|
+
* matching passphrase when the wallet signs (a mismatch yields invalid
|
|
445
|
+
* hashes). */
|
|
446
|
+
export const NETWORK_PASSPHRASES = {
|
|
447
|
+
testnet: 'Test SDF Network ; September 2015',
|
|
448
|
+
mainnet: 'Public Global Stellar Network ; September 2015',
|
|
449
|
+
};
|
|
450
|
+
export const InstallPolicyInputSchema = z
|
|
451
|
+
.object({
|
|
452
|
+
/** The smart account contract address (C...) that will receive the rule. */
|
|
453
|
+
smartAccount: z.string(),
|
|
454
|
+
/** The signer that authorises the install (G... wallet). Used only for
|
|
455
|
+
* sequence number + auth nonce simulation; never persisted, never signed. */
|
|
456
|
+
sourceAccount: z.string(),
|
|
457
|
+
/** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
|
|
458
|
+
rule: MandateSpecSchemaForRule,
|
|
459
|
+
/** Per-rule install nonce; 1 for a fresh install. */
|
|
460
|
+
installNonce: z.number().int().positive(),
|
|
461
|
+
/** Optional RPC URL override. Defaults to the public testnet endpoint
|
|
462
|
+
* (`TESTNET_RPC_URL`); the override is refused unless
|
|
463
|
+
* `allowUnpinnedRpcUrl: true`, because the auth nonce and
|
|
464
|
+
* rootInvocation in the response come from whichever RPC answered. */
|
|
465
|
+
rpcUrl: z.string().url().optional(),
|
|
466
|
+
/** Opt-in to using a non-pinned RPC URL. Without this flag the tool
|
|
467
|
+
* refuses any `rpcUrl` other than `TESTNET_RPC_URL` because the
|
|
468
|
+
* caller's auth-digest binds to whatever the RPC returned. */
|
|
469
|
+
allowUnpinnedRpcUrl: z.boolean().optional(),
|
|
470
|
+
/** Opt-in to pointing the rule's interpreter policy at any address
|
|
471
|
+
* other than the pinned testnet interpreter. Default-deny: a caller
|
|
472
|
+
* that controls the interpreter can permit everything. */
|
|
473
|
+
allowUnpinnedInterpreter: z.boolean().optional(),
|
|
474
|
+
/** Base fee in stroops; defaults to BASE_FEE (100). */
|
|
475
|
+
baseFee: z.number().int().positive().optional(),
|
|
476
|
+
})
|
|
477
|
+
.refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
|
|
478
|
+
message: 'smartAccount and sourceAccount are required',
|
|
479
|
+
});
|
|
480
|
+
export const RevokePolicyInputSchema = z
|
|
481
|
+
.object({
|
|
482
|
+
/** The smart account contract address (C...). */
|
|
483
|
+
smartAccount: z.string(),
|
|
484
|
+
/** The wallet that will sign the removal. The ACCOUNT decides whether it
|
|
485
|
+
* accepts that signer; this schema does not assert a rule it cannot
|
|
486
|
+
* verify, since the account's source is not in this repo. Proven on
|
|
487
|
+
* testnet: the account's deployer can revoke. */
|
|
488
|
+
sourceAccount: z.string(),
|
|
489
|
+
/** The rule id to remove from the smart account + interpreter. */
|
|
490
|
+
ruleId: z.number().int().nonnegative(),
|
|
491
|
+
/** Optional RPC URL override; same pin as install. */
|
|
492
|
+
rpcUrl: z.string().url().optional(),
|
|
493
|
+
/** Opt-in to using a non-pinned RPC URL. Same semantics as install. */
|
|
494
|
+
allowUnpinnedRpcUrl: z.boolean().optional(),
|
|
495
|
+
/** Base fee in stroops; defaults to BASE_FEE (100). */
|
|
496
|
+
baseFee: z.number().int().positive().optional(),
|
|
497
|
+
})
|
|
498
|
+
.refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
|
|
499
|
+
message: 'smartAccount and sourceAccount are required',
|
|
500
|
+
});
|
|
501
|
+
export const GetInterpreterInfoInputSchema = z.object({
|
|
502
|
+
/** Network to query. The pin differs per network; defaults to testnet. */
|
|
503
|
+
network: NetworkSchema.optional(),
|
|
504
|
+
/** When true, perform an optional live `grammar_version()` RPC call to
|
|
505
|
+
* verify the deployed contract matches the pin. */
|
|
506
|
+
verifyLive: z.boolean().optional(),
|
|
507
|
+
/** Optional RPC URL override. */
|
|
508
|
+
rpcUrl: z.string().url().optional(),
|
|
509
|
+
});
|
|
222
510
|
// ===== Error envelope (canonical) =====
|
|
223
511
|
//
|
|
224
512
|
// Mirrors ToolError from packages/policy-synth/src/errors.ts. We use a
|
|
@@ -31,6 +31,20 @@ export interface ComposeUserResponses {
|
|
|
31
31
|
* `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
|
|
32
32
|
* entries on the same asset emit multiple leaves. */
|
|
33
33
|
oraclePriceBound?: OraclePriceBound[];
|
|
34
|
+
/** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
|
|
35
|
+
* `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
|
|
36
|
+
* slippage floor bounding the output arg against the input arg of the same
|
|
37
|
+
* call.
|
|
38
|
+
*
|
|
39
|
+
* REQUIRED to be supplied by the caller: it is never derived from the
|
|
40
|
+
* recording. The recorded in/out pair is a price at one moment, and
|
|
41
|
+
* freezing it as policy would deny ordinary trades as soon as the rate
|
|
42
|
+
* moves. Absent, no floor is emitted and the existing unbounded-output
|
|
43
|
+
* warning stands. */
|
|
44
|
+
swapMinOutRatio?: {
|
|
45
|
+
num: string;
|
|
46
|
+
den: string;
|
|
47
|
+
};
|
|
34
48
|
/** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
|
|
35
49
|
* swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
|
|
36
50
|
* pin. Absent -> the recipient is pinned to the recorded value (mirroring
|
|
@@ -197,7 +197,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
197
197
|
// the window_spent path above already consumed the limit, so the per-call
|
|
198
198
|
// arg cap is skipped to avoid binding one limit to two different semantics.
|
|
199
199
|
const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
|
|
200
|
-
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled);
|
|
200
|
+
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
|
|
201
201
|
}
|
|
202
202
|
// `scope.method` is carried on BOTH IRs so each adapter produces a
|
|
203
203
|
// self-consistent rule. The interpreter adapter lowers scope.method into a
|
|
@@ -239,7 +239,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
239
239
|
* SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
|
|
240
240
|
* (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
|
|
241
241
|
* call_arg[2]). */
|
|
242
|
-
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled) {
|
|
242
|
+
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
|
|
243
243
|
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
244
244
|
// a single-element allowlist; the interpreter adapter lowers it to `in`.
|
|
245
245
|
// When interpreter is not enabled, route to OZ so the caller sees today's
|
|
@@ -447,6 +447,30 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
447
447
|
ozConstraints.push(cond);
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
+
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
451
|
+
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
452
|
+
// which is the case the unbounded-swap warning above describes.
|
|
453
|
+
const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn);
|
|
454
|
+
const minOutRatio = swapMinOutRatio;
|
|
455
|
+
if (minOutRatio !== undefined &&
|
|
456
|
+
inputArgIndex !== undefined &&
|
|
457
|
+
outMinArgIndex !== undefined &&
|
|
458
|
+
inputAmountArg &&
|
|
459
|
+
inputAmountArg.type === 'i128') {
|
|
460
|
+
const floor = {
|
|
461
|
+
op: 'slippage_floor',
|
|
462
|
+
outArgIndex: outMinArgIndex,
|
|
463
|
+
inArgIndex: inputArgIndex,
|
|
464
|
+
num: minOutRatio.num,
|
|
465
|
+
den: minOutRatio.den,
|
|
466
|
+
};
|
|
467
|
+
if (interpreterEnabled) {
|
|
468
|
+
interpreterConstraints.push(floor);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
ozConstraints.push(floor);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
450
474
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
451
475
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
452
476
|
// arg. When absent, PIN the recipient to the recorded value - mirroring the
|
|
@@ -491,6 +515,21 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
491
515
|
* the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
|
|
492
516
|
* input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
|
|
493
517
|
* function has no positional input-amount argument -> undefined (no cap bound). */
|
|
518
|
+
/** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
|
|
519
|
+
*
|
|
520
|
+
* Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
|
|
521
|
+
* fixes the output and varies the input, so its output needs no floor (its
|
|
522
|
+
* arg[1] is `amount_in_max`, already bounded by the input cap). Returning
|
|
523
|
+
* undefined there keeps a floor from being pinned to the wrong argument. */
|
|
524
|
+
function soroswapMinOutArgIndex(fn) {
|
|
525
|
+
switch (fn) {
|
|
526
|
+
case 'swap_exact_tokens_for_tokens':
|
|
527
|
+
case 'swap_exact_in_for_tokens':
|
|
528
|
+
return 1;
|
|
529
|
+
default:
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
494
533
|
function soroswapInputAmountArgIndex(fn) {
|
|
495
534
|
switch (fn) {
|
|
496
535
|
case 'swap_exact_tokens_for_tokens':
|
|
@@ -223,6 +223,9 @@ function lowerRule(rule, config) {
|
|
|
223
223
|
* by `lowerRule` to populate `uncovered` before lowering. */
|
|
224
224
|
function unsupportedConstruct(cond) {
|
|
225
225
|
switch (cond.op) {
|
|
226
|
+
// Expressible: `call_arg_scaled` is exactly this construct.
|
|
227
|
+
case 'slippage_floor':
|
|
228
|
+
return null;
|
|
226
229
|
case 'in':
|
|
227
230
|
// The oracle position rule is a hard error and must be raised before a
|
|
228
231
|
// construct can be written off as uncovered - otherwise a subtree that
|
|
@@ -286,6 +289,20 @@ function unsourceableSelector(s) {
|
|
|
286
289
|
* address -> SCOPE_SELF_CALL). */
|
|
287
290
|
function lowerCondition(cond, config) {
|
|
288
291
|
switch (cond.op) {
|
|
292
|
+
// `out >= in * num/den`. The contract refuses den == 0 or a non-positive
|
|
293
|
+
// ratio at install, so a malformed floor fails loudly rather than
|
|
294
|
+
// silently inverting the comparison.
|
|
295
|
+
case 'slippage_floor':
|
|
296
|
+
return {
|
|
297
|
+
op: 'gte',
|
|
298
|
+
left: { kind: 'call_arg', index: cond.outArgIndex },
|
|
299
|
+
right: {
|
|
300
|
+
kind: 'call_arg_scaled',
|
|
301
|
+
index: cond.inArgIndex,
|
|
302
|
+
num: cond.num,
|
|
303
|
+
den: cond.den,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
289
306
|
case 'and':
|
|
290
307
|
return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) };
|
|
291
308
|
case 'or':
|
|
@@ -463,6 +480,8 @@ function assertNoOracleDescendants(node) {
|
|
|
463
480
|
}
|
|
464
481
|
function containsOracle(node) {
|
|
465
482
|
switch (node.op) {
|
|
483
|
+
case 'slippage_floor':
|
|
484
|
+
return false;
|
|
466
485
|
case 'compare':
|
|
467
486
|
return node.compare.selector.kind === 'oracle_price';
|
|
468
487
|
case 'in':
|
|
@@ -501,6 +520,8 @@ function assertOracleParamsTighten(params) {
|
|
|
501
520
|
* express. Mirrors the OZ adapter's `describeCondition` for parity. */
|
|
502
521
|
function describeCondition(cond) {
|
|
503
522
|
switch (cond.op) {
|
|
523
|
+
case 'slippage_floor':
|
|
524
|
+
return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`;
|
|
504
525
|
case 'in':
|
|
505
526
|
return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`;
|
|
506
527
|
case 'eq_seq':
|
|
@@ -212,6 +212,10 @@ function matchSpendingLimit(c) {
|
|
|
212
212
|
* cannot express, used to populate `uncovered`. */
|
|
213
213
|
function describeCondition(cond) {
|
|
214
214
|
switch (cond.op) {
|
|
215
|
+
case 'slippage_floor':
|
|
216
|
+
// The OZ primitives bound a value against a constant; this bounds one
|
|
217
|
+
// call argument against another, which none of them can express.
|
|
218
|
+
return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`;
|
|
215
219
|
case 'in':
|
|
216
220
|
return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`;
|
|
217
221
|
case 'eq_seq':
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { Contract, rpc, type Transaction } from '@stellar/stellar-sdk';
|
|
2
|
+
import { DEFAULT_GRAMMAR_VERSION } from './build-add-context-rule.ts';
|
|
3
|
+
/** Minimal Soroban RPC surface the install pipeline needs. Test seams
|
|
4
|
+
* inject a stub so the builder stays deterministic; production builds
|
|
5
|
+
* the real one via `createRpcServer` from `../record/rpc.ts`. */
|
|
6
|
+
export interface InstallRpcClient {
|
|
7
|
+
getAccount(address: string): Promise<{
|
|
8
|
+
sequenceNumber(): string;
|
|
9
|
+
}>;
|
|
10
|
+
simulateTransaction(tx: Transaction | Parameters<rpc.Server['simulateTransaction']>[0]): Promise<rpc.Api.SimulateTransactionResponse>;
|
|
11
|
+
getLatestLedger(): Promise<{
|
|
12
|
+
sequence: number;
|
|
13
|
+
}>;
|
|
14
|
+
/** Live `grammar_version()` lookup. Returns the deployed u32. */
|
|
15
|
+
getContractVersion(address: string): Promise<number>;
|
|
16
|
+
}
|
|
17
|
+
/** Convert a real rpc.Server into the InstallRpcClient surface. The
|
|
18
|
+
* `getContractVersion` lookup uses `simulateTransaction` against
|
|
19
|
+
* `contract.call('grammar_version')` and decodes the returned u32.
|
|
20
|
+
*
|
|
21
|
+
* The passphrase is a REQUIRED argument rather than something read off the
|
|
22
|
+
* server: `rpc.Server` does not carry one, so reaching for
|
|
23
|
+
* `server.networkPassphrase` yielded a non-string and every version probe
|
|
24
|
+
* died with "Invalid passphrase provided to Transaction". The caller knows
|
|
25
|
+
* which network it dialled; make it say so. */
|
|
26
|
+
export declare function rpcClientFromServer(server: rpc.Server, networkPassphrase: string): InstallRpcClient;
|
|
27
|
+
/** Inputs for the install-policy build. */
|
|
28
|
+
export interface BuildInstallPolicyArgs {
|
|
29
|
+
/** The smart account contract address (C...). */
|
|
30
|
+
smartAccount: string;
|
|
31
|
+
/** The signer that authorises the install (G... wallet). */
|
|
32
|
+
sourceAccount: string;
|
|
33
|
+
/** Network passphrase - pins the hash the auth digest binds. */
|
|
34
|
+
networkPassphrase: string;
|
|
35
|
+
/** The new rule to install. Mirrors the core `ContextRuleDraft`. */
|
|
36
|
+
rule: BuildInstallPolicyRuleDraft;
|
|
37
|
+
/** Per-rule install nonce; 1 for a fresh install. */
|
|
38
|
+
installNonce: number;
|
|
39
|
+
/** Already-encoded (base64) canonical ScVal of the predicate. */
|
|
40
|
+
encodedPredicate: string;
|
|
41
|
+
/** Hex sha256 of the canonical predicate XDR bytes. */
|
|
42
|
+
predicateHash: string;
|
|
43
|
+
/** Per-policy oracle overrides. */
|
|
44
|
+
oracleParams?: {
|
|
45
|
+
maxStalenessSeconds?: number;
|
|
46
|
+
maxDeviationBps?: number;
|
|
47
|
+
maxCrossFeedDeviationBps?: number;
|
|
48
|
+
};
|
|
49
|
+
/** Grammar version override; defaults to 1. */
|
|
50
|
+
grammarVersion?: number;
|
|
51
|
+
/** Base fee in stroops; defaults to BASE_FEE (100). */
|
|
52
|
+
baseFee?: number;
|
|
53
|
+
/** RPC client to use for the simulation pass; injected for tests. */
|
|
54
|
+
rpc: InstallRpcClient;
|
|
55
|
+
/** Ledger window (in ledgers) for the auth entry's `validUntil`. */
|
|
56
|
+
authValidUntilLedgers?: number;
|
|
57
|
+
}
|
|
58
|
+
export type BuildInstallPolicyRuleDraft = {
|
|
59
|
+
contextRuleType: {
|
|
60
|
+
kind: 'default';
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'call_contract';
|
|
63
|
+
contract: string;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'create_contract';
|
|
66
|
+
wasmHash: string;
|
|
67
|
+
};
|
|
68
|
+
name: string;
|
|
69
|
+
validUntilLedger: number | null;
|
|
70
|
+
signers: BuildInstallPolicySignerDraft[];
|
|
71
|
+
policies: BuildInstallPolicyPolicyRef[];
|
|
72
|
+
};
|
|
73
|
+
export type BuildInstallPolicySignerDraft = {
|
|
74
|
+
kind: 'delegated';
|
|
75
|
+
address: string;
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'external';
|
|
78
|
+
verifier: string;
|
|
79
|
+
keyBytes: string;
|
|
80
|
+
};
|
|
81
|
+
export type BuildInstallPolicyPolicyRef = {
|
|
82
|
+
kind: 'interpreter';
|
|
83
|
+
interpreterAddress: string;
|
|
84
|
+
predicateBlobBase64: string;
|
|
85
|
+
} | {
|
|
86
|
+
kind: 'oz_builtin';
|
|
87
|
+
instanceAddress: string;
|
|
88
|
+
primitive: {
|
|
89
|
+
primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold';
|
|
90
|
+
params: Record<string, unknown>;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
/** Human-readable description of the install call, decoded FROM the built
|
|
94
|
+
* XDR (not from the input args). The XDR is the source of truth: a
|
|
95
|
+
* human approving a review card needs the description to mirror the bytes
|
|
96
|
+
* the wallet will sign. Deriving `describes` from the input args would
|
|
97
|
+
* re-describe the caller's intent rather than the transaction. */
|
|
98
|
+
export interface InstallCallDescribes {
|
|
99
|
+
/** Smart account that owns the new rule (echoed from the XDR). */
|
|
100
|
+
targetContract: string;
|
|
101
|
+
/** The fn name on the target (always `add_context_rule` today). */
|
|
102
|
+
fnName: 'add_context_rule';
|
|
103
|
+
/** The new rule's display name, decoded from args[1]. */
|
|
104
|
+
ruleName: string;
|
|
105
|
+
/** The `validUntil` ledger sequence decoded from args[2] (null when void). */
|
|
106
|
+
validUntilLedger: number | null;
|
|
107
|
+
/** The signers attached to the rule, decoded from args[3]. The
|
|
108
|
+
* `verifier`/`keyBytes` payload of an external signer is omitted - it
|
|
109
|
+
* is not material to the review card and stays opaque to keep the
|
|
110
|
+
* description focused on what the human has to recognise. */
|
|
111
|
+
signers: Array<{
|
|
112
|
+
kind: 'delegated';
|
|
113
|
+
address: string;
|
|
114
|
+
} | {
|
|
115
|
+
kind: 'external';
|
|
116
|
+
verifier: string;
|
|
117
|
+
}>;
|
|
118
|
+
/** One entry per policy attached to the rule, decoded from the policies
|
|
119
|
+
* map (args[4]). The address is the map key; the kind + extras below
|
|
120
|
+
* describe the value. The interpreter policy also reports the
|
|
121
|
+
* sha256 of the predicate blob actually embedded in the XDR - so a
|
|
122
|
+
* mismatch between the wire bytes and the review card is detectable
|
|
123
|
+
* by reading `describes`. */
|
|
124
|
+
policies: Array<{
|
|
125
|
+
kind: 'interpreter';
|
|
126
|
+
address: string;
|
|
127
|
+
installNonce: number;
|
|
128
|
+
predicateHash: string;
|
|
129
|
+
predicateSha256OfEmbeddedBytes: string;
|
|
130
|
+
} | {
|
|
131
|
+
kind: 'oz_builtin';
|
|
132
|
+
address: string;
|
|
133
|
+
primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold';
|
|
134
|
+
}>;
|
|
135
|
+
/** The install nonce, decoded from the interpreter policy's
|
|
136
|
+
* `install_nonce` field. Echoed at the top level for reviewer convenience;
|
|
137
|
+
* the per-policy entry is the source of truth. */
|
|
138
|
+
installNonce: number;
|
|
139
|
+
}
|
|
140
|
+
/** Output of the install-policy build. The unsigned XDR is the wallet's
|
|
141
|
+
* input; the captured auth nonce + invocation root make the response
|
|
142
|
+
* self-describing for callers that want to inspect what they signed. */
|
|
143
|
+
export interface BuildInstallPolicyResult {
|
|
144
|
+
/** Unsigned Soroban transaction envelope, base64 XDR. */
|
|
145
|
+
unsignedXdr: string;
|
|
146
|
+
/** Smart account contract address (echo). */
|
|
147
|
+
smartAccount: string;
|
|
148
|
+
/** Source account (echo) - the address that must sign. */
|
|
149
|
+
sourceAccount: string;
|
|
150
|
+
/** The host-call target + fn name. Call 2 (interpreter.install) is NOT
|
|
151
|
+
* emitted here; the account assigns the rule id in call 1 and call 2
|
|
152
|
+
* needs that id to bind its `context_rule_ids`. See `followUp` below. */
|
|
153
|
+
call: {
|
|
154
|
+
contract: string;
|
|
155
|
+
fn: 'add_context_rule';
|
|
156
|
+
};
|
|
157
|
+
/** The follow-up call the caller MUST issue after this one confirms.
|
|
158
|
+
* The rule id the account assigns in call 1 is required to bind the
|
|
159
|
+
* interpreter's `install` auth context. */
|
|
160
|
+
followUp: {
|
|
161
|
+
contract: 'interpreter';
|
|
162
|
+
fn: 'install';
|
|
163
|
+
requiresRuleIdFromCallOne: true;
|
|
164
|
+
hint: string;
|
|
165
|
+
};
|
|
166
|
+
/** Human-readable description of the install call, decoded FROM the
|
|
167
|
+
* built unsigned XDR (not from the input args). The wallet signature
|
|
168
|
+
* binds to bytes; the review card has to bind to the same bytes, so
|
|
169
|
+
* this is the only safe source. */
|
|
170
|
+
describes: InstallCallDescribes;
|
|
171
|
+
/** The auth nonce the host assigned to this call (snapshot). */
|
|
172
|
+
authNonce: string;
|
|
173
|
+
/** The ledger sequence + window the auth entry expires at. */
|
|
174
|
+
authValidUntilLedger: number;
|
|
175
|
+
/** The captured rootInvocation so a downstream caller can verify the
|
|
176
|
+
* signature payload matches the one the host expects. */
|
|
177
|
+
rootInvocationXdr: string;
|
|
178
|
+
}
|
|
179
|
+
/** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
|
|
180
|
+
* The output XDR is signed by the wallet, not by us. */
|
|
181
|
+
export declare function buildInstallPolicyXdr(args: BuildInstallPolicyArgs): Promise<BuildInstallPolicyResult>;
|
|
182
|
+
/** Build an unsigned XDR for `account.remove_context_rule(ruleId)`. The
|
|
183
|
+
* smart account itself handles uninstalling each attached policy (calling
|
|
184
|
+
* `interpreter.uninstall` for the interpreter policy); this builder only
|
|
185
|
+
* emits the account-side removal call.
|
|
186
|
+
*
|
|
187
|
+
* Who may authorise it is the ACCOUNT's decision, and the account's source is
|
|
188
|
+
* not in this repo. The interpreter's own `uninstall` is master-gated, but
|
|
189
|
+
* that is a different entry point from this one, so do not restate it as the
|
|
190
|
+
* rule for this call. What is proven on testnet is that the account's
|
|
191
|
+
* deployer can revoke. This builder does not pre-check the signer: it would
|
|
192
|
+
* be guessing at a contract it cannot read, and the chain is the authority. */
|
|
193
|
+
export declare function buildRevokePolicyXdr(args: {
|
|
194
|
+
smartAccount: string;
|
|
195
|
+
sourceAccount: string;
|
|
196
|
+
ruleId: number;
|
|
197
|
+
networkPassphrase: string;
|
|
198
|
+
rpc: InstallRpcClient;
|
|
199
|
+
baseFee?: number;
|
|
200
|
+
authValidUntilLedgers?: number;
|
|
201
|
+
}): Promise<BuildRevokePolicyResult>;
|
|
202
|
+
export interface BuildRevokePolicyResult {
|
|
203
|
+
unsignedXdr: string;
|
|
204
|
+
smartAccount: string;
|
|
205
|
+
sourceAccount: string;
|
|
206
|
+
call: {
|
|
207
|
+
contract: string;
|
|
208
|
+
fn: 'remove_context_rule';
|
|
209
|
+
ruleId: number;
|
|
210
|
+
};
|
|
211
|
+
authNonce: string;
|
|
212
|
+
authValidUntilLedger: number;
|
|
213
|
+
rootInvocationXdr: string;
|
|
214
|
+
}
|
|
215
|
+
/** Re-export so the run-layer does not need to import from
|
|
216
|
+
* build-add-context-rule.ts (keeps the install/ -> install/ dependency
|
|
217
|
+
* direction intact). */
|
|
218
|
+
export { Contract, DEFAULT_GRAMMAR_VERSION };
|