@crediolabs/policy-synth 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/adapters/interpreter/adapter.d.ts +2 -2
  2. package/dist/adapters/interpreter/adapter.js +11 -3
  3. package/dist/errors.d.ts +6 -1
  4. package/dist/install/authority-overlap.js +12 -0
  5. package/dist/install/build-add-context-rule.d.ts +1 -1
  6. package/dist/install/read-account-rules.d.ts +79 -0
  7. package/dist/install/read-account-rules.js +241 -0
  8. package/dist/predicate/decode.js +22 -1
  9. package/dist/predicate/encode.js +52 -5
  10. package/dist/predicate/from-json.js +21 -1
  11. package/dist/review-card/builder.js +40 -0
  12. package/dist/review-card/cross-check.js +34 -0
  13. package/dist/review-card/render-leaf.d.ts +1 -1
  14. package/dist/review-card/render-leaf.js +7 -0
  15. package/dist/run/index.d.ts +2 -2
  16. package/dist/run/index.js +52 -9
  17. package/dist/run/schemas.d.ts +64 -14
  18. package/dist/run/schemas.js +70 -6
  19. package/dist/simulate/deny-cases.js +11 -0
  20. package/dist/simulate/evaluate.js +86 -5
  21. package/dist/synth/declare.d.ts +13 -0
  22. package/dist/synth/declare.js +34 -5
  23. package/dist/synth/synthesize-from-recording.js +1 -1
  24. package/dist/types.d.ts +21 -1
  25. package/dist/types.js +1 -1
  26. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  27. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  28. package/dist-cjs/errors.d.ts +6 -1
  29. package/dist-cjs/install/authority-overlap.js +12 -0
  30. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  31. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  32. package/dist-cjs/install/read-account-rules.js +252 -0
  33. package/dist-cjs/predicate/decode.js +22 -1
  34. package/dist-cjs/predicate/encode.js +52 -5
  35. package/dist-cjs/predicate/from-json.js +21 -1
  36. package/dist-cjs/review-card/builder.js +40 -0
  37. package/dist-cjs/review-card/cross-check.js +34 -0
  38. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  39. package/dist-cjs/review-card/render-leaf.js +7 -0
  40. package/dist-cjs/run/index.d.ts +2 -2
  41. package/dist-cjs/run/index.js +54 -9
  42. package/dist-cjs/run/schemas.d.ts +64 -14
  43. package/dist-cjs/run/schemas.js +71 -7
  44. package/dist-cjs/simulate/deny-cases.js +11 -0
  45. package/dist-cjs/simulate/evaluate.js +86 -5
  46. package/dist-cjs/synth/declare.d.ts +13 -0
  47. package/dist-cjs/synth/declare.js +34 -5
  48. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  49. package/dist-cjs/types.d.ts +21 -1
  50. package/dist-cjs/types.js +1 -1
  51. package/package.json +1 -1
  52. package/src/adapters/interpreter/adapter.ts +13 -5
  53. package/src/errors.ts +5 -0
  54. package/src/install/authority-overlap.ts +11 -0
  55. package/src/install/read-account-rules.ts +313 -0
  56. package/src/predicate/decode.ts +22 -1
  57. package/src/predicate/encode.ts +55 -5
  58. package/src/predicate/from-json.ts +21 -1
  59. package/src/review-card/builder.ts +45 -2
  60. package/src/review-card/cross-check.ts +35 -1
  61. package/src/review-card/render-leaf.ts +8 -1
  62. package/src/run/index.ts +57 -8
  63. package/src/run/schemas.ts +83 -6
  64. package/src/simulate/deny-cases.ts +12 -1
  65. package/src/simulate/evaluate.ts +101 -9
  66. package/src/synth/declare.ts +54 -5
  67. package/src/synth/synthesize-from-recording.ts +1 -1
  68. package/src/types.ts +16 -1
@@ -16,11 +16,12 @@
16
16
  // first: it constrains the amount in THIS call rather than implying a rolling
17
17
  // total nothing tracks.
18
18
  //
19
- // What a declaration can say maps one-to-one onto grammar 3:
20
- // fn -> eq(call_fn, literal_symbol)
21
- // contract -> eq(call_contract, literal_address)
22
- // maxAmount -> lte(call_arg(i), literal_i128)
23
- // recipients -> in(call_arg(j), [literal_address, ...])
19
+ // What a declaration can say maps one-to-one onto grammar 4:
20
+ // fn -> eq(call_fn, literal_symbol)
21
+ // contract -> eq(call_contract, literal_address)
22
+ // maxAmount -> lte(call_arg(i), literal_i128)
23
+ // recipients -> in(call_arg(j), [literal_address, ...])
24
+ // minOutputRatio-> gte(call_arg(out), call_arg_scaled(in, num, den))
24
25
  Object.defineProperty(exports, "__esModule", { value: true });
25
26
  exports.declarePredicate = declarePredicate;
26
27
  const address_ts_1 = require("./address.js");
@@ -87,6 +88,34 @@ function declarePredicate(d) {
87
88
  right: { kind: 'literal_i128', value: d.maxAmount },
88
89
  });
89
90
  }
91
+ if (d.minOutputRatio !== undefined) {
92
+ const { num, den, inputArgIndex, outputArgIndex } = d.minOutputRatio;
93
+ if (!/^[0-9]+$/.test(num) || !/^[0-9]+$/.test(den)) {
94
+ throw declareError('SYNTHESIS_ERROR', `minOutputRatio num/den must be unsigned integers, got "${num}"/"${den}" (a 1% slippage tolerance is num "99", den "100")`);
95
+ }
96
+ // Both are refused on chain at install (INVALID_SCALED_RATIO). Refusing
97
+ // here too means the caller learns before a transaction is built.
98
+ if (den === '0') {
99
+ throw declareError('SYNTHESIS_ERROR', 'minOutputRatio.den is zero: the ratio has no value');
100
+ }
101
+ if (num === '0') {
102
+ throw declareError('SYNTHESIS_ERROR', 'minOutputRatio.num is zero: the floor would be zero, which constrains nothing. Omit it instead.');
103
+ }
104
+ if (inputArgIndex === outputArgIndex) {
105
+ throw declareError('SYNTHESIS_ERROR', `minOutputRatio bounds arg[${inputArgIndex}] against itself, which is true for any ratio at or below 1 and false above it - never a slippage floor. Pass the distinct input and output positions.`);
106
+ }
107
+ if (BigInt(num) > BigInt(den)) {
108
+ // Demanding MORE out than went in is not slippage protection; it is a
109
+ // rule that denies every honest trade. Loud beats a policy that never
110
+ // permits.
111
+ warnings.push(`minOutputRatio ${num}/${den} is above 1: it requires the output to EXCEED the input, which no ordinary swap satisfies. Check the ratio is not inverted.`);
112
+ }
113
+ children.push({
114
+ op: 'gte',
115
+ left: { kind: 'call_arg', index: outputArgIndex },
116
+ right: { kind: 'call_arg_scaled', index: inputArgIndex, num, den },
117
+ });
118
+ }
90
119
  // A single conjunct is emitted bare. `and` with one child encodes to
91
120
  // different bytes than the child alone, and the extra node buys nothing.
92
121
  const predicate = children.length === 1 ? children[0] : { op: 'and', children };
@@ -2,7 +2,7 @@
2
2
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
3
3
  //
4
4
  // `synthesizeFromRecording` INFERS a bounded policy from a `RecordedTransaction`
5
- // via the `PolicyIR` + interpreter adapter pair.
5
+ // via the composer + interpreter adapter pair.
6
6
  // Flow: validate -> parseConfidence gate -> lower -> decideScope -> composeFromRecording
7
7
  // -> interpreter compile + self-verify.
8
8
  // Same (tx, opts) -> byte-identical ProposedPolicy (no randomness, clock, globals).
@@ -124,7 +124,7 @@ export type PolicyRef = {
124
124
  * `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
125
125
  * the wire derives from here; `grammar-version-parity.test.ts` pins it to the
126
126
  * contract so a skew cannot pass a green test run. */
127
- export declare const GRAMMAR_VERSION: 3;
127
+ export declare const GRAMMAR_VERSION: 4;
128
128
  /** A serialised predicate document stored against a (smart_account, rule_id) pair. */
129
129
  export interface PolicyDocument {
130
130
  /** Grammar version baked into the interpreter wasm. Fail-closed strict match at install
@@ -146,14 +146,29 @@ export interface PolicyDocument {
146
146
  export type PredicateNode = {
147
147
  op: 'and';
148
148
  children: PredicateNode[];
149
+ } | {
150
+ op: 'or';
151
+ children: PredicateNode[];
149
152
  } | {
150
153
  op: 'eq';
151
154
  left: PredicateLeaf;
152
155
  right: PredicateLeaf;
156
+ } | {
157
+ op: 'lt';
158
+ left: PredicateLeaf;
159
+ right: PredicateLeaf;
153
160
  } | {
154
161
  op: 'lte';
155
162
  left: PredicateLeaf;
156
163
  right: PredicateLeaf;
164
+ } | {
165
+ op: 'gt';
166
+ left: PredicateLeaf;
167
+ right: PredicateLeaf;
168
+ } | {
169
+ op: 'gte';
170
+ left: PredicateLeaf;
171
+ right: PredicateLeaf;
157
172
  } | {
158
173
  op: 'in';
159
174
  needle: PredicateLeaf;
@@ -174,6 +189,11 @@ export type PredicateLeaf = {
174
189
  index: number;
175
190
  element: number;
176
191
  field: string;
192
+ } | {
193
+ kind: 'call_arg_scaled';
194
+ index: number;
195
+ num: string;
196
+ den: string;
177
197
  } | {
178
198
  kind: 'literal_address';
179
199
  value: string;
package/dist-cjs/types.js CHANGED
@@ -55,4 +55,4 @@ exports.PREDICATE_CAPS = {
55
55
  * `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
56
56
  * the wire derives from here; `grammar-version-parity.test.ts` pins it to the
57
57
  * contract so a skew cannot pass a green test run. */
58
- exports.GRAMMAR_VERSION = 3;
58
+ exports.GRAMMAR_VERSION = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
- // src/adapters/interpreter/adapter.ts - the interpreter-policy CustodyAdapter.
1
+ // src/adapters/interpreter/adapter.ts - the interpreter-policy adapter.
2
2
  //
3
- // Compiles a PolicyIR to a single interpreter `PolicyDocument` + `PolicyRef`
3
+ // Compiles a `ComposedRule` to a single interpreter `PolicyDocument` + `PolicyRef`
4
4
  // carrying the canonical predicate encoding from `predicate/encode.ts`. This is
5
5
  // the only backend; the compose step lowers every constraint to it.
6
6
  //
@@ -41,9 +41,9 @@ export interface InterpreterAdapterConfig {
41
41
  smartAccountAddress: string
42
42
  }
43
43
 
44
- /** The result of compiling a PolicyIR for this backend. */
44
+ /** The result of compiling a `ComposedRule` for this backend. */
45
45
  export interface CompileResult {
46
- /** false => some IR construct this backend cannot express (see `uncovered`). */
46
+ /** false => some constraint this backend cannot express (see `uncovered`). */
47
47
  covered: boolean
48
48
  /** Human-readable list of unsupported constructs. */
49
49
  uncovered: string[]
@@ -195,14 +195,22 @@ function assertNoSelfCall(node: PredicateNode, config: InterpreterAdapterConfig)
195
195
  }
196
196
  }
197
197
  switch (node.op) {
198
+ // Both boolean nodes recurse. `or` is listed explicitly rather than left
199
+ // to a default branch: this is a security check, and a new node kind must
200
+ // fail to compile here rather than silently skip it.
198
201
  case 'and':
202
+ case 'or':
199
203
  for (const c of node.children) assertNoSelfCall(c, config)
200
204
  return
201
205
  case 'in':
202
206
  // The needle is a selector; only the haystack carries literals.
203
207
  for (const h of node.haystack) checkLeaf(h)
204
208
  return
205
- default:
209
+ case 'eq':
210
+ case 'lt':
211
+ case 'lte':
212
+ case 'gt':
213
+ case 'gte':
206
214
  checkLeaf(node.left)
207
215
  checkLeaf(node.right)
208
216
  }
package/src/errors.ts CHANGED
@@ -43,6 +43,11 @@ export type ErrorCode =
43
43
  | 'UNSUPPORTED_NODE'
44
44
  | 'STATEFUL_BOUND'
45
45
  | 'NOT_IN_ALLOWLIST'
46
+ /** A comparison against a `call_arg_scaled` operand failed (contract 107). */
47
+ | 'SLIPPAGE_FLOOR'
48
+ /** A `call_arg_scaled` ratio is zero or non-positive. Refused at install
49
+ * (contract 214) and mirrored at encode. */
50
+ | 'INVALID_SCALED_RATIO'
46
51
  // --- escape-hatch compile gate ---
47
52
  | 'COMPILE_OK'
48
53
  | 'COMPILE_GATE_FAILED'
@@ -200,6 +200,17 @@ export function permittedSelectors(node: PredicateNode): Selector[] {
200
200
  for (const child of node.children) acc = intersectSelectors(acc, permittedSelectors(child))
201
201
  return acc
202
202
  }
203
+ case 'or': {
204
+ // Any branch may hold, so the permitted set is the UNION. A union of
205
+ // over-approximations is still an over-approximation, so this keeps the
206
+ // fail-safe direction while staying tighter than the wildcard the
207
+ // default branch would give. Precision matters here: `or` is how a
208
+ // policy says "pair A or pair B", and widening that to the wildcard
209
+ // would report an overlap against every rule on the account.
210
+ const acc: Selector[] = []
211
+ for (const child of node.children) acc.push(...permittedSelectors(child))
212
+ return dedupe(acc)
213
+ }
203
214
  case 'eq': {
204
215
  const sel = selectorFromEq(node.left, node.right)
205
216
  return sel === null ? [WILDCARD] : [sel]
@@ -0,0 +1,313 @@
1
+ //! Reading an OpenZeppelin smart account's context rules back off chain.
2
+ //!
3
+ //! `authority-overlap.ts` needs to know what a signer can already do before a
4
+ //! new policy is installed. That means every rule on the account: its context
5
+ //! type, its signers, its attached policies, and - for rules our interpreter
6
+ //! polices - the predicate itself.
7
+ //!
8
+ //! Without this, the overlap scan can only report on rules the CALLER supplied,
9
+ //! which means it answers "what did you tell me about" rather than "what is on
10
+ //! the account". Those are different questions, and only the second one is
11
+ //! worth anything to someone deciding whether to sign.
12
+ //!
13
+ //! The predicate is NOT reachable through a contract call. The interpreter
14
+ //! publishes only `grammar_version`, `install`, `enforce`, `uninstall` and
15
+ //! `rotate_master_signer_set`, so the stored document is read as a ledger entry
16
+ //! instead. That keeps this a purely client-side capability: adding a getter
17
+ //! would change a deployed contract's ABI and force a redeploy plus re-audit to
18
+ //! obtain data the ledger already exposes.
19
+ //!
20
+ //! The decoders are pure so they can be tested without a network; the caller
21
+ //! supplies raw `ScVal`s.
22
+
23
+ import {
24
+ Account,
25
+ Address,
26
+ BASE_FEE,
27
+ Contract,
28
+ Keypair,
29
+ rpc,
30
+ TransactionBuilder,
31
+ xdr,
32
+ } from '@stellar/stellar-sdk'
33
+ import { decodePredicate } from '../predicate/decode.ts'
34
+ import type { SignerDraft } from '../types.ts'
35
+ import type { ContextType, ObservedRule } from './authority-overlap.ts'
36
+
37
+ /** `storage.rs` - the third element of the persistent doc key tuple. */
38
+ export const K_DOC = 1
39
+
40
+ /** Persistent-storage key for a rule's stored document:
41
+ * `(account, rule_id, K_DOC)`. */
42
+ export function docKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal {
43
+ return xdr.ScVal.scvVec([
44
+ new Address(smartAccount).toScVal(),
45
+ xdr.ScVal.scvU32(ruleId),
46
+ xdr.ScVal.scvU32(K_DOC),
47
+ ])
48
+ }
49
+
50
+ /** Ledger key for the interpreter's persistent entry holding that document. */
51
+ export function docLedgerKey(
52
+ interpreter: string,
53
+ smartAccount: string,
54
+ ruleId: number
55
+ ): xdr.LedgerKey {
56
+ return xdr.LedgerKey.contractData(
57
+ new xdr.LedgerKeyContractData({
58
+ contract: new Address(interpreter).toScAddress(),
59
+ key: docKeyScVal(smartAccount, ruleId),
60
+ durability: xdr.ContractDataDurability.persistent(),
61
+ })
62
+ )
63
+ }
64
+
65
+ // ---- ScVal helpers -----
66
+
67
+ /** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
68
+ * by field-name symbol. Returns undefined when the field is absent so a
69
+ * caller can distinguish "not there" from "there and empty". */
70
+ function mapField(v: xdr.ScVal, name: string): xdr.ScVal | undefined {
71
+ if (v.switch() !== xdr.ScValType.scvMap()) return undefined
72
+ for (const entry of v.map() ?? []) {
73
+ const key = entry.key()
74
+ if (key.switch() === xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
75
+ return entry.val()
76
+ }
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ function u32Of(v: xdr.ScVal | undefined): number | undefined {
82
+ return v?.switch() === xdr.ScValType.scvU32() ? v.u32() : undefined
83
+ }
84
+
85
+ function addressOf(v: xdr.ScVal | undefined): string | undefined {
86
+ if (!v || v.switch() !== xdr.ScValType.scvAddress()) return undefined
87
+ return Address.fromScAddress(v.address()).toString()
88
+ }
89
+
90
+ /** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
91
+ function enumVariant(v: xdr.ScVal | undefined): { tag: string; args: xdr.ScVal[] } | undefined {
92
+ if (!v || v.switch() !== xdr.ScValType.scvVec()) return undefined
93
+ const items = v.vec() ?? []
94
+ const head = items[0]
95
+ if (!head || head.switch() !== xdr.ScValType.scvSymbol()) return undefined
96
+ return { tag: head.sym().toString(), args: items.slice(1) }
97
+ }
98
+
99
+ // ---- decoders -----
100
+
101
+ /** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
102
+ * is the widest reading and therefore the safe one: it makes the rule look
103
+ * like it could serve any call, so overlap is over-reported, never missed. */
104
+ export function decodeContextType(v: xdr.ScVal | undefined): ContextType {
105
+ const variant = enumVariant(v)
106
+ if (!variant) return { kind: 'default' }
107
+ if (variant.tag === 'CallContract') {
108
+ const addr = addressOf(variant.args[0])
109
+ return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' }
110
+ }
111
+ if (variant.tag === 'CreateContract') {
112
+ const arg = variant.args[0]
113
+ const hash = arg?.switch() === xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : ''
114
+ return { kind: 'create_contract', wasmHash: hash }
115
+ }
116
+ return { kind: 'default' }
117
+ }
118
+
119
+ /** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
120
+ export function decodeSigner(v: xdr.ScVal): SignerDraft | undefined {
121
+ const variant = enumVariant(v)
122
+ if (!variant) return undefined
123
+ if (variant.tag === 'Delegated') {
124
+ const addr = addressOf(variant.args[0])
125
+ return addr ? { kind: 'delegated', address: addr } : undefined
126
+ }
127
+ if (variant.tag === 'External') {
128
+ const verifier = addressOf(variant.args[0])
129
+ const keyArg = variant.args[1]
130
+ const keyBytes =
131
+ keyArg?.switch() === xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : ''
132
+ return verifier ? { kind: 'external', verifier, keyBytes } : undefined
133
+ }
134
+ return undefined
135
+ }
136
+
137
+ /** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
138
+ * `predicate` is filled in separately from the ledger entry. */
139
+ export function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined {
140
+ const id = u32Of(mapField(v, 'id'))
141
+ if (id === undefined) return undefined
142
+
143
+ const signersVal = mapField(v, 'signers')
144
+ const signers: SignerDraft[] = []
145
+ if (signersVal?.switch() === xdr.ScValType.scvVec()) {
146
+ for (const s of signersVal.vec() ?? []) {
147
+ const decoded = decodeSigner(s)
148
+ if (decoded) signers.push(decoded)
149
+ }
150
+ }
151
+
152
+ const policiesVal = mapField(v, 'policies')
153
+ const policyAddresses: string[] = []
154
+ if (policiesVal?.switch() === xdr.ScValType.scvVec()) {
155
+ for (const p of policiesVal.vec() ?? []) {
156
+ const addr = addressOf(p)
157
+ if (addr) policyAddresses.push(addr)
158
+ }
159
+ }
160
+
161
+ return {
162
+ id,
163
+ contextType: decodeContextType(mapField(v, 'context_type')),
164
+ signers,
165
+ policyAddresses,
166
+ }
167
+ }
168
+
169
+ /** The interpreter's `StoredDoc { predicate_bytes }`. */
170
+ export function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined {
171
+ const field = mapField(v, 'predicate_bytes')
172
+ if (!field || field.switch() !== xdr.ScValType.scvBytes()) return undefined
173
+ return field.bytes()
174
+ }
175
+
176
+ // ---- collection -----
177
+
178
+ /** The three reads the scan needs. Kept as an interface so the collection
179
+ * below is testable without a network. */
180
+ export interface AccountRuleReader {
181
+ /** OZ `get_context_rules_count()`. */
182
+ getContextRuleCount(smartAccount: string): Promise<number>
183
+ /** OZ `get_context_rule(id)`. Undefined when the id is absent. */
184
+ getContextRule(smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>
185
+ /** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
186
+ * Undefined when no document is stored for that rule. */
187
+ getStoredDoc(
188
+ interpreter: string,
189
+ smartAccount: string,
190
+ ruleId: number
191
+ ): Promise<xdr.ScVal | undefined>
192
+ }
193
+
194
+ /** How far the id scan will probe before giving up. OZ imposes no per-account
195
+ * rule cap, so there is no exact bound to derive; this one is far above any
196
+ * realistic account and keeps a malformed `Count` from spinning forever. */
197
+ export const MAX_RULE_ID_SCAN = 512
198
+
199
+ export interface CollectedRules {
200
+ rules: ObservedRule[]
201
+ /** Rule ids whose stored predicate could not be read even though the
202
+ * interpreter is attached. Such a rule is reported without a predicate,
203
+ * which classifies it as opaque rather than as safely narrow. */
204
+ unreadablePredicateRuleIds: number[]
205
+ /** True when the scan stopped before accounting for every live rule. The
206
+ * result is then a SUBSET of the account's rules, so an empty overlap list
207
+ * proves nothing and the caller must not present it as safety. */
208
+ incomplete: boolean
209
+ }
210
+
211
+ /**
212
+ * Every context rule on the account, with predicates filled in for the rules
213
+ * our interpreter polices.
214
+ *
215
+ * Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
216
+ * decrements `Count` on removal without ever reusing an id, so after any
217
+ * removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
218
+ * would silently skip live rules at higher ids, and a skipped rule is a missed
219
+ * overlap - the one error that reports safety which does not exist. Instead the
220
+ * scan walks ids upward until it has accounted for `Count` live rules.
221
+ *
222
+ * A rule whose predicate cannot be read is deliberately left without one. That
223
+ * demotes it to the `foreign` class, so the scan reports it as opaque instead
224
+ * of assuming it is narrow.
225
+ */
226
+ export async function collectObservedRules(args: {
227
+ reader: AccountRuleReader
228
+ smartAccount: string
229
+ interpreterAddress: string
230
+ maxRuleIdScan?: number
231
+ }): Promise<CollectedRules> {
232
+ const count = await args.reader.getContextRuleCount(args.smartAccount)
233
+ const limit = args.maxRuleIdScan ?? MAX_RULE_ID_SCAN
234
+ const rules: ObservedRule[] = []
235
+ const unreadablePredicateRuleIds: number[] = []
236
+
237
+ let id = 0
238
+ while (rules.length < count && id < limit) {
239
+ const raw = await args.reader.getContextRule(args.smartAccount, id)
240
+ id++
241
+ if (!raw) continue
242
+ const rule = decodeContextRule(raw)
243
+ if (!rule) continue
244
+
245
+ if (rule.policyAddresses.includes(args.interpreterAddress)) {
246
+ const doc = await args.reader.getStoredDoc(
247
+ args.interpreterAddress,
248
+ args.smartAccount,
249
+ rule.id
250
+ )
251
+ const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined
252
+ if (bytes) {
253
+ try {
254
+ rule.predicate = decodePredicate(bytes)
255
+ } catch {
256
+ unreadablePredicateRuleIds.push(rule.id)
257
+ }
258
+ } else {
259
+ unreadablePredicateRuleIds.push(rule.id)
260
+ }
261
+ }
262
+ rules.push(rule)
263
+ }
264
+
265
+ return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count }
266
+ }
267
+
268
+ /**
269
+ * An `AccountRuleReader` over a live RPC server.
270
+ *
271
+ * The two OZ getters are read-only simulations: the source account is
272
+ * constructed locally because a simulation never checks its sequence number,
273
+ * and asking the network for a random key would 404.
274
+ *
275
+ * The stored document is fetched as a ledger entry rather than a contract
276
+ * call, because the interpreter publishes no getter for it.
277
+ */
278
+ export function accountRuleReaderFromServer(
279
+ server: rpc.Server,
280
+ networkPassphrase: string
281
+ ): AccountRuleReader {
282
+ async function simulateCall(
283
+ contract: string,
284
+ method: string,
285
+ ...args: xdr.ScVal[]
286
+ ): Promise<xdr.ScVal | undefined> {
287
+ const account = new Account(Keypair.random().publicKey(), '0')
288
+ const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
289
+ .addOperation(new Contract(contract).call(method, ...args))
290
+ .setTimeout(30)
291
+ .build()
292
+ const sim = await server.simulateTransaction(tx)
293
+ if (rpc.Api.isSimulationError(sim)) return undefined
294
+ return sim.result?.retval
295
+ }
296
+
297
+ return {
298
+ async getContextRuleCount(smartAccount) {
299
+ const val = await simulateCall(smartAccount, 'get_context_rules_count')
300
+ return u32Of(val) ?? 0
301
+ },
302
+ async getContextRule(smartAccount, ruleId) {
303
+ return simulateCall(smartAccount, 'get_context_rule', xdr.ScVal.scvU32(ruleId))
304
+ },
305
+ async getStoredDoc(interpreter, smartAccount, ruleId) {
306
+ const key = docLedgerKey(interpreter, smartAccount, ruleId)
307
+ const res = await server.getLedgerEntries(key)
308
+ const entry = res.entries?.[0]?.val
309
+ if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
310
+ return entry.contractData().val()
311
+ },
312
+ }
313
+ }
@@ -64,6 +64,15 @@ function expectSymbol(v: xdr.ScVal | undefined, what: string): string {
64
64
  return v.sym().toString()
65
65
  }
66
66
 
67
+ /** Strict i128, returned as a decimal string. Strict on purpose: the Rust
68
+ * decoder refuses a u32 in an i128 slot rather than widening it, so
69
+ * accepting one here would let a predicate decode off chain and be refused
70
+ * on chain. */
71
+ function expectI128(v: xdr.ScVal | undefined, what: string): string {
72
+ if (!v || v.switch() !== xdr.ScValType.scvI128()) throw malformed(`${what} is not an i128`)
73
+ return scValToBigInt(v).toString()
74
+ }
75
+
67
76
  /** Arity check with the same intent as the Rust `check_arity`: a selector with
68
77
  * the wrong element count is malformed, not silently truncated. */
69
78
  function arity(items: xdr.ScVal[], n: number, selector: string): void {
@@ -94,6 +103,14 @@ function decodeSelectorLeaf(items: xdr.ScVal[], sym: string): PredicateLeaf {
94
103
  element: expectU32(items[2], 'call_arg_field element'),
95
104
  field: expectSymbol(items[3], 'call_arg_field field'),
96
105
  }
106
+ case 'call_arg_scaled':
107
+ arity(items, 4, sym)
108
+ return {
109
+ kind: 'call_arg_scaled',
110
+ index: expectU32(items[1], 'call_arg_scaled index'),
111
+ num: expectI128(items[2], 'call_arg_scaled num'),
112
+ den: expectI128(items[3], 'call_arg_scaled den'),
113
+ }
97
114
  default:
98
115
  // Deliberately NOT a literal_vec fallback - see the header note.
99
116
  throw malformed(`unknown selector symbol '${sym}'`)
@@ -129,14 +146,18 @@ export function decodeNode(v: xdr.ScVal): PredicateNode {
129
146
  const op = selectorSymbol(items)
130
147
  if (op === null) throw malformed('node does not start with an operator symbol')
131
148
  switch (op) {
132
- case 'and': {
149
+ case 'and':
150
+ case 'or': {
133
151
  arity(items, 2, op)
134
152
  const children = expectVec(items[1] as xdr.ScVal, `${op} children`).map(decodeNode)
135
153
  if (children.length === 0) throw malformed(`${op} has no children`)
136
154
  return { op, children }
137
155
  }
138
156
  case 'eq':
157
+ case 'lt':
139
158
  case 'lte':
159
+ case 'gt':
160
+ case 'gte':
140
161
  arity(items, 3, op)
141
162
  return {
142
163
  op,
@@ -124,7 +124,8 @@ function walk(
124
124
  counters: { selectorLeaves: number }
125
125
  ): { depth: number; leaves: number } {
126
126
  switch (node.op) {
127
- case 'and': {
127
+ case 'and':
128
+ case 'or': {
128
129
  if (node.children.length === 0) {
129
130
  throw capError(
130
131
  'MALFORMED_PREDICATE',
@@ -141,7 +142,10 @@ function walk(
141
142
  return { depth: maxChildDepth + 1, leaves: totalLeaves }
142
143
  }
143
144
  case 'eq':
144
- case 'lte': {
145
+ case 'lt':
146
+ case 'lte':
147
+ case 'gt':
148
+ case 'gte': {
145
149
  collectSelector(node.left, counters)
146
150
  collectSelector(node.right, counters)
147
151
  return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) }
@@ -192,14 +196,20 @@ function collectSelector(leaf: PredicateLeaf, counters: { selectorLeaves: number
192
196
 
193
197
  function encodeNode(node: PredicateNode): xdr.ScVal {
194
198
  switch (node.op) {
195
- case 'and': {
199
+ case 'and':
200
+ case 'or': {
196
201
  const encoded = node.children.map(encodeNode)
197
- // sort children by their canonical XDR bytes ascending.
202
+ // sort children by their canonical XDR bytes ascending. Both operators
203
+ // are commutative for the permit decision, so sorting costs no meaning
204
+ // and buys a stable hash for logically-identical predicates.
198
205
  const sorted = sortByCanonicalBytes(encoded)
199
206
  return xdr.ScVal.scvVec([symbol(node.op), xdr.ScVal.scvVec(sorted)])
200
207
  }
201
208
  case 'eq':
202
- case 'lte': {
209
+ case 'lt':
210
+ case 'lte':
211
+ case 'gt':
212
+ case 'gte': {
203
213
  return xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)])
204
214
  }
205
215
  case 'in': {
@@ -231,6 +241,15 @@ function encodeLeaf(leaf: PredicateLeaf): xdr.ScVal {
231
241
  xdr.ScVal.scvU32(leaf.element),
232
242
  xdr.ScVal.scvSymbol(leaf.field),
233
243
  ])
244
+ case 'call_arg_scaled':
245
+ // num/den are i128 on the wire. The Rust decoder type-checks both
246
+ // slots, so a u32 here would be refused rather than widened.
247
+ return xdr.ScVal.scvVec([
248
+ symbol('call_arg_scaled'),
249
+ xdr.ScVal.scvU32(leaf.index),
250
+ scvI128FromDecimal(leaf.num),
251
+ scvI128FromDecimal(leaf.den),
252
+ ])
234
253
  case 'literal_address':
235
254
  return scvAddressFromStrkey(leaf.value)
236
255
  case 'literal_i128':
@@ -332,6 +351,33 @@ function validateLeafValues(node: PredicateNode): void {
332
351
  throw malformed(`call_arg_field.element out of u32 range at ${path}`)
333
352
  }
334
353
  return
354
+ case 'call_arg_scaled': {
355
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
356
+ throw malformed(`call_arg_scaled.index out of u32 range at ${path}`)
357
+ }
358
+ // Mirror of the contract's install gate (214). Without it the TS
359
+ // self-verify would green-light a ratio the chain refuses, which is
360
+ // exactly the divergence this validator exists to prevent.
361
+ let num: bigint
362
+ let den: bigint
363
+ try {
364
+ num = BigInt(leaf.num)
365
+ den = BigInt(leaf.den)
366
+ } catch {
367
+ throw malformed(`call_arg_scaled num/den must be i128 decimal strings at ${path}`)
368
+ }
369
+ if (den === 0n) {
370
+ throw malformed(
371
+ `call_arg_scaled.den is zero at ${path}: the contract refuses it at install (INVALID_SCALED_RATIO)`
372
+ )
373
+ }
374
+ if (num <= 0n || den < 0n) {
375
+ throw malformed(
376
+ `call_arg_scaled ratio ${leaf.num}/${leaf.den} at ${path} is not positive: a negative ratio inverts the comparison, so the floor would permit what it was written to refuse. The contract refuses it at install (INVALID_SCALED_RATIO)`
377
+ )
378
+ }
379
+ return
380
+ }
335
381
  case 'call_contract':
336
382
  case 'call_fn':
337
383
  case 'literal_address':
@@ -342,12 +388,16 @@ function validateLeafValues(node: PredicateNode): void {
342
388
  function walkNode(n: PredicateNode, path: string): void {
343
389
  switch (n.op) {
344
390
  case 'and':
391
+ case 'or':
345
392
  n.children.forEach((c, i) => {
346
393
  walkNode(c, `${path}.children[${i}]`)
347
394
  })
348
395
  return
349
396
  case 'eq':
397
+ case 'lt':
350
398
  case 'lte':
399
+ case 'gt':
400
+ case 'gte':
351
401
  walkLeaf(n.left, `${path}.left`)
352
402
  walkLeaf(n.right, `${path}.right`)
353
403
  return