@crediolabs/policy-synth 0.1.17 → 0.2.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.
Files changed (113) hide show
  1. package/README.md +44 -246
  2. package/dist/adapters/interpreter/adapter.d.ts +6 -7
  3. package/dist/adapters/interpreter/adapter.js +28 -48
  4. package/dist/adapters/oz/adapter.js +12 -13
  5. package/dist/codegen/compile-gate.js +5 -0
  6. package/dist/codegen/template.js +17 -0
  7. package/dist/install/authority-overlap.d.ts +134 -0
  8. package/dist/install/authority-overlap.js +0 -0
  9. package/dist/install/build-add-context-rule.d.ts +8 -0
  10. package/dist/install/build-add-context-rule.js +17 -46
  11. package/dist/install/build-install-policy.d.ts +12 -5
  12. package/dist/install/build-install-policy.js +32 -23
  13. package/dist/install/build-merge-policy.d.ts +70 -0
  14. package/dist/install/build-merge-policy.js +130 -0
  15. package/dist/install/get-interpreter-info.js +2 -2
  16. package/dist/install/index.d.ts +3 -1
  17. package/dist/install/index.js +8 -1
  18. package/dist/install/oz-auth.js +5 -3
  19. package/dist/install/plan-merge-policy.d.ts +49 -0
  20. package/dist/install/plan-merge-policy.js +86 -0
  21. package/dist/install/read-account-rules.d.ts +100 -0
  22. package/dist/install/read-account-rules.js +283 -0
  23. package/dist/predicate/decode.js +1 -1
  24. package/dist/predicate/encode.js +171 -10
  25. package/dist/record/decode.js +5 -6
  26. package/dist/registry/protocols.d.ts +1 -1
  27. package/dist/registry/protocols.js +1 -1
  28. package/dist/review-card/builder.d.ts +6 -0
  29. package/dist/review-card/builder.js +9 -1
  30. package/dist/run/index.d.ts +104 -18
  31. package/dist/run/index.js +359 -75
  32. package/dist/run/schemas.d.ts +479 -18
  33. package/dist/run/schemas.js +132 -24
  34. package/dist/synth/compose-from-recording.d.ts +10 -15
  35. package/dist/synth/compose-from-recording.js +79 -124
  36. package/dist/synth/deny-cases.d.ts +5 -0
  37. package/dist/synth/deny-cases.js +52 -13
  38. package/dist/synth/evaluate.js +69 -119
  39. package/dist/synth/harness.d.ts +13 -1
  40. package/dist/synth/harness.js +22 -1
  41. package/dist/synth/index.d.ts +1 -1
  42. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  43. package/dist/synth/synthesize-from-recording.js +129 -155
  44. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  45. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  46. package/dist-cjs/adapters/oz/adapter.js +12 -13
  47. package/dist-cjs/codegen/compile-gate.js +5 -0
  48. package/dist-cjs/codegen/template.js +17 -0
  49. package/dist-cjs/install/authority-overlap.d.ts +134 -0
  50. package/dist-cjs/install/authority-overlap.js +0 -0
  51. package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
  52. package/dist-cjs/install/build-add-context-rule.js +17 -45
  53. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  54. package/dist-cjs/install/build-install-policy.js +32 -23
  55. package/dist-cjs/install/build-merge-policy.d.ts +70 -0
  56. package/dist-cjs/install/build-merge-policy.js +134 -0
  57. package/dist-cjs/install/get-interpreter-info.js +2 -2
  58. package/dist-cjs/install/index.d.ts +3 -1
  59. package/dist-cjs/install/index.js +24 -3
  60. package/dist-cjs/install/oz-auth.js +5 -3
  61. package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
  62. package/dist-cjs/install/plan-merge-policy.js +90 -0
  63. package/dist-cjs/install/read-account-rules.d.ts +100 -0
  64. package/dist-cjs/install/read-account-rules.js +296 -0
  65. package/dist-cjs/predicate/decode.js +1 -1
  66. package/dist-cjs/predicate/encode.js +171 -10
  67. package/dist-cjs/record/decode.js +5 -6
  68. package/dist-cjs/registry/protocols.d.ts +1 -1
  69. package/dist-cjs/registry/protocols.js +1 -1
  70. package/dist-cjs/review-card/builder.d.ts +6 -0
  71. package/dist-cjs/review-card/builder.js +9 -1
  72. package/dist-cjs/run/index.d.ts +104 -18
  73. package/dist-cjs/run/index.js +360 -74
  74. package/dist-cjs/run/schemas.d.ts +479 -18
  75. package/dist-cjs/run/schemas.js +133 -25
  76. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  77. package/dist-cjs/synth/compose-from-recording.js +79 -124
  78. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  79. package/dist-cjs/synth/deny-cases.js +52 -13
  80. package/dist-cjs/synth/evaluate.js +69 -119
  81. package/dist-cjs/synth/harness.d.ts +13 -1
  82. package/dist-cjs/synth/harness.js +22 -1
  83. package/dist-cjs/synth/index.d.ts +1 -1
  84. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  85. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  86. package/package.json +1 -1
  87. package/src/adapters/interpreter/adapter.ts +28 -48
  88. package/src/adapters/oz/adapter.ts +12 -13
  89. package/src/codegen/compile-gate.ts +5 -0
  90. package/src/codegen/template.ts +17 -0
  91. package/src/install/authority-overlap.ts +0 -0
  92. package/src/install/build-add-context-rule.ts +29 -56
  93. package/src/install/build-install-policy.ts +41 -23
  94. package/src/install/build-merge-policy.ts +219 -0
  95. package/src/install/get-interpreter-info.ts +2 -2
  96. package/src/install/index.ts +36 -2
  97. package/src/install/oz-auth.ts +5 -3
  98. package/src/install/plan-merge-policy.ts +133 -0
  99. package/src/install/read-account-rules.ts +376 -0
  100. package/src/predicate/decode.ts +1 -1
  101. package/src/predicate/encode.ts +176 -10
  102. package/src/record/decode.ts +5 -6
  103. package/src/registry/protocols.ts +1 -1
  104. package/src/review-card/builder.ts +17 -1
  105. package/src/run/index.ts +500 -102
  106. package/src/run/schemas.ts +145 -24
  107. package/src/synth/compose-from-recording.ts +87 -132
  108. package/src/synth/deny-cases.ts +59 -13
  109. package/src/synth/evaluate.ts +70 -118
  110. package/src/synth/harness.ts +29 -1
  111. package/src/synth/index.ts +1 -0
  112. package/src/synth/synthesize-from-recording.ts +209 -192
  113. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
@@ -1,26 +1,9 @@
1
1
  // src/adapters/interpreter/adapter.ts - the interpreter-policy CustodyAdapter.
2
2
  //
3
3
  // Compiles a PolicyIR to a single interpreter `PolicyDocument` + `PolicyRef`
4
- // carrying the canonical predicate encoding defined in
5
- // `packages/policy-interpreter/INTERPRETER_INSTALL_PARAMS.md`. The adapter is
6
- // the second backend (the OZ built-in adapter is the first); together they
7
- // cover the canonical language subset. The compose step (P3) routes IR
8
- // constructs between them.
9
- //
10
- // What it lowers (every IR construct the predicate DSL expresses):
11
- // - IRLogic and/or -> PredicateNode and/or
12
- // - IRLogic not -> PredicateNode not
13
- // - IRCompare -> selector(left) vs literal(right), op carried over
14
- // - IR `in` -> needle=selector, haystack=literals (PURESET
15
- // membership; the haystack is always sorted by the
16
- // encoder)
17
- // - IR `eq_seq` -> eq(selectorLeaf, literal_vec([...])) - exact ordered
18
- // sequence equality; element order is preserved
19
- // verbatim (the encoder does NOT sort the vec)
20
- // - IRSelector -> matching PredicateLeaf (see lowerSelector)
21
- // - scope.contract -> sibling `call_contract == <contract>` (always
22
- // emitted when set)
23
- // - scope.method -> sibling `call_fn == <method>`
4
+ // carrying the canonical predicate encoding from `predicate/encode.ts`. Second
5
+ // backend (the OZ built-in adapter is the first); the compose step (P3) routes
6
+ // IR constructs between them.
24
7
  //
25
8
  // Three fail-closed enforcement gates (per spec):
26
9
  // - oracle_price leaves MUST sit directly under the top-level `and`; nesting
@@ -66,7 +49,7 @@ import type {
66
49
  * deploy artifact we do not have yet; install is a later phase. */
67
50
  export const PLACEHOLDER_INTERPRETER_ADDRESS = 'VERIFY-interpreter-address'
68
51
 
69
- /** Wasm-level oracle defaults (mirrors INTERPRETER_INSTALL_PARAMS.md OracleParams).
52
+ /** Wasm-level oracle defaults (mirrors the interpreter's `OracleParams`).
70
53
  * Per-policy overrides may TIGHTEN only - they may never exceed these. */
71
54
  export const ORACLE_DEFAULTS = {
72
55
  maxStalenessSeconds: 600,
@@ -90,18 +73,16 @@ export interface InterpreterAdapterConfig {
90
73
  const CAPABILITIES: CustodyCapabilities = {
91
74
  supportsSpendWindow: true,
92
75
  supportsThreshold: false, // thresholds are the OZ adapter's job
93
- // False for the predicate-leaf path: the interpreter refuses a `valid_until`
94
- // leaf at install. Expiry is still available, but as the context rule's
95
- // validUntilLedger, which the smart account enforces - not as a predicate.
76
+ // Expiry is via the context rule's validUntilLedger, not a predicate - the
77
+ // interpreter refuses a `valid_until` leaf at install.
96
78
  supportsTimeExpiry: false,
97
79
  supportsOraclePrice: true,
98
80
  supportsInvocationCount: true,
99
81
  supportsGeneralPredicate: true,
100
82
  }
101
83
 
102
- /** Parse confidence for a deterministic (non-decoded) input: full, with an
103
- * empty unknown/opaque breakdown. A mandate needs no decoding, so the gate is
104
- * not applicable and confidence is 1. */
84
+ /** Parse confidence for a deterministic (non-decoded) input: full (1.0). A
85
+ * mandate needs no decoding, so the gate is not applicable. */
105
86
  const FULL_PARSE_CONFIDENCE: ParseConfidence = {
106
87
  overall: 1,
107
88
  knownContracts: [],
@@ -123,12 +104,11 @@ export function createInterpreterAdapter(config: InterpreterAdapterConfig): Cust
123
104
 
124
105
  /** Lower a single IR rule to the canonical pre-encoding `PredicateNode`. The
125
106
  * orchestrator uses this to wire the self-verify + minimise pipeline: after
126
- * `compile(ir)` succeeds (`covered === true`, `proposed` set), the
127
- * orchestrator re-derives the PredicateNode via this helper to drive
128
- * `minimize` and `runHarness` on the SAME shape the encoder saw. Pure and
129
- * deterministic: same `rule + config` -> byte-identical PredicateNode. The
130
- * `uncovered` list the adapter surfaces during compile is NOT re-derived
131
- * here; callers that need it should use the result of `compile(ir)`. */
107
+ * `compile(ir)` succeeds it re-derives the PredicateNode via this helper to
108
+ * drive `minimize` and `runHarness` on the SAME shape the encoder saw. Pure
109
+ * and deterministic: same `rule + config` -> byte-identical PredicateNode.
110
+ * The `uncovered` list is NOT re-derived - callers needing it must use
111
+ * `compile(ir)`. */
132
112
  export function lowerRuleToPredicate(
133
113
  rule: IRPolicyRule,
134
114
  config: InterpreterAdapterConfig
@@ -193,9 +173,9 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
193
173
  const uncovered: string[] = []
194
174
 
195
175
  // scope -> context rule + sibling predicates. contract/method each become
196
- // their own `eq` leaf and are merged into the top-level and alongside the
197
- // constraints. This keeps the top-level shape `and` (mandatory per the oracle
198
- // position rule and the canonical hash stability for `and`).
176
+ // their own `eq` leaf and are merged into the top-level `and` alongside the
177
+ // constraints. The top-level MUST be `and` so oracle leaves sit directly
178
+ // under it (mandatory per the oracle position rule + canonical hash stability).
199
179
  const scopeContract: string | undefined = rule.scope.contract
200
180
  const scopeMethod: string | undefined = rule.scope.method
201
181
  if (rule.scope.chainId !== undefined) {
@@ -324,18 +304,18 @@ function unsupportedConstruct(cond: IRCondition): string | null {
324
304
  const s = cond.compare.selector
325
305
  if (s.kind === 'calldata') return 'EVM calldata comparison (predicate DSL)'
326
306
  if (s.kind === 'value') return 'tx.value comparison (predicate DSL)'
327
- // The on-chain interpreter sees ONE authorized call - there is no
328
- // `Context.sub_invocations` in v1 - so it cannot observe the
329
- // transaction's token movements. `amount` has no value to read, and
330
- // `window_spent` accumulates BY that amount, so its counter would never
331
- // move. Deriving either from the call payload would quietly swap "value
307
+ // The on-chain interpreter sees ONE authorized call - no
308
+ // `Context.sub_invocations` in v1 - so it cannot observe token
309
+ // movements. `amount` has no value to read, and `window_spent`
310
+ // accumulates BY that amount, so its counter would never move.
311
+ // Deriving either from the call payload would quietly swap "value
332
312
  // actually moved" for "value the caller declared" - a weaker guarantee
333
313
  // than the review card would be claiming.
334
314
  //
335
- // Rolling spend caps belong to the OZ `spending_limit` primitive, which
336
- // is already audited and which the OZ adapter emits. A per-call cap is
337
- // expressible here as `arg_field`; bounding it with `invocation_count`
338
- // gives an enforceable ceiling per window.
315
+ // Rolling spend caps belong to the OZ `spending_limit` primitive (already
316
+ // audited, emitted by the OZ adapter). A per-call cap is expressible here
317
+ // as `arg_field`; bounding it with `invocation_count` gives an enforceable
318
+ // ceiling per window.
339
319
  return unsourceableSelector(s)
340
320
  }
341
321
  // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
@@ -454,9 +434,9 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
454
434
  case 'arg_field':
455
435
  return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
456
436
  // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
457
- // before lowering - the interpreter cannot source either on chain. Reaching
458
- // here means the pre-scan was bypassed, so fail loudly rather than emit a
459
- // leaf the contract will refuse.
437
+ // before lowering - the interpreter cannot source either on chain.
438
+ // Reaching here means the pre-scan was bypassed; fail loudly rather than
439
+ // emit a leaf the contract will refuse.
460
440
  case 'amount':
461
441
  case 'window_spent':
462
442
  throw new Error(
@@ -9,7 +9,7 @@
9
9
  // Anything needing a capability this backend lacks (oracle price, invocation
10
10
  // count, per-arg comparison/allowlist, guard, nested boolean predicate) is NOT
11
11
  // emitted: it is named in `uncovered` and `covered` is set false. Nothing is
12
- // silently dropped - the uncovered constructs are named instead.
12
+ // silently dropped.
13
13
  //
14
14
  // OZ built-in policy instance addresses are per-network deploy artifacts we do
15
15
  // not have yet (install is a later phase). They are injected via config; week-1
@@ -58,9 +58,8 @@ export function placeholderOzConfig(network: Network): OzAdapterConfig {
58
58
  return { network, instances: PLACEHOLDER_OZ_INSTANCES }
59
59
  }
60
60
 
61
- /** Parse confidence for a deterministic (non-decoded) input: full, with an
62
- * empty unknown/opaque breakdown. A mandate needs no decoding, so the gate is
63
- * not applicable and confidence is 1. */
61
+ /** Parse confidence for a deterministic (non-decoded) input: full (1.0). A
62
+ * mandate needs no decoding, so the gate is not applicable. */
64
63
  const FULL_PARSE_CONFIDENCE: ParseConfidence = {
65
64
  overall: 1,
66
65
  knownContracts: [],
@@ -131,10 +130,10 @@ function lowerRule(rule: IRPolicyRule, config: OzAdapterConfig): LoweredRule {
131
130
  const uncovered: string[] = []
132
131
  const policyRefs: PolicyRef[] = []
133
132
 
134
- // scope -> context rule type. OZ scopes by contract (CallContract); a finer
135
- // method-level restriction is a predicate concern and must be flagged as not
136
- // covered because CallContract alone permits other methods on the same contract
137
- // (e.g. an unbounded approve alongside a capped transfer).
133
+ // scope -> context rule type. OZ scopes by contract (CallContract); a
134
+ // method-level restriction is flagged as not covered because CallContract
135
+ // alone permits other methods on the same contract (e.g. an unbounded approve
136
+ // alongside a capped transfer).
138
137
  const contextRuleType: ContextRuleDraft['contextRuleType'] =
139
138
  rule.scope.contract !== undefined
140
139
  ? { kind: 'call_contract', contract: rule.scope.contract }
@@ -177,8 +176,8 @@ function lowerRule(rule: IRPolicyRule, config: OzAdapterConfig): LoweredRule {
177
176
  }
178
177
 
179
178
  // constraints -> spending_limit where they match; else not covered. The OZ
180
- // spending_limit policy takes `{ spending_limit: i128, period_ledgers: u32 }`
181
- // and has NO token param: it only accepts a CallContract context rule
179
+ // spending_limit takes `{ spending_limit: i128, period_ledgers: u32 }` and
180
+ // has NO token param: it only accepts a CallContract context rule
182
181
  // (OnlyCallContractAllowed) and limits transfers of that context's contract,
183
182
  // so the spent token must equal the scope contract, and the window is a
184
183
  // ledger count (~5s/ledger), not seconds.
@@ -209,7 +208,7 @@ function lowerRule(rule: IRPolicyRule, config: OzAdapterConfig): LoweredRule {
209
208
 
210
209
  // approval.threshold -> simple/weighted threshold primitive. A threshold < 1
211
210
  // is not a real M-of-N gate (0 approvals authorises everything), so refuse to
212
- // emit a no-op primitive and flag it as not covered instead.
211
+ // emit a no-op primitive and flag it as not covered.
213
212
  if (rule.approval) {
214
213
  if (!Number.isInteger(rule.approval.threshold) || rule.approval.threshold < 1) {
215
214
  uncovered.push(
@@ -286,8 +285,8 @@ function matchSpendingLimit(c: IRCondition): SpendingLimitMatch | null {
286
285
  function describeCondition(cond: IRCondition): string {
287
286
  switch (cond.op) {
288
287
  case 'slippage_floor':
289
- // The OZ primitives bound a value against a constant; this bounds one
290
- // call argument against another, which none of them can express.
288
+ // OZ primitives bound a value against a constant; this bounds one call
289
+ // argument against another, which none of them can express.
291
290
  return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`
292
291
  case 'in':
293
292
  return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`
@@ -107,6 +107,11 @@ publish = false
107
107
 
108
108
  [lib]
109
109
  crate-type = ["cdylib"]
110
+ # The gate writes the source beside the manifest rather than under src/, so
111
+ # the path is stated. Without it cargo looks for src/lib.rs and fails to
112
+ # parse the manifest before it ever compiles anything, which reads as a
113
+ # broken generator rather than a broken scaffold.
114
+ path = "lib.rs"
110
115
 
111
116
  [dependencies]
112
117
  soroban-sdk = "22"
@@ -1,6 +1,23 @@
1
1
  // src/codegen/template.ts - deterministic Rust source generator for the OZ
2
2
  // `Policy` escape-hatch skeleton.
3
3
  //
4
+ // SUPERSEDED. The architecture this belongs to no longer exists. A deployed
5
+ // policy involves exactly two contracts, the OZ smart account and one
6
+ // immutable interpreter, and the synthesiser emits policy DATA rather than
7
+ // code. Nothing on the synthesis path reaches this module, and no install can
8
+ // produce a contract from it. When a constraint falls outside the grammar the
9
+ // answer is to version the synthesiser and the interpreter together, not to
10
+ // put unaudited Rust on chain per policy.
11
+ //
12
+ // Deleting it is a decision nobody has taken yet, not a dependency question.
13
+ // `compile-gate.ts` takes a Rust string and knows nothing about this module,
14
+ // and its tests compile a hardcoded fixture, so the gate keeps working either
15
+ // way. What deleting this would remove is the only in-repo producer of policy
16
+ // Rust for the gate to check. Do not read its presence as the product
17
+ // generating contracts, and do not wire it into the synthesis path.
18
+ //
19
+ // What follows describes the abandoned design.
20
+ //
4
21
  // This is the LAST-RESORT tool for constraints the v1 DSL cannot express. It
5
22
  // lives OUT of the audited happy-path surface; the synthesiser never emits it
6
23
  // automatically. It mirrors Zodiac Roles' `Custom` operator: a hand-written
Binary file
@@ -226,15 +226,13 @@ function encodePoliciesMap(args: BuildAddContextRuleArgs): xdr.ScVal {
226
226
  const entries: xdr.ScMapEntry[] = []
227
227
  for (const ref of args.policies) {
228
228
  if (ref.kind === 'interpreter') {
229
- const val = encodePolicyInstallParams(args)
230
229
  entries.push(
231
230
  new xdr.ScMapEntry({
232
231
  key: Address.fromString(ref.interpreterAddress).toScVal(),
233
- val,
232
+ val: encodePolicyInstallParams(args),
234
233
  })
235
234
  )
236
- }
237
- if (ref.kind === 'oz_builtin') {
235
+ } else if (ref.kind === 'oz_builtin') {
238
236
  entries.push(
239
237
  new xdr.ScMapEntry({
240
238
  key: Address.fromString(ref.instanceAddress).toScVal(),
@@ -320,7 +318,18 @@ function encodeI128(value: string): xdr.ScVal {
320
318
  )
321
319
  }
322
320
 
323
- function encodePolicyInstallParams(args: BuildAddContextRuleArgs): xdr.ScVal {
321
+ /** The `PolicyInstallParams` ScVal an interpreter policy receives.
322
+ *
323
+ * Exported so the merge remedy can re-install a policy through `add_policy`
324
+ * using the SAME encoder as a fresh install. Re-implementing it would risk
325
+ * exactly the drift this file warns about: the field order is ABI-significant
326
+ * and a differing encoding yields a rule that denies every call. */
327
+ export type PolicyInstallParamArgs = Pick<
328
+ BuildAddContextRuleArgs,
329
+ 'encodedPredicate' | 'predicateHash' | 'installNonce' | 'oracleParams' | 'grammarVersion'
330
+ >
331
+
332
+ export function encodePolicyInstallParams(args: PolicyInstallParamArgs): xdr.ScVal {
324
333
  const predicate = Buffer.from(args.encodedPredicate, 'base64')
325
334
  const computedHash = createHash('sha256').update(predicate).digest('hex')
326
335
  if (computedHash !== args.predicateHash) {
@@ -329,58 +338,22 @@ function encodePolicyInstallParams(args: BuildAddContextRuleArgs): xdr.ScVal {
329
338
  `predicateHash ${args.predicateHash.slice(0, 16)}... does not match sha256(encodedPredicate) ${computedHash.slice(0, 16)}...`
330
339
  )
331
340
  }
332
- const entries: xdr.ScMapEntry[] = []
333
- for (const k of POLICY_INSTALL_PARAM_FIELDS) {
334
- const key = xdr.ScVal.scvSymbol(k)
335
- switch (k) {
336
- case 'grammar_version':
337
- entries.push(
338
- new xdr.ScMapEntry({
339
- key,
340
- val: xdr.ScVal.scvU32(args.grammarVersion ?? DEFAULT_GRAMMAR_VERSION),
341
- })
342
- )
343
- break
344
- case 'install_nonce':
345
- entries.push(new xdr.ScMapEntry({ key, val: xdr.ScVal.scvU32(args.installNonce) }))
346
- break
347
- case 'predicate':
348
- entries.push(new xdr.ScMapEntry({ key, val: xdr.ScVal.scvBytes(predicate) }))
349
- break
350
- case 'predicate_hash':
351
- entries.push(
352
- new xdr.ScMapEntry({
353
- key,
354
- val: xdr.ScVal.scvBytes(Buffer.from(args.predicateHash, 'hex')),
355
- })
356
- )
357
- break
358
- case 'oracle_max_staleness_seconds':
359
- entries.push(
360
- new xdr.ScMapEntry({
361
- key,
362
- val: encodeOptionU32(args.oracleParams?.maxStalenessSeconds),
363
- })
364
- )
365
- break
366
- case 'oracle_max_deviation_bps':
367
- entries.push(
368
- new xdr.ScMapEntry({
369
- key,
370
- val: encodeOptionU32(args.oracleParams?.maxDeviationBps),
371
- })
372
- )
373
- break
374
- case 'oracle_max_xfeed_dev_bps':
375
- entries.push(
376
- new xdr.ScMapEntry({
377
- key,
378
- val: encodeOptionU32(args.oracleParams?.maxCrossFeedDeviationBps),
379
- })
380
- )
381
- break
382
- }
341
+ // Per-field value encoders. Each entry returns the ScVal to drop into the
342
+ // map; the key symbol comes from the surrounding loop. Adding a field is
343
+ // one row here plus the symbol in POLICY_INSTALL_PARAM_FIELDS.
344
+ const oracleParams = args.oracleParams
345
+ const valueFor: Record<(typeof POLICY_INSTALL_PARAM_FIELDS)[number], () => xdr.ScVal> = {
346
+ grammar_version: () => xdr.ScVal.scvU32(args.grammarVersion ?? DEFAULT_GRAMMAR_VERSION),
347
+ install_nonce: () => xdr.ScVal.scvU32(args.installNonce),
348
+ predicate: () => xdr.ScVal.scvBytes(predicate),
349
+ predicate_hash: () => xdr.ScVal.scvBytes(Buffer.from(args.predicateHash, 'hex')),
350
+ oracle_max_staleness_seconds: () => encodeOptionU32(oracleParams?.maxStalenessSeconds),
351
+ oracle_max_deviation_bps: () => encodeOptionU32(oracleParams?.maxDeviationBps),
352
+ oracle_max_xfeed_dev_bps: () => encodeOptionU32(oracleParams?.maxCrossFeedDeviationBps),
383
353
  }
354
+ const entries = POLICY_INSTALL_PARAM_FIELDS.map(
355
+ (k) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(k), val: valueFor[k]() })
356
+ )
384
357
  // The host orders map entries by the SYMBOL STRING, not by XDR bytes. A
385
358
  // length prefix in the XDR encoding would otherwise put `amount` before
386
359
  // `address` and produce a struct the contract reads differently. Emit the
@@ -1,24 +1,21 @@
1
- // src/install/build-install-policy.ts - builds the unsigned Soroban
2
- // transaction XDR for `account.add_context_rule(...)` and
3
- // `account.remove_context_rule(...)`.
1
+ // src/install/build-install-policy.ts - builds the unsigned Soroban transaction
2
+ // XDR for `account.add_context_rule(...)` and `account.remove_context_rule(...)`.
4
3
  //
5
- // The MCP server is stateless and holds no key material (server.ts:10-12),
6
- // so this module NEVER signs. The caller (the wallet / CLI / SDK consumer)
7
- // wraps the returned XDR in a transaction envelope and signs that envelope
8
- // with their wallet; the wallet signature IS the user-confirmation step.
4
+ // The MCP server is stateless and holds no key material, so this module NEVER
5
+ // signs. The caller (wallet / CLI / SDK consumer) wraps the returned XDR in a
6
+ // transaction envelope and signs that envelope with their wallet; the wallet
7
+ // signature IS the user-confirmation step.
9
8
  //
10
- // We deliberately depart from the spec in plans/phase-04 which calls for a
9
+ // We deliberately depart from the original design, which called for a
11
10
  // two-call `install_policy`/`confirm_install` pair backed by a host-signed
12
- // short-TTL `action_id`. That contract requires (a) a stateful store and
13
- // (b) key material the server does not have, so we ship the simpler ONE-CALL
14
- // shape. The wallet signature covers the change.
11
+ // short-TTL `action_id`. That contract requires stateful store + key material
12
+ // the server does not have, so we ship the simpler ONE-CALL shape. The wallet
13
+ // signature covers the change.
15
14
  //
16
15
  // `buildInstallPolicyXdr` installs the policy in ONE call. `add_context_rule`
17
16
  // takes `policies` as a `Map<policy_address, install_param>` and the account
18
17
  // forwards each install_param to that policy, so the interpreter stores the
19
- // predicate document as part of this same transaction. Earlier revisions
20
- // documented a second `interpreter.install` call; that was wrong, and issuing
21
- // it fails - the account re-enters the interpreter while it is mid-install.
18
+ // predicate document as part of this same transaction.
22
19
 
23
20
  import { createHash } from 'node:crypto'
24
21
  import {
@@ -59,11 +56,10 @@ export interface InstallRpcClient {
59
56
  * `getContractVersion` lookup uses `simulateTransaction` against
60
57
  * `contract.call('grammar_version')` and decodes the returned u32.
61
58
  *
62
- * The passphrase is a REQUIRED argument rather than something read off the
63
- * server: `rpc.Server` does not carry one, so reaching for
64
- * `server.networkPassphrase` yielded a non-string and every version probe
65
- * died with "Invalid passphrase provided to Transaction". The caller knows
66
- * which network it dialled; make it say so. */
59
+ * The passphrase is a REQUIRED argument (not read off the server): `rpc.Server`
60
+ * does not carry one, so reaching for `server.networkPassphrase` returned a
61
+ * non-string and every version probe died with "Invalid passphrase provided to
62
+ * Transaction". The caller knows which network it dialled; make it say so. */
67
63
  export function rpcClientFromServer(
68
64
  server: rpc.Server,
69
65
  networkPassphrase: string
@@ -183,6 +179,14 @@ export interface InstallCallDescribes {
183
179
  * is not material to the review card and stays opaque to keep the
184
180
  * description focused on what the human has to recognise. */
185
181
  signers: Array<{ kind: 'delegated'; address: string } | { kind: 'external'; verifier: string }>
182
+ /** Cross-layer L1: a human-readable note about the OZ any-of-N signer
183
+ * semantic that fires for rules with multiple signers. Present only
184
+ * when `signers.length >= 2` (a single-signer rule is trivially any-of-1,
185
+ * and the note would just add noise). The note is purely additive to
186
+ * the review card text - it does NOT alter the wire bytes or the
187
+ * signer-set constraint, only the description a human reads before
188
+ * signing the install. */
189
+ signerNote: string | null
186
190
  /** One entry per policy attached to the rule, decoded from the policies
187
191
  * map (args[4]). The address is the map key; the kind + extras below
188
192
  * describe the value. The interpreter policy also reports the
@@ -616,7 +620,7 @@ function decodeInstallCallDescribes(
616
620
  const tag = tuple[0]?.sym().toString()
617
621
  const inner = tuple[1]
618
622
  if (tag === 'Delegated') {
619
- if (!inner || inner.switch().name !== 'scvAddress') {
623
+ if (inner?.switch().name !== 'scvAddress') {
620
624
  throw new Error('install_policy: a Delegated signer is missing its Address')
621
625
  }
622
626
  signers.push({
@@ -626,7 +630,7 @@ function decodeInstallCallDescribes(
626
630
  continue
627
631
  }
628
632
  if (tag === 'External') {
629
- if (!inner || inner.switch().name !== 'scvAddress') {
633
+ if (inner?.switch().name !== 'scvAddress') {
630
634
  throw new Error('install_policy: an External signer is missing its verifier Address')
631
635
  }
632
636
  signers.push({
@@ -671,14 +675,14 @@ function decodeInstallCallDescribes(
671
675
  // OZ primitives carry spending_limit/period_ledgers/threshold/signers.
672
676
  if (fields.has('predicate') || fields.has('grammar_version') || fields.has('install_nonce')) {
673
677
  const installNonceScv = fields.get('install_nonce')
674
- if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
678
+ if (installNonceScv?.switch().name !== 'scvU32') {
675
679
  throw new Error(
676
680
  `install_policy: interpreter policy ${address} is missing a u32 install_nonce`
677
681
  )
678
682
  }
679
683
  const installNonce = installNonceScv.u32()
680
684
  const predicateScv = fields.get('predicate')
681
- if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
685
+ if (predicateScv?.switch().name !== 'scvBytes') {
682
686
  throw new Error(
683
687
  `install_policy: interpreter policy ${address} is missing its bytes predicate`
684
688
  )
@@ -724,12 +728,26 @@ function decodeInstallCallDescribes(
724
728
  // `observedInstallNonce` is the nonce baked into whichever interpreter
725
729
  // policy is present; when none, fall back to the caller-supplied value.
726
730
  const installNonce = observedInstallNonce ?? expectedInstallNonce
731
+ // Cross-layer L1: OZ Accounts context rules follow any-of-N semantics for
732
+ // the signers they accept - any ONE attached signer may authorise a
733
+ // permitted op under the rule. A user with multiple signers attached
734
+ // therefore has a strict superset of authority of a single-signer rule,
735
+ // not a stricter one; the install succeeds as written, but a human
736
+ // reviewing the install card may expect the opposite (and choose the
737
+ // wrong threshold because of it). Surface the any-of-N note on the
738
+ // description so the human reads the same wire-level semantic the
739
+ // contract enforces.
740
+ const signerNote =
741
+ signers.length >= 2
742
+ ? 'any ONE signer may authorise a permitted op under this rule (OZ any-of-N semantic)'
743
+ : null
727
744
  return {
728
745
  targetContract,
729
746
  fnName: 'add_context_rule',
730
747
  ruleName,
731
748
  validUntilLedger,
732
749
  signers,
750
+ signerNote,
733
751
  policies,
734
752
  installNonce,
735
753
  }