@crediolabs/policy-synth 0.1.18 → 0.3.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 (159) hide show
  1. package/README.md +6 -5
  2. package/dist/adapters/interpreter/adapter.d.ts +19 -25
  3. package/dist/adapters/interpreter/adapter.js +54 -487
  4. package/dist/adapters/interpreter/index.d.ts +1 -1
  5. package/dist/adapters/interpreter/index.js +1 -1
  6. package/dist/adapters/oz/adapter.js +1 -15
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/index.d.ts +1 -4
  9. package/dist/index.js +1 -4
  10. package/dist/install/build-add-context-rule.d.ts +3 -11
  11. package/dist/install/build-add-context-rule.js +2 -97
  12. package/dist/install/build-install-policy.d.ts +4 -53
  13. package/dist/install/build-install-policy.js +44 -123
  14. package/dist/ir/index.d.ts +1 -1
  15. package/dist/ir/types.d.ts +7 -76
  16. package/dist/ir/types.js +0 -2
  17. package/dist/predicate/decode.d.ts +1 -1
  18. package/dist/predicate/decode.js +2 -70
  19. package/dist/predicate/encode.js +45 -229
  20. package/dist/predicate/from-json.js +1 -42
  21. package/dist/record/decode.js +3 -8
  22. package/dist/review-card/builder.d.ts +4 -3
  23. package/dist/review-card/builder.js +18 -162
  24. package/dist/review-card/cross-check.js +2 -105
  25. package/dist/review-card/render-leaf.d.ts +4 -0
  26. package/dist/review-card/render-leaf.js +47 -0
  27. package/dist/run/index.d.ts +35 -53
  28. package/dist/run/index.js +115 -156
  29. package/dist/run/schemas.d.ts +168 -1650
  30. package/dist/run/schemas.js +36 -199
  31. package/dist/seams/types.d.ts +0 -2
  32. package/dist/simulate/deny-cases.d.ts +26 -0
  33. package/dist/simulate/deny-cases.js +282 -0
  34. package/dist/simulate/evaluate.d.ts +18 -0
  35. package/dist/simulate/evaluate.js +271 -0
  36. package/dist/simulate/index.d.ts +4 -0
  37. package/dist/simulate/index.js +8 -0
  38. package/dist/synth/compose-from-recording.d.ts +24 -51
  39. package/dist/synth/compose-from-recording.js +120 -282
  40. package/dist/synth/deny-cases.d.ts +5 -7
  41. package/dist/synth/deny-cases.js +23 -373
  42. package/dist/synth/evaluate.d.ts +0 -21
  43. package/dist/synth/evaluate.js +56 -336
  44. package/dist/synth/index.d.ts +0 -5
  45. package/dist/synth/index.js +0 -5
  46. package/dist/synth/permit-context.d.ts +2 -9
  47. package/dist/synth/permit-context.js +1 -82
  48. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  49. package/dist/synth/synthesize-from-recording.js +41 -356
  50. package/dist/types.d.ts +8 -60
  51. package/dist/types.js +5 -2
  52. package/dist/verify/simulate.d.ts +1 -14
  53. package/dist/verify/simulate.js +2 -88
  54. package/dist/verify/verify.d.ts +1 -7
  55. package/dist/verify/verify.js +0 -8
  56. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  57. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  58. package/dist-cjs/adapters/interpreter/index.d.ts +1 -1
  59. package/dist-cjs/adapters/interpreter/index.js +1 -2
  60. package/dist-cjs/adapters/oz/adapter.js +1 -15
  61. package/dist-cjs/errors.d.ts +1 -1
  62. package/dist-cjs/index.d.ts +1 -4
  63. package/dist-cjs/index.js +1 -4
  64. package/dist-cjs/install/build-add-context-rule.d.ts +3 -11
  65. package/dist-cjs/install/build-add-context-rule.js +1 -96
  66. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  67. package/dist-cjs/install/build-install-policy.js +44 -122
  68. package/dist-cjs/ir/index.d.ts +1 -1
  69. package/dist-cjs/ir/types.d.ts +7 -76
  70. package/dist-cjs/ir/types.js +0 -2
  71. package/dist-cjs/predicate/decode.d.ts +1 -1
  72. package/dist-cjs/predicate/decode.js +2 -70
  73. package/dist-cjs/predicate/encode.js +45 -229
  74. package/dist-cjs/predicate/from-json.js +1 -42
  75. package/dist-cjs/record/decode.js +3 -8
  76. package/dist-cjs/review-card/builder.d.ts +4 -3
  77. package/dist-cjs/review-card/builder.js +23 -167
  78. package/dist-cjs/review-card/cross-check.js +7 -110
  79. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  80. package/dist-cjs/review-card/render-leaf.js +52 -0
  81. package/dist-cjs/run/index.d.ts +35 -53
  82. package/dist-cjs/run/index.js +116 -162
  83. package/dist-cjs/run/schemas.d.ts +168 -1650
  84. package/dist-cjs/run/schemas.js +37 -200
  85. package/dist-cjs/seams/types.d.ts +0 -2
  86. package/dist-cjs/simulate/deny-cases.d.ts +26 -0
  87. package/dist-cjs/simulate/deny-cases.js +286 -0
  88. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  89. package/dist-cjs/simulate/evaluate.js +274 -0
  90. package/dist-cjs/simulate/index.d.ts +4 -0
  91. package/dist-cjs/simulate/index.js +13 -0
  92. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  93. package/dist-cjs/synth/compose-from-recording.js +120 -282
  94. package/dist-cjs/synth/deny-cases.d.ts +5 -7
  95. package/dist-cjs/synth/deny-cases.js +23 -374
  96. package/dist-cjs/synth/evaluate.d.ts +0 -21
  97. package/dist-cjs/synth/evaluate.js +57 -337
  98. package/dist-cjs/synth/index.d.ts +0 -5
  99. package/dist-cjs/synth/index.js +1 -11
  100. package/dist-cjs/synth/permit-context.d.ts +2 -9
  101. package/dist-cjs/synth/permit-context.js +1 -82
  102. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  103. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  104. package/dist-cjs/types.d.ts +8 -60
  105. package/dist-cjs/types.js +6 -3
  106. package/dist-cjs/verify/simulate.d.ts +1 -14
  107. package/dist-cjs/verify/simulate.js +2 -88
  108. package/dist-cjs/verify/verify.d.ts +1 -7
  109. package/dist-cjs/verify/verify.js +0 -8
  110. package/package.json +4 -4
  111. package/src/adapters/interpreter/adapter.ts +76 -572
  112. package/src/errors.ts +0 -19
  113. package/src/index.ts +1 -4
  114. package/src/install/build-add-context-rule.ts +4 -127
  115. package/src/install/build-install-policy.ts +93 -214
  116. package/src/predicate/decode.ts +2 -70
  117. package/src/predicate/encode.ts +49 -261
  118. package/src/predicate/from-json.ts +1 -43
  119. package/src/record/decode.ts +3 -8
  120. package/src/review-card/builder.ts +19 -168
  121. package/src/review-card/cross-check.ts +3 -105
  122. package/src/review-card/render-leaf.ts +48 -0
  123. package/src/run/index.ts +130 -186
  124. package/src/run/schemas.ts +38 -214
  125. package/src/simulate/deny-cases.ts +334 -0
  126. package/src/simulate/evaluate.ts +284 -0
  127. package/src/simulate/index.ts +11 -0
  128. package/src/synth/compose-from-recording.ts +148 -347
  129. package/src/synth/index.ts +0 -13
  130. package/src/synth/synthesize-from-recording.ts +43 -456
  131. package/src/types.ts +13 -49
  132. package/dist/install/build-install-predicate.d.ts +0 -96
  133. package/dist/install/build-install-predicate.js +0 -444
  134. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  135. package/dist-cjs/install/build-install-predicate.js +0 -479
  136. package/src/adapters/interpreter/index.ts +0 -8
  137. package/src/adapters/oz/adapter.ts +0 -376
  138. package/src/adapters/oz/index.ts +0 -9
  139. package/src/codegen/compile-gate.ts +0 -167
  140. package/src/codegen/index.ts +0 -17
  141. package/src/codegen/template.ts +0 -165
  142. package/src/ir/index.ts +0 -13
  143. package/src/ir/types.ts +0 -132
  144. package/src/mandate/index.ts +0 -4
  145. package/src/mandate/to-ir.ts +0 -71
  146. package/src/mandate/types.ts +0 -21
  147. package/src/seams/index.ts +0 -11
  148. package/src/seams/types.ts +0 -81
  149. package/src/synth/deny-cases.ts +0 -663
  150. package/src/synth/evaluate.ts +0 -613
  151. package/src/synth/harness.ts +0 -68
  152. package/src/synth/minimize.ts +0 -48
  153. package/src/synth/permit-context.ts +0 -136
  154. package/src/synth/predicate-literals.ts +0 -27
  155. package/src/synth/synthesize-from-mandate.ts +0 -82
  156. package/src/verify/envelope.ts +0 -28
  157. package/src/verify/index.ts +0 -5
  158. package/src/verify/simulate.ts +0 -311
  159. package/src/verify/verify.ts +0 -243
package/src/types.ts CHANGED
@@ -64,13 +64,11 @@ export const MAX_SCVAL_CLONE_DEPTH = 30 as const
64
64
  * - MAX_DEPTH = 5: 3 walkthroughs need depth 2; budget = 2x growth + 1 for `not` chain.
65
65
  * - MAX_LEAVES = 200: allowlists (recipients, paths) typically < 100; budget = 2x + 1 nested allowlist.
66
66
  * - MAX_PREDICATE_BYTES = 32 KB: well under the per-ledger-entry 64 KB persistent-storage limit.
67
- * - MAX_ORACLE_READS = 6: two-round confirmation per asset consumes 2 reads; cap accommodates a predicate that references up to 3 unique oracle assets (3 * 2 = 6 reads). Stays a fail-closed install-time cap.
68
67
  * - MAX_IN_OPERAND_COUNT = 32: allowlist < 32 is a UX-natural bound. */
69
68
  export const PREDICATE_CAPS = {
70
69
  MAX_DEPTH: 5,
71
70
  MAX_LEAVES: 200,
72
71
  MAX_PREDICATE_BYTES: 32 * 1024,
73
- MAX_ORACLE_READS: 6,
74
72
  MAX_IN_OPERAND_COUNT: 32,
75
73
  } as const
76
74
 
@@ -97,15 +95,23 @@ export type SignerDraft =
97
95
  | { kind: 'external'; verifier: string; keyBytes: string }
98
96
 
99
97
  /** Reference to one policy attached to a context rule. */
100
- export type PolicyRef =
101
- | { kind: 'oz_builtin'; primitive: OZPrimitiveConfig; instanceAddress: string }
102
- | { kind: 'interpreter'; interpreterAddress: string; predicateBlobBase64: string }
98
+ export type PolicyRef = {
99
+ kind: 'interpreter'
100
+ interpreterAddress: string
101
+ predicateBlobBase64: string
102
+ }
103
+
104
+ /** Grammar version baked into the interpreter wasm, mirroring `SELF_VERSION` in
105
+ * `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
106
+ * the wire derives from here; `grammar-version-parity.test.ts` pins it to the
107
+ * contract so a skew cannot pass a green test run. */
108
+ export const GRAMMAR_VERSION = 3 as const
103
109
 
104
110
  /** A serialised predicate document stored against a (smart_account, rule_id) pair. */
105
111
  export interface PolicyDocument {
106
112
  /** Grammar version baked into the interpreter wasm. Fail-closed strict match at install
107
113
  * AND at evaluate (defence in depth). NOT the per-edit revision. */
108
- grammarVersion: 1
114
+ grammarVersion: typeof GRAMMAR_VERSION
109
115
  /** Per-rule install nonce. Must equal the interpreter's stored nonce + 1 at install.
110
116
  * First install accepts nonce = 1 with stored nonce = 0. Re-install is structurally
111
117
  * possible by incrementing the nonce; uninstall removes the nonce with state so a
@@ -117,24 +123,13 @@ export interface PolicyDocument {
117
123
  /** Hash of the post-canonicalisation bytes of encodedPredicate. Stored alongside the
118
124
  * doc and re-verified at evaluate. */
119
125
  predicateHash: string
120
- /** Per-policy overrides of oracle thresholds; absent = use defaults.
121
- * Overrides may TIGHTEN only (never widen) - the interpreter rejects any per-policy
122
- * value looser than the wasm-level defaults at install. */
123
- oracleParams?: {
124
- /** Seconds; must be <= MAX_ORACLE_STALENESS_SECONDS (600). */
125
- maxStalenessSeconds?: number
126
- /** Basis points; must be <= MAX_ORACLE_DEVIATION_BPS (200). */
127
- maxDeviationBps?: number
128
- }
129
126
  }
130
127
 
131
128
  /** The versioned predicate AST (interpreter side). Tagged union. */
132
129
  export type PredicateNode =
133
130
  | { op: 'and'; children: PredicateNode[] }
134
- | { op: 'or'; children: PredicateNode[] }
135
- | { op: 'not'; child: PredicateNode }
136
131
  | { op: 'eq'; left: PredicateLeaf; right: PredicateLeaf }
137
- | { op: 'lt' | 'lte' | 'gt' | 'gte'; left: PredicateLeaf; right: PredicateLeaf }
132
+ | { op: 'lte'; left: PredicateLeaf; right: PredicateLeaf }
138
133
  | {
139
134
  op: 'in'
140
135
  needle: PredicateLeaf
@@ -155,48 +150,18 @@ export type PredicateLeaf =
155
150
  // recorded ScVal type. A non-vec arg, missing element, missing field, or
156
151
  // type mismatch all DENY (fail-closed).
157
152
  | { kind: 'call_arg_field'; index: number; element: number; field: string }
158
- // `call_arg_scaled(index, num, den)` evaluates to `args[index] * num / den`
159
- // (truncating toward zero). The slippage-floor leaf: a swap policy
160
- // expresses `call_arg[out] >= call_arg_scaled(in, num, den)` as "the
161
- // minimum output must be at least this ratio of the input". `num`/`den`
162
- // are decimal strings on i128 (mirrors `literal_i128`); the contract
163
- // refuses `den == 0` and `num <= 0` / `den <= 0` at install.
164
- | { kind: 'call_arg_scaled'; index: number; num: string; den: string }
165
- // `call_sub_invocation[i]` is NOT in v1 grammar. OZ `Policy::enforce`
166
- // receives one `Context`, not a sub-invocation tree; sub-invocations live under
167
- // `InvokerContractAuthEntry::Contract(SubContractInvocation)`, not in the
168
- // enforce Context. Scope is per-authorized-context; a v1.1 sub-invocation
169
- // tree leaf is gated on the enforce-Context-shape spike result.
170
- | { kind: 'amount'; token: string }
171
- | { kind: 'window_spent'; token: string; windowSeconds: number }
172
- | { kind: 'now' }
173
- | { kind: 'valid_until' }
174
- | { kind: 'invocation_count_in_window'; windowSecs: number }
175
- | { kind: 'oracle_price'; asset: string } // Stellar Address (SAC address for SEP-41; XLM uses its network SAC)
176
- // The right-hand side of an oracle comparison. It carries its own decimal
177
- // basis because the contract cannot recover it: prices normalise to 9 dp, so
178
- // a raw 14-dp threshold is ~10^5 too large and `price <= threshold` becomes
179
- // trivially true. `value` is a decimal string on i128, like `literal_i128`.
180
- | { kind: 'oracle_threshold'; value: string; decimals: number }
181
153
  // Literal leaves: bare ScVal on the wire (no selector-tuple wrapper). Right-hand side of
182
154
  // comparisons, elements of `in` haystacks, and operands to future arithmetic nodes.
183
155
  | { kind: 'literal_address'; value: string }
184
156
  | { kind: 'literal_i128'; value: string }
185
157
  | { kind: 'literal_symbol'; value: string }
186
158
  | { kind: 'literal_u32'; value: number }
187
- | { kind: 'literal_u64'; value: string }
188
- | { kind: 'literal_bytes'; value: string } // hex string; wrapped as ScVal::Bytes at encode time
189
159
  // Exact ordered vector of literals (e.g. a swap hop path). Encodes to a bare
190
160
  // ScVal::Vec with the caller's order preserved verbatim - the order IS the
191
161
  // semantic. An exact ordered sequence equality is expressed by `eq(selector,
192
162
  // literal_vec([...]))`. `in` is reserved for pure set membership (sorted).
193
163
  | { kind: 'literal_vec'; elements: PredicateLeaf[] }
194
164
 
195
- export interface OZPrimitiveConfig {
196
- primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
197
- params: Record<string, unknown>
198
- }
199
-
200
165
  export interface ContractInvocation {
201
166
  contract: string
202
167
  fn: string
@@ -263,7 +228,6 @@ export type AmbiguityCode =
263
228
  | 'AMOUNT_BOUND_MISSING'
264
229
  | 'RECIPIENT_ALLOWLIST_EMPTY'
265
230
  | 'FREQUENCY_BOUND_MISSING'
266
- | 'ORACLE_ASSET_UNKNOWN'
267
231
  | 'MULTIPLE_UNRELATED_TARGETS'
268
232
 
269
233
  /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
@@ -1,96 +0,0 @@
1
- import { Contract } from '@stellar/stellar-sdk';
2
- import type { InstallRpcClient } from './build-install-policy.ts';
3
- /** Inputs for the install-predicate build. */
4
- export interface BuildInstallPredicateArgs {
5
- /** The smart account contract address (C...). The interpreter hashes
6
- * this rule's `signers` at install; we fetch it via `get_context_rule`
7
- * on this address, not reconstruct it. */
8
- smartAccount: string;
9
- /** The signer that authorises the install (G... wallet). */
10
- sourceAccount: string;
11
- /** The rule id the account assigned in call 1. */
12
- ruleId: number;
13
- /** The interpreter contract address (C...). */
14
- interpreterAddress: string;
15
- /** Network passphrase - pins the hash the auth digest binds. */
16
- networkPassphrase: string;
17
- /** Already-encoded (base64) canonical ScVal of the predicate. */
18
- encodedPredicate: string;
19
- /** Hex sha256 of the canonical predicate XDR bytes. */
20
- predicateHash: string;
21
- /** Grammar version override; defaults to 1. */
22
- grammarVersion?: number;
23
- /** Base fee in stroops; defaults to BASE_FEE (100). */
24
- baseFee?: number;
25
- /** RPC client to use for the simulation pass; injected for tests. */
26
- rpc: InstallRpcClient;
27
- /** Ledger window (in ledgers) for the auth entry's `validUntil`. */
28
- authValidUntilLedgers?: number;
29
- }
30
- /** Human-readable description of the install-predicate call, decoded FROM
31
- * the built XDR (not from the input args). The wallet signature binds
32
- * to bytes; the review card has to bind to the same bytes, so this is
33
- * the only safe source.
34
- *
35
- * `ruleId` is NOT decoded from the XDR - it lives inside
36
- * `raw_context_rule`, which is passed through verbatim. Decoding it
37
- * here would re-introduce the client-side reconstruction the file
38
- * header warns against. The top-level `BuildInstallPredicateResult`
39
- * echoes the caller-supplied rule id instead. */
40
- export interface InstallPredicateCallDescribes {
41
- /** The interpreter that will hold the predicate. */
42
- targetContract: string;
43
- /** The fn name on the target (always `install` today). */
44
- fnName: 'install';
45
- /** Grammar version the interpreter enforces; read off the wire. */
46
- grammarVersion: number;
47
- /** Per-rule install nonce; read off the wire. */
48
- installNonce: number;
49
- /** Hex sha256 of the encoded predicate blob; read off the wire. */
50
- predicateHash: string;
51
- /** Hex sha256 of the predicate bytes embedded in the params map. The
52
- * two values MUST match; a difference is a wire tampering signal. */
53
- predicateSha256OfEmbeddedBytes: string;
54
- /** The smart account address encoded in args[2], read off the wire. */
55
- smartAccountOnWire: string;
56
- }
57
- /** Output of the install-predicate build. The unsigned XDR is the
58
- * wallet's input; the wallet signs it (single envelope signature) and
59
- * submits. The auth tree matches install_policy: account entry
60
- * carrying OZ's AuthPayload + a source-account delegated signer entry.
61
- * Both are covered by the envelope signature. */
62
- export interface BuildInstallPredicateResult {
63
- /** Unsigned Soroban transaction envelope, base64 XDR. */
64
- unsignedXdr: string;
65
- /** Smart account contract address (echo). */
66
- smartAccount: string;
67
- /** Source account (echo) - the address that signs the envelope. */
68
- sourceAccount: string;
69
- /** The interpreter contract address (echo). */
70
- interpreterAddress: string;
71
- /** The rule id the account assigned in call 1 (echo). */
72
- ruleId: number;
73
- /** The host-call target + fn name. */
74
- call: {
75
- contract: string;
76
- fn: 'install';
77
- };
78
- /** The smart-account auth nonce (snapshot). */
79
- authNonce: string;
80
- /** The ledger sequence + window the auth entry expires at. */
81
- authValidUntilLedger: number;
82
- /** The captured rootInvocation for the smart-account entry. */
83
- rootInvocationXdr: string;
84
- /** Human-readable description of the install call, decoded FROM the
85
- * built unsigned XDR (not from the input args). The wallet signature
86
- * binds to bytes; the review card has to bind to the same bytes, so
87
- * this is the only safe source. */
88
- describes: InstallPredicateCallDescribes;
89
- }
90
- /** Build the unsigned transaction envelope for
91
- * `interpreter.install(params, raw_context_rule, smart_account)`. The
92
- * output XDR is signed by the wallet, not by us. */
93
- export declare function buildInstallPredicateXdr(args: BuildInstallPredicateArgs): Promise<BuildInstallPredicateResult>;
94
- /** Re-export `Contract` so the run-layer does not need to import the
95
- * SDK directly. Mirrors the build-install-policy.ts convention. */
96
- export { Contract };
@@ -1,444 +0,0 @@
1
- // src/install/build-install-predicate.ts - builds the unsigned Soroban
2
- // transaction XDR for `interpreter.install(params, raw_context_rule, smart_account)`.
3
- //
4
- // Call 2 of the install sequence. The OZ smart account's `add_context_rule`
5
- // (call 1, see build-install-policy.ts) attaches the rule to the account and
6
- // the interpreter POLICY address, but the interpreter itself needs the
7
- // encoded predicate bytes + the matching hash + the verbatim context rule
8
- // it will hash at enforce. Storing those is what `interpreter.install`
9
- // does; without it the interpreter returns MissingState and the rule denies
10
- // EVERYTHING - which on a deny-only demo is indistinguishable from a
11
- // working policy.
12
- //
13
- // CALL 1 / CALL 2 NONCE LIAISON (critical invariant, empirically verified
14
- // on testnet 2026-08-01):
15
- // - The OZ smart account's `add_context_rule` increments the
16
- // interpreter's stored nonce for `(smart_account, rule_id)` from 0
17
- // to 1. (Verified via getLedgerEntries after call 1.)
18
- // - `interpreter.install` requires
19
- // `install_params.install_nonce == stored_nonce.saturating_add(1)`.
20
- // - Therefore call 2 MUST derive its install_nonce from the stored
21
- // nonce via getLedgerEntries - taking it as caller input is a design
22
- // bug that produces NonceReplay (#202) for the caller every time
23
- // call 1 has bumped the counter. (octopos historically accepts an
24
- // arbitrary install_nonce and the user has to know to pass 2; this
25
- // builder reads the stored nonce instead.)
26
- //
27
- // AUTH TREE (empirically verified on testnet 2026-08-01):
28
- // The recording simulation of `interpreter.install` returns ONE
29
- // address-credential auth entry:
30
- // auth[0] credentials=address address=<SMART_ACCOUNT> root=install
31
- // (The previously hypothesised second entry for the rule signer does
32
- // not materialise on this pinned interpreter: `__check_auth` for the
33
- // rule id in `contextRuleIds` runs the rule's `policy.enforce`, but
34
- // because the rule's policy is the interpreter we are CALLING, the
35
- // interpreter's own `install` does not recurse into require_auth on
36
- // the rule signer; the only require_auth is the smart account's
37
- // `__check_auth` from the rule the auth payload names.)
38
- //
39
- // The auth tree is therefore the same shape as `buildInstallPolicyXdr`:
40
- // [0] accountEntry(original, validUntil, AuthPayload([sourceAccount], [ruleId], () => Bytes()))
41
- // [1] delegatedSignerEntry(smartAccount, digest)
42
- // The second entry is a `sorobanCredentialsSourceAccount` and the
43
- // source-account envelope signature covers it. The wallet signs only
44
- // the envelope. Verified on testnet 2026-08-01 against the pinned
45
- // interpreter CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP.
46
- //
47
- // CONTEXT RULE (empirically verified on testnet 2026-08-01):
48
- // `contextRuleIds` is the NEW rule id assigned in call 1 - exactly
49
- // what octopos's `installPredicateDoc` passes. Earlier reasoning
50
- // claimed rule 0 was required (chicken-and-egg: the new rule's
51
- // interpreter policy denies `install` because no predicate is
52
- // installed yet); that reasoning is wrong. The smart account
53
- // consults the rule's interpreter policy for the `install` host call
54
- // and the interpreter permits it - the rule's own install is a
55
- // privileged install-only path that the interpreter lets through. A
56
- // `[0]` choice was producing a structurally different auth tree
57
- // whose digest bound to the wrong rule and whose `AuthPayload`'s
58
- // context_rule_ids never matched the rule the interpreter recorded.
59
- //
60
- // `params` is an ScMap for a #[contracttype] struct. The host unpacks by
61
- // field count, so a missing or extra field traps with WasmVm UnexpectedSize
62
- // rather than a clean code. Keys MUST be in this sorted order:
63
- // grammar_version, install_nonce, oracle_max_deviation_bps,
64
- // oracle_max_staleness_seconds, oracle_max_xfeed_dev_bps,
65
- // predicate, predicate_hash
66
- //
67
- // `raw_context_rule` MUST be the ScVal `get_context_rule(ruleId)` returned,
68
- // passed through VERBATIM. The interpreter hashes `context_rule.signers` at
69
- // install and re-checks at enforce; any reconstruction that differs in any
70
- // field denies RULE_SIGNERS_CHANGED forever, bricking the rule. So this
71
- // builder fetches the rule itself - callers do not get to hand-build it.
72
- //
73
- // SECURITY INVARIANT: the auth_digest binds to networkPassphrase +
74
- // per-credential nonce + the call's root invocation + context_rule_ids.
75
- // Replaying the envelope against another call or another account is
76
- // impossible without re-signing.
77
- import { createHash } from 'node:crypto';
78
- import { Account, Address, BASE_FEE, Contract, Operation, rpc, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
79
- import { accountEntry, authDigest, authPayload, delegatedSignerEntry, signaturePayload, } from "./oz-auth.js";
80
- /** Build the unsigned transaction envelope for
81
- * `interpreter.install(params, raw_context_rule, smart_account)`. The
82
- * output XDR is signed by the wallet, not by us. */
83
- export async function buildInstallPredicateXdr(args) {
84
- // 1. Fetch the raw context rule from the smart account via simulation.
85
- // The interpreter hashes the rule's signers at install; we MUST hand
86
- // it the exact bytes the account holds, not a reconstruction.
87
- const rawContextRule = await fetchRawContextRule(args.rpc, args.sourceAccount, args.smartAccount, args.ruleId, args.networkPassphrase);
88
- // 2. Read the interpreter's stored nonce for this (smart_account,
89
- // rule_id) so call 2's install_nonce is exactly stored+1. Anything
90
- // else trips NonceReplay (#202). The key shape matches
91
- // `packages/policy-interpreter/src/storage.rs`:
92
- // K_NONCE: u32 = 2
93
- // key = (Address(smart_account), u32(rule_id), u32(2))
94
- // A value of `None` means stored_nonce = 0 (first install).
95
- const storedNonce = await fetchStoredNonce(args.rpc, args.interpreterAddress, args.smartAccount, args.ruleId);
96
- const installNonce = storedNonce + 1;
97
- // 3. Build the params ScMap. Field order is sorted by symbol string;
98
- // the host unpacks by field count and any missing/extra field traps
99
- // with WasmVm UnexpectedSize rather than a clean code.
100
- const grammarVersion = args.grammarVersion ?? 1;
101
- const predicateBytes = Buffer.from(args.encodedPredicate, 'base64');
102
- const predicateHashBytes = Buffer.from(args.predicateHash, 'hex');
103
- const params = buildInstallParams({
104
- grammarVersion,
105
- installNonce,
106
- predicateBytes,
107
- predicateHashBytes,
108
- });
109
- // 4. Build the host function. `interpreter.install(params, raw_rule,
110
- // smart_account)`.
111
- const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
112
- contractAddress: new Address(args.interpreterAddress).toScAddress(),
113
- functionName: 'install',
114
- args: [params, rawContextRule, new Address(args.smartAccount).toScVal()],
115
- }));
116
- const source = await args.rpc.getAccount(args.sourceAccount);
117
- const makeOperation = (auth = []) => Operation.invokeHostFunction({ func: hostFunction, auth });
118
- const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
119
- const buildTx = (op) => buildUnsignedTx({
120
- sourceAccount: args.sourceAccount,
121
- sequence: source.sequenceNumber(),
122
- fee: baseFee,
123
- networkPassphrase: args.networkPassphrase,
124
- op,
125
- });
126
- // 5. Recording simulation: capture the smart-account auth entry.
127
- // `contextRuleIds = [ruleId]` - the rule the account assigned in
128
- // call 1 - is what octopos's `installPredicateDoc` passes (see
129
- // apps/web/ui/octopos/smart-account-install.ts:313-320). The rule's
130
- // own interpreter policy permits the `install` host call (it is a
131
- // privileged install path), so authorising via the rule itself is
132
- // safe and is the only choice whose auth tree binds to the rule
133
- // the interpreter recorded.
134
- const recordingTx = buildTx(makeOperation());
135
- const recorded = await args.rpc.simulateTransaction(recordingTx);
136
- if (rpc.Api.isSimulationError(recorded)) {
137
- throw new Error('install_predicate: simulateTransaction failed');
138
- }
139
- const recordedAuth = recorded.result?.auth ?? [];
140
- const original = recordedAuth.find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
141
- Address.fromScAddress(entry.credentials().address().address()).toString() ===
142
- args.smartAccount);
143
- if (!original) {
144
- throw new Error(`install_predicate: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
145
- }
146
- // 6. Build the OZ auth entries (same shape as install_policy).
147
- // `contextRuleIds = [args.ruleId]` binds the AuthPayload's
148
- // context_rule_ids + the authDigest to the rule the interpreter
149
- // recorded. The delegated signer entry uses source-account
150
- // credentials so the envelope signature covers it; the wallet
151
- // supplies only the ordinary envelope signature.
152
- const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
153
- (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
154
- const contextRuleIds = [args.ruleId];
155
- const digest = authDigest(signaturePayload(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
156
- const authEntries = [
157
- accountEntry(original, validUntilLedger, authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
158
- ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
159
- ];
160
- // 7. Enforcing simulation + assemble. Same pattern as
161
- // buildInstallPolicyXdr: the SDK preserves the OZ auth entries
162
- // during assembly and adds the simulated Soroban footprint +
163
- // resource fee, yielding a complete unsigned envelope.
164
- const txWithAuth = buildTx(makeOperation(authEntries));
165
- const enforcing = await args.rpc.simulateTransaction(txWithAuth);
166
- if (rpc.Api.isSimulationError(enforcing)) {
167
- throw new Error('install_predicate: auth simulateTransaction failed');
168
- }
169
- const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build();
170
- // 8. Decode the structured description FROM the final assembled
171
- // transaction. The bytes are the source of truth.
172
- const describes = decodeInstallPredicateCallDescribes(finalTx, args.predicateHash);
173
- return {
174
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
175
- smartAccount: args.smartAccount,
176
- sourceAccount: args.sourceAccount,
177
- interpreterAddress: args.interpreterAddress,
178
- ruleId: args.ruleId,
179
- call: { contract: args.interpreterAddress, fn: 'install' },
180
- authNonce: original.credentials().address().nonce().toString(),
181
- authValidUntilLedger: validUntilLedger,
182
- rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
183
- describes,
184
- };
185
- }
186
- // ---- internals ----
187
- /** ~25 minutes at 5s/ledger. Same default as build-install-policy so the
188
- * two-call sequence can be signed inside the same window without a
189
- * fresh source account bump between calls. */
190
- const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
191
- /** K_NONCE constant from packages/policy-interpreter/src/storage.rs.
192
- * The interpreter's persistent-storage key shape is the tuple
193
- * `(Address, u32, u32)` where the third u32 is the namespace. We hard-
194
- * code 2 here because the interpreter source is pinned and out of this
195
- * builder's scope; a drift test should assert these stay in lockstep
196
- * if the storage layout ever changes. */
197
- const K_NONCE = 2;
198
- /** Read the interpreter's stored nonce for `(smart_account, rule_id)`
199
- * via a direct ledger read. No public getter exists on the pinned
200
- * interpreter, so we read the contract-data ledger entry directly.
201
- * Returns 0 when the entry is absent (a fresh (account, rule_id) that
202
- * has never been installed). Used to derive `install_nonce =
203
- * stored_nonce + 1` for `interpreter.install`.
204
- *
205
- * The InstallRpcClient interface intentionally omits `getLedgerEntries`
206
- * (the install pipeline uses getAccount / simulateTransaction /
207
- * getLatestLedger only); we hit the real Soroban RPC through the
208
- * shared sdk module rather than extending the interface, since this
209
- * single read keeps the read surface scoped.
210
- *
211
- * If the read errors (network blip, pinned interpreter still deploying,
212
- * etc.) we treat the read as if no entry exists and return 0 - the
213
- * enforcing simulation will fail closed on NonceReplay if we got it
214
- * wrong, with a clean error the caller can act on. */
215
- async function fetchStoredNonce(rpc, interpreterAddress, smartAccount, ruleId) {
216
- // Recover the underlying RPC URL by probing `getLatestLedger` - this
217
- // works for both the real-server and mock clients (mocks return a
218
- // sequence but not a URL). For tests, this returns 0 unless the mock
219
- // also surfaces an URL. We accept the limit: tests for the install
220
- // pipeline drive the nonce via the recorded context_rule rather than
221
- // this read (see build-install-predicate.test.ts).
222
- //
223
- // Production reads go through the Soroban RPC directly. We import the
224
- // sdk module lazily here so the test-time mock surface does not have
225
- // to grow.
226
- const { rpc: sdkRpc } = await import('@stellar/stellar-sdk');
227
- // The InstallRpcClient does not carry the URL; the run-layer wires a
228
- // real rpc.Server for production. We construct one from the pinned
229
- // TESTNET_RPC_URL here. This is hard-coded because the read surface
230
- // is testnet-only today; mainnet would need its own pinned address
231
- // and a separate URL pin.
232
- const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org';
233
- const server = new sdkRpc.Server(TESTNET_RPC_URL, { allowHttp: false });
234
- const key = xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
235
- contract: new Address(interpreterAddress).toScAddress(),
236
- key: xdr.ScVal.scvVec([
237
- new Address(smartAccount).toScVal(),
238
- xdr.ScVal.scvU32(ruleId),
239
- xdr.ScVal.scvU32(K_NONCE),
240
- ]),
241
- durability: xdr.ContractDataDurability.persistent(),
242
- }));
243
- try {
244
- const r = await server.getLedgerEntries(key);
245
- if (!r.entries || r.entries.length === 0)
246
- return 0;
247
- const entry = r.entries[0];
248
- if (!entry)
249
- return 0;
250
- const data = entry.val?.contractData();
251
- if (!data)
252
- return 0;
253
- const val = data.val();
254
- if (val.switch().name === 'scvU32')
255
- return val.u32();
256
- return 0;
257
- }
258
- catch {
259
- // Treat ledger read errors as "no prior install". The enforcing
260
- // simulation will fail closed with NonceReplay if the assumption
261
- // is wrong, surfacing a clean error to the caller.
262
- void rpc;
263
- return 0;
264
- }
265
- }
266
- /** Fetch the verbatim ScVal the smart account holds for a given rule id.
267
- * Used by `interpreter.install` (call 2): the interpreter hashes
268
- * `context_rule.signers` at install and re-checks at enforce, so any
269
- * client-side reconstruction that differs in any field makes the rule
270
- * deny RULE_SIGNERS_CHANGED forever. We round-trip through a `simulate`
271
- * of `get_context_rule(ruleId)` and return the raw retval. */
272
- async function fetchRawContextRule(rpcClient, sourceAccount, smartAccount, ruleId, networkPassphrase) {
273
- const source = await rpcClient.getAccount(sourceAccount);
274
- const op = Operation.invokeHostFunction({
275
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
276
- contractAddress: new Address(smartAccount).toScAddress(),
277
- functionName: 'get_context_rule',
278
- args: [xdr.ScVal.scvU32(ruleId)],
279
- })),
280
- auth: [],
281
- });
282
- const tx = new TransactionBuilder(new Account(sourceAccount, source.sequenceNumber()), {
283
- fee: BASE_FEE,
284
- networkPassphrase,
285
- })
286
- .addOperation(op)
287
- .setTimeout(0)
288
- .build();
289
- const sim = await rpcClient.simulateTransaction(tx);
290
- if (rpc.Api.isSimulationError(sim)) {
291
- throw new Error(`install_predicate: get_context_rule(${ruleId}) simulateTransaction failed: ${sim.error}`);
292
- }
293
- const retval = sim.result?.retval;
294
- if (!retval) {
295
- throw new Error(`install_predicate: get_context_rule(${ruleId}) returned no value`);
296
- }
297
- return retval;
298
- }
299
- /** Build the params ScMap for `interpreter.install`. Keys MUST be sorted
300
- * by symbol string (the canonical map encoding requires it). The host
301
- * unpacks by field count, so a missing or extra field traps with WasmVm
302
- * UnexpectedSize rather than a clean code. */
303
- function buildInstallParams(args) {
304
- const entry = (k, v) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(k), val: v });
305
- // Sorted order: grammar_version, install_nonce, oracle_max_deviation_bps,
306
- // oracle_max_staleness_seconds, oracle_max_xfeed_dev_bps, predicate,
307
- // predicate_hash.
308
- return xdr.ScVal.scvMap([
309
- entry('grammar_version', xdr.ScVal.scvU32(args.grammarVersion)),
310
- entry('install_nonce', xdr.ScVal.scvU32(args.installNonce)),
311
- entry('oracle_max_deviation_bps', xdr.ScVal.scvVoid()),
312
- entry('oracle_max_staleness_seconds', xdr.ScVal.scvVoid()),
313
- entry('oracle_max_xfeed_dev_bps', xdr.ScVal.scvVoid()),
314
- entry('predicate', xdr.ScVal.scvBytes(args.predicateBytes)),
315
- entry('predicate_hash', xdr.ScVal.scvBytes(args.predicateHashBytes)),
316
- ]);
317
- }
318
- /** Build an unsigned Soroban transaction envelope. Same pattern as
319
- * build-install-policy.ts so the consumer code path is uniform. */
320
- function buildUnsignedTx(args) {
321
- const account = new Account(args.sourceAccount, args.sequence);
322
- return new TransactionBuilder(account, {
323
- fee: args.fee,
324
- networkPassphrase: args.networkPassphrase,
325
- })
326
- .addOperation(args.op)
327
- .setTimeout(0)
328
- .build();
329
- }
330
- /** Decode the install-predicate call's structured fields directly out of
331
- * the final assembled transaction. The bytes are the source of truth:
332
- * a human reviewing the install wants to see what the wallet will
333
- * actually sign, not what we think it will. The decode is deliberately
334
- * strict - any shape we did not build ourselves throws, since that
335
- * would mean the XDR was tampered with (or the encoder changed and
336
- * this descriptor lags).
337
- *
338
- * The second argument (raw context rule) is passed through verbatim;
339
- * we do NOT decode it here because reconstructing the rule's fields
340
- * client-side is exactly what the file header warns against. The rule
341
- * id lives inside it and is echoed from input at the top-level result. */
342
- function decodeInstallPredicateCallDescribes(tx, expectedPredicateHash) {
343
- const operations = tx.toEnvelope().v1().tx().operations();
344
- if (operations.length !== 1 || !operations[0]) {
345
- throw new Error(`install_predicate: final transaction has ${operations.length} operations, expected 1`);
346
- }
347
- const op = operations[0];
348
- const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
349
- if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
350
- throw new Error('install_predicate: built op is not an invokeHostFunction(invokeContract(...)) call');
351
- }
352
- const invokeArgs = hostFn.invokeContract();
353
- if (!invokeArgs) {
354
- throw new Error('install_predicate: built op has no InvokeContractArgs payload');
355
- }
356
- const targetContract = Address.fromScAddress(invokeArgs.contractAddress()).toString();
357
- const fnName = invokeArgs.functionName().toString();
358
- if (fnName !== 'install') {
359
- throw new Error(`install_predicate: built op fn name is "${fnName}", expected "install"`);
360
- }
361
- const scvArgs = invokeArgs.args();
362
- if (scvArgs.length !== 3) {
363
- throw new Error(`install_predicate: built op has ${scvArgs.length} args, expected 3 (params, raw_context_rule, smart_account)`);
364
- }
365
- const paramsScv = scvArgs[0];
366
- const rawContextRuleScv = scvArgs[1];
367
- const smartAccountScv = scvArgs[2];
368
- if (!paramsScv || !rawContextRuleScv || !smartAccountScv) {
369
- throw new Error('install_predicate: built op args include an undefined slot despite length check');
370
- }
371
- // params: an ScMap with seven fields (sorted by symbol).
372
- if (paramsScv.switch().name !== 'scvMap') {
373
- throw new Error('install_predicate: args[0] (params) is not an ScVal::Map');
374
- }
375
- const paramsFields = new Map();
376
- for (const inner of paramsScv.map() ?? []) {
377
- const fk = inner.key();
378
- if (fk.switch().name !== 'scvSymbol') {
379
- throw new Error('install_predicate: params field key is not an ScVal::Symbol');
380
- }
381
- paramsFields.set(fk.sym().toString(), inner.val());
382
- }
383
- const EXPECTED_PARAMS_KEYS = [
384
- 'grammar_version',
385
- 'install_nonce',
386
- 'oracle_max_deviation_bps',
387
- 'oracle_max_staleness_seconds',
388
- 'oracle_max_xfeed_dev_bps',
389
- 'predicate',
390
- 'predicate_hash',
391
- ];
392
- const actualKeys = [...paramsFields.keys()].sort();
393
- if (actualKeys.length !== EXPECTED_PARAMS_KEYS.length ||
394
- actualKeys.some((k, i) => k !== EXPECTED_PARAMS_KEYS[i])) {
395
- throw new Error(`install_predicate: params keys ${JSON.stringify(actualKeys)} do not match the canonical sorted set ${JSON.stringify(EXPECTED_PARAMS_KEYS)}`);
396
- }
397
- const grammarVersionScv = paramsFields.get('grammar_version');
398
- if (!grammarVersionScv || grammarVersionScv.switch().name !== 'scvU32') {
399
- throw new Error('install_predicate: params.grammar_version is missing or not u32');
400
- }
401
- const grammarVersion = grammarVersionScv.u32();
402
- const installNonceScv = paramsFields.get('install_nonce');
403
- if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
404
- throw new Error('install_predicate: params.install_nonce is missing or not u32');
405
- }
406
- const installNonce = installNonceScv.u32();
407
- const predicateScv = paramsFields.get('predicate');
408
- if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
409
- throw new Error('install_predicate: params.predicate is missing or not bytes');
410
- }
411
- const predicateBytes = Buffer.from(predicateScv.bytes());
412
- const predicateSha256OfEmbeddedBytes = createHash('sha256').update(predicateBytes).digest('hex');
413
- const predicateHashScv = paramsFields.get('predicate_hash');
414
- if (!predicateHashScv || predicateHashScv.switch().name !== 'scvBytes') {
415
- throw new Error('install_predicate: params.predicate_hash is missing or not bytes');
416
- }
417
- const predicateHash = Buffer.from(predicateHashScv.bytes()).toString('hex');
418
- if (predicateHash !== expectedPredicateHash) {
419
- throw new Error(`install_predicate: params.predicate_hash (${predicateHash}) differs from the caller-supplied hash (${expectedPredicateHash})`);
420
- }
421
- if (rawContextRuleScv.switch().name === 'scvVoid') {
422
- throw new Error('install_predicate: args[1] (raw_context_rule) is scvVoid');
423
- }
424
- if (smartAccountScv.switch().name !== 'scvAddress') {
425
- throw new Error('install_predicate: args[2] (smart_account) is not an ScVal::Address');
426
- }
427
- const smartAccountOnWire = Address.fromScAddress(smartAccountScv.address()).toString();
428
- return {
429
- targetContract,
430
- fnName: 'install',
431
- grammarVersion,
432
- installNonce,
433
- predicateHash,
434
- predicateSha256OfEmbeddedBytes,
435
- smartAccountOnWire,
436
- };
437
- }
438
- /** Re-export `Contract` so the run-layer does not need to import the
439
- * SDK directly. Mirrors the build-install-policy.ts convention. */
440
- export { Contract };
441
- // Suppress the unused-import warning for K_NONCE while keeping the
442
- // constant visible for the drift test it invites. (Removed once a
443
- // drift test is in place that references it.)
444
- void K_NONCE;