@crediolabs/policy-synth 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) 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 +14 -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.js +51 -8
  16. package/dist/run/schemas.d.ts +45 -14
  17. package/dist/run/schemas.js +43 -6
  18. package/dist/simulate/deny-cases.js +11 -0
  19. package/dist/simulate/evaluate.js +86 -5
  20. package/dist/synth/declare.d.ts +13 -0
  21. package/dist/synth/declare.js +34 -5
  22. package/dist/synth/synthesize-from-recording.js +1 -1
  23. package/dist/types.d.ts +21 -1
  24. package/dist/types.js +1 -1
  25. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  26. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  27. package/dist-cjs/errors.d.ts +6 -1
  28. package/dist-cjs/install/authority-overlap.js +12 -0
  29. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  30. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  31. package/dist-cjs/install/read-account-rules.js +252 -0
  32. package/dist-cjs/predicate/decode.js +22 -1
  33. package/dist-cjs/predicate/encode.js +52 -5
  34. package/dist-cjs/predicate/from-json.js +14 -1
  35. package/dist-cjs/review-card/builder.js +40 -0
  36. package/dist-cjs/review-card/cross-check.js +34 -0
  37. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  38. package/dist-cjs/review-card/render-leaf.js +7 -0
  39. package/dist-cjs/run/index.js +51 -8
  40. package/dist-cjs/run/schemas.d.ts +45 -14
  41. package/dist-cjs/run/schemas.js +43 -6
  42. package/dist-cjs/simulate/deny-cases.js +11 -0
  43. package/dist-cjs/simulate/evaluate.js +86 -5
  44. package/dist-cjs/synth/declare.d.ts +13 -0
  45. package/dist-cjs/synth/declare.js +34 -5
  46. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  47. package/dist-cjs/types.d.ts +21 -1
  48. package/dist-cjs/types.js +1 -1
  49. package/package.json +1 -1
  50. package/src/adapters/interpreter/adapter.ts +13 -5
  51. package/src/errors.ts +5 -0
  52. package/src/install/authority-overlap.ts +11 -0
  53. package/src/install/read-account-rules.ts +313 -0
  54. package/src/predicate/decode.ts +22 -1
  55. package/src/predicate/encode.ts +55 -5
  56. package/src/predicate/from-json.ts +14 -1
  57. package/src/review-card/builder.ts +45 -2
  58. package/src/review-card/cross-check.ts +35 -1
  59. package/src/review-card/render-leaf.ts +8 -1
  60. package/src/run/index.ts +54 -8
  61. package/src/run/schemas.ts +43 -6
  62. package/src/simulate/deny-cases.ts +12 -1
  63. package/src/simulate/evaluate.ts +101 -9
  64. package/src/synth/declare.ts +54 -5
  65. package/src/synth/synthesize-from-recording.ts +1 -1
  66. package/src/types.ts +16 -1
@@ -12,9 +12,9 @@ export interface InterpreterAdapterConfig {
12
12
  * rejected as `SCOPE_SELF_CALL`. */
13
13
  smartAccountAddress: string;
14
14
  }
15
- /** The result of compiling a PolicyIR for this backend. */
15
+ /** The result of compiling a `ComposedRule` for this backend. */
16
16
  export interface CompileResult {
17
- /** false => some IR construct this backend cannot express (see `uncovered`). */
17
+ /** false => some constraint this backend cannot express (see `uncovered`). */
18
18
  covered: boolean;
19
19
  /** Human-readable list of unsupported constructs. */
20
20
  uncovered: string[];
@@ -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
  //
@@ -133,7 +133,11 @@ function assertNoSelfCall(node, config) {
133
133
  }
134
134
  };
135
135
  switch (node.op) {
136
+ // Both boolean nodes recurse. `or` is listed explicitly rather than left
137
+ // to a default branch: this is a security check, and a new node kind must
138
+ // fail to compile here rather than silently skip it.
136
139
  case 'and':
140
+ case 'or':
137
141
  for (const c of node.children)
138
142
  assertNoSelfCall(c, config);
139
143
  return;
@@ -142,7 +146,11 @@ function assertNoSelfCall(node, config) {
142
146
  for (const h of node.haystack)
143
147
  checkLeaf(h);
144
148
  return;
145
- default:
149
+ case 'eq':
150
+ case 'lt':
151
+ case 'lte':
152
+ case 'gt':
153
+ case 'gte':
146
154
  checkLeaf(node.left);
147
155
  checkLeaf(node.right);
148
156
  }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,9 @@
1
- export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ARG_MISMATCH' | 'CONTRACT_SCOPE' | 'UNSUPPORTED_NODE' | 'STATEFUL_BOUND' | 'NOT_IN_ALLOWLIST' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
1
+ export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ARG_MISMATCH' | 'CONTRACT_SCOPE' | 'UNSUPPORTED_NODE' | 'STATEFUL_BOUND' | 'NOT_IN_ALLOWLIST'
2
+ /** A comparison against a `call_arg_scaled` operand failed (contract 107). */
3
+ | 'SLIPPAGE_FLOOR'
4
+ /** A `call_arg_scaled` ratio is zero or non-positive. Refused at install
5
+ * (contract 214) and mirrored at encode. */
6
+ | 'INVALID_SCALED_RATIO' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
2
7
  export interface ToolError {
3
8
  code: ErrorCode;
4
9
  message: string;
@@ -125,6 +125,18 @@ export function permittedSelectors(node) {
125
125
  acc = intersectSelectors(acc, permittedSelectors(child));
126
126
  return acc;
127
127
  }
128
+ case 'or': {
129
+ // Any branch may hold, so the permitted set is the UNION. A union of
130
+ // over-approximations is still an over-approximation, so this keeps the
131
+ // fail-safe direction while staying tighter than the wildcard the
132
+ // default branch would give. Precision matters here: `or` is how a
133
+ // policy says "pair A or pair B", and widening that to the wildcard
134
+ // would report an overlap against every rule on the account.
135
+ const acc = [];
136
+ for (const child of node.children)
137
+ acc.push(...permittedSelectors(child));
138
+ return dedupe(acc);
139
+ }
128
140
  case 'eq': {
129
141
  const sel = selectorFromEq(node.left, node.right);
130
142
  return sel === null ? [WILDCARD] : [sel];
@@ -23,7 +23,7 @@ export interface BuildAddContextRuleArgs {
23
23
  * before the chain does; the contract will refuse a mismatch again. */
24
24
  grammarVersion?: number;
25
25
  }
26
- export declare const DEFAULT_GRAMMAR_VERSION: 3;
26
+ export declare const DEFAULT_GRAMMAR_VERSION: 4;
27
27
  /** The verb `add_context_rule` takes on the wire. */
28
28
  export declare const ADD_CONTEXT_RULE_SYMBOL: "add_context_rule";
29
29
  /** Tuple of `ScVal` arguments to pass to `Operation.invokeHostFunction` for
@@ -0,0 +1,79 @@
1
+ import { rpc, xdr } from '@stellar/stellar-sdk';
2
+ import type { SignerDraft } from '../types.ts';
3
+ import type { ContextType, ObservedRule } from './authority-overlap.ts';
4
+ /** `storage.rs` - the third element of the persistent doc key tuple. */
5
+ export declare const K_DOC = 1;
6
+ /** Persistent-storage key for a rule's stored document:
7
+ * `(account, rule_id, K_DOC)`. */
8
+ export declare function docKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal;
9
+ /** Ledger key for the interpreter's persistent entry holding that document. */
10
+ export declare function docLedgerKey(interpreter: string, smartAccount: string, ruleId: number): xdr.LedgerKey;
11
+ /** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
12
+ * is the widest reading and therefore the safe one: it makes the rule look
13
+ * like it could serve any call, so overlap is over-reported, never missed. */
14
+ export declare function decodeContextType(v: xdr.ScVal | undefined): ContextType;
15
+ /** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
16
+ export declare function decodeSigner(v: xdr.ScVal): SignerDraft | undefined;
17
+ /** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
18
+ * `predicate` is filled in separately from the ledger entry. */
19
+ export declare function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined;
20
+ /** The interpreter's `StoredDoc { predicate_bytes }`. */
21
+ export declare function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined;
22
+ /** The three reads the scan needs. Kept as an interface so the collection
23
+ * below is testable without a network. */
24
+ export interface AccountRuleReader {
25
+ /** OZ `get_context_rules_count()`. */
26
+ getContextRuleCount(smartAccount: string): Promise<number>;
27
+ /** OZ `get_context_rule(id)`. Undefined when the id is absent. */
28
+ getContextRule(smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
29
+ /** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
30
+ * Undefined when no document is stored for that rule. */
31
+ getStoredDoc(interpreter: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
32
+ }
33
+ /** How far the id scan will probe before giving up. OZ imposes no per-account
34
+ * rule cap, so there is no exact bound to derive; this one is far above any
35
+ * realistic account and keeps a malformed `Count` from spinning forever. */
36
+ export declare const MAX_RULE_ID_SCAN = 512;
37
+ export interface CollectedRules {
38
+ rules: ObservedRule[];
39
+ /** Rule ids whose stored predicate could not be read even though the
40
+ * interpreter is attached. Such a rule is reported without a predicate,
41
+ * which classifies it as opaque rather than as safely narrow. */
42
+ unreadablePredicateRuleIds: number[];
43
+ /** True when the scan stopped before accounting for every live rule. The
44
+ * result is then a SUBSET of the account's rules, so an empty overlap list
45
+ * proves nothing and the caller must not present it as safety. */
46
+ incomplete: boolean;
47
+ }
48
+ /**
49
+ * Every context rule on the account, with predicates filled in for the rules
50
+ * our interpreter polices.
51
+ *
52
+ * Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
53
+ * decrements `Count` on removal without ever reusing an id, so after any
54
+ * removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
55
+ * would silently skip live rules at higher ids, and a skipped rule is a missed
56
+ * overlap - the one error that reports safety which does not exist. Instead the
57
+ * scan walks ids upward until it has accounted for `Count` live rules.
58
+ *
59
+ * A rule whose predicate cannot be read is deliberately left without one. That
60
+ * demotes it to the `foreign` class, so the scan reports it as opaque instead
61
+ * of assuming it is narrow.
62
+ */
63
+ export declare function collectObservedRules(args: {
64
+ reader: AccountRuleReader;
65
+ smartAccount: string;
66
+ interpreterAddress: string;
67
+ maxRuleIdScan?: number;
68
+ }): Promise<CollectedRules>;
69
+ /**
70
+ * An `AccountRuleReader` over a live RPC server.
71
+ *
72
+ * The two OZ getters are read-only simulations: the source account is
73
+ * constructed locally because a simulation never checks its sequence number,
74
+ * and asking the network for a random key would 404.
75
+ *
76
+ * The stored document is fetched as a ledger entry rather than a contract
77
+ * call, because the interpreter publishes no getter for it.
78
+ */
79
+ export declare function accountRuleReaderFromServer(server: rpc.Server, networkPassphrase: string): AccountRuleReader;
@@ -0,0 +1,241 @@
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
+ import { Account, Address, BASE_FEE, Contract, Keypair, rpc, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
23
+ import { decodePredicate } from "../predicate/decode.js";
24
+ /** `storage.rs` - the third element of the persistent doc key tuple. */
25
+ export const K_DOC = 1;
26
+ /** Persistent-storage key for a rule's stored document:
27
+ * `(account, rule_id, K_DOC)`. */
28
+ export function docKeyScVal(smartAccount, ruleId) {
29
+ return xdr.ScVal.scvVec([
30
+ new Address(smartAccount).toScVal(),
31
+ xdr.ScVal.scvU32(ruleId),
32
+ xdr.ScVal.scvU32(K_DOC),
33
+ ]);
34
+ }
35
+ /** Ledger key for the interpreter's persistent entry holding that document. */
36
+ export function docLedgerKey(interpreter, smartAccount, ruleId) {
37
+ return xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
38
+ contract: new Address(interpreter).toScAddress(),
39
+ key: docKeyScVal(smartAccount, ruleId),
40
+ durability: xdr.ContractDataDurability.persistent(),
41
+ }));
42
+ }
43
+ // ---- ScVal helpers -----
44
+ /** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
45
+ * by field-name symbol. Returns undefined when the field is absent so a
46
+ * caller can distinguish "not there" from "there and empty". */
47
+ function mapField(v, name) {
48
+ if (v.switch() !== xdr.ScValType.scvMap())
49
+ return undefined;
50
+ for (const entry of v.map() ?? []) {
51
+ const key = entry.key();
52
+ if (key.switch() === xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
53
+ return entry.val();
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ function u32Of(v) {
59
+ return v?.switch() === xdr.ScValType.scvU32() ? v.u32() : undefined;
60
+ }
61
+ function addressOf(v) {
62
+ if (!v || v.switch() !== xdr.ScValType.scvAddress())
63
+ return undefined;
64
+ return Address.fromScAddress(v.address()).toString();
65
+ }
66
+ /** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
67
+ function enumVariant(v) {
68
+ if (!v || v.switch() !== xdr.ScValType.scvVec())
69
+ return undefined;
70
+ const items = v.vec() ?? [];
71
+ const head = items[0];
72
+ if (!head || head.switch() !== xdr.ScValType.scvSymbol())
73
+ return undefined;
74
+ return { tag: head.sym().toString(), args: items.slice(1) };
75
+ }
76
+ // ---- decoders -----
77
+ /** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
78
+ * is the widest reading and therefore the safe one: it makes the rule look
79
+ * like it could serve any call, so overlap is over-reported, never missed. */
80
+ export function decodeContextType(v) {
81
+ const variant = enumVariant(v);
82
+ if (!variant)
83
+ return { kind: 'default' };
84
+ if (variant.tag === 'CallContract') {
85
+ const addr = addressOf(variant.args[0]);
86
+ return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' };
87
+ }
88
+ if (variant.tag === 'CreateContract') {
89
+ const arg = variant.args[0];
90
+ const hash = arg?.switch() === xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : '';
91
+ return { kind: 'create_contract', wasmHash: hash };
92
+ }
93
+ return { kind: 'default' };
94
+ }
95
+ /** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
96
+ export function decodeSigner(v) {
97
+ const variant = enumVariant(v);
98
+ if (!variant)
99
+ return undefined;
100
+ if (variant.tag === 'Delegated') {
101
+ const addr = addressOf(variant.args[0]);
102
+ return addr ? { kind: 'delegated', address: addr } : undefined;
103
+ }
104
+ if (variant.tag === 'External') {
105
+ const verifier = addressOf(variant.args[0]);
106
+ const keyArg = variant.args[1];
107
+ const keyBytes = keyArg?.switch() === xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : '';
108
+ return verifier ? { kind: 'external', verifier, keyBytes } : undefined;
109
+ }
110
+ return undefined;
111
+ }
112
+ /** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
113
+ * `predicate` is filled in separately from the ledger entry. */
114
+ export function decodeContextRule(v) {
115
+ const id = u32Of(mapField(v, 'id'));
116
+ if (id === undefined)
117
+ return undefined;
118
+ const signersVal = mapField(v, 'signers');
119
+ const signers = [];
120
+ if (signersVal?.switch() === xdr.ScValType.scvVec()) {
121
+ for (const s of signersVal.vec() ?? []) {
122
+ const decoded = decodeSigner(s);
123
+ if (decoded)
124
+ signers.push(decoded);
125
+ }
126
+ }
127
+ const policiesVal = mapField(v, 'policies');
128
+ const policyAddresses = [];
129
+ if (policiesVal?.switch() === xdr.ScValType.scvVec()) {
130
+ for (const p of policiesVal.vec() ?? []) {
131
+ const addr = addressOf(p);
132
+ if (addr)
133
+ policyAddresses.push(addr);
134
+ }
135
+ }
136
+ return {
137
+ id,
138
+ contextType: decodeContextType(mapField(v, 'context_type')),
139
+ signers,
140
+ policyAddresses,
141
+ };
142
+ }
143
+ /** The interpreter's `StoredDoc { predicate_bytes }`. */
144
+ export function decodeStoredPredicateBytes(v) {
145
+ const field = mapField(v, 'predicate_bytes');
146
+ if (!field || field.switch() !== xdr.ScValType.scvBytes())
147
+ return undefined;
148
+ return field.bytes();
149
+ }
150
+ /** How far the id scan will probe before giving up. OZ imposes no per-account
151
+ * rule cap, so there is no exact bound to derive; this one is far above any
152
+ * realistic account and keeps a malformed `Count` from spinning forever. */
153
+ export const MAX_RULE_ID_SCAN = 512;
154
+ /**
155
+ * Every context rule on the account, with predicates filled in for the rules
156
+ * our interpreter polices.
157
+ *
158
+ * Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
159
+ * decrements `Count` on removal without ever reusing an id, so after any
160
+ * removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
161
+ * would silently skip live rules at higher ids, and a skipped rule is a missed
162
+ * overlap - the one error that reports safety which does not exist. Instead the
163
+ * scan walks ids upward until it has accounted for `Count` live rules.
164
+ *
165
+ * A rule whose predicate cannot be read is deliberately left without one. That
166
+ * demotes it to the `foreign` class, so the scan reports it as opaque instead
167
+ * of assuming it is narrow.
168
+ */
169
+ export async function collectObservedRules(args) {
170
+ const count = await args.reader.getContextRuleCount(args.smartAccount);
171
+ const limit = args.maxRuleIdScan ?? MAX_RULE_ID_SCAN;
172
+ const rules = [];
173
+ const unreadablePredicateRuleIds = [];
174
+ let id = 0;
175
+ while (rules.length < count && id < limit) {
176
+ const raw = await args.reader.getContextRule(args.smartAccount, id);
177
+ id++;
178
+ if (!raw)
179
+ continue;
180
+ const rule = decodeContextRule(raw);
181
+ if (!rule)
182
+ continue;
183
+ if (rule.policyAddresses.includes(args.interpreterAddress)) {
184
+ const doc = await args.reader.getStoredDoc(args.interpreterAddress, args.smartAccount, rule.id);
185
+ const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined;
186
+ if (bytes) {
187
+ try {
188
+ rule.predicate = decodePredicate(bytes);
189
+ }
190
+ catch {
191
+ unreadablePredicateRuleIds.push(rule.id);
192
+ }
193
+ }
194
+ else {
195
+ unreadablePredicateRuleIds.push(rule.id);
196
+ }
197
+ }
198
+ rules.push(rule);
199
+ }
200
+ return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count };
201
+ }
202
+ /**
203
+ * An `AccountRuleReader` over a live RPC server.
204
+ *
205
+ * The two OZ getters are read-only simulations: the source account is
206
+ * constructed locally because a simulation never checks its sequence number,
207
+ * and asking the network for a random key would 404.
208
+ *
209
+ * The stored document is fetched as a ledger entry rather than a contract
210
+ * call, because the interpreter publishes no getter for it.
211
+ */
212
+ export function accountRuleReaderFromServer(server, networkPassphrase) {
213
+ async function simulateCall(contract, method, ...args) {
214
+ const account = new Account(Keypair.random().publicKey(), '0');
215
+ const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
216
+ .addOperation(new Contract(contract).call(method, ...args))
217
+ .setTimeout(30)
218
+ .build();
219
+ const sim = await server.simulateTransaction(tx);
220
+ if (rpc.Api.isSimulationError(sim))
221
+ return undefined;
222
+ return sim.result?.retval;
223
+ }
224
+ return {
225
+ async getContextRuleCount(smartAccount) {
226
+ const val = await simulateCall(smartAccount, 'get_context_rules_count');
227
+ return u32Of(val) ?? 0;
228
+ },
229
+ async getContextRule(smartAccount, ruleId) {
230
+ return simulateCall(smartAccount, 'get_context_rule', xdr.ScVal.scvU32(ruleId));
231
+ },
232
+ async getStoredDoc(interpreter, smartAccount, ruleId) {
233
+ const key = docLedgerKey(interpreter, smartAccount, ruleId);
234
+ const res = await server.getLedgerEntries(key);
235
+ const entry = res.entries?.[0]?.val;
236
+ if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData())
237
+ return undefined;
238
+ return entry.contractData().val();
239
+ },
240
+ };
241
+ }
@@ -55,6 +55,15 @@ function expectSymbol(v, what) {
55
55
  throw malformed(`${what} is not a symbol`);
56
56
  return v.sym().toString();
57
57
  }
58
+ /** Strict i128, returned as a decimal string. Strict on purpose: the Rust
59
+ * decoder refuses a u32 in an i128 slot rather than widening it, so
60
+ * accepting one here would let a predicate decode off chain and be refused
61
+ * on chain. */
62
+ function expectI128(v, what) {
63
+ if (!v || v.switch() !== xdr.ScValType.scvI128())
64
+ throw malformed(`${what} is not an i128`);
65
+ return scValToBigInt(v).toString();
66
+ }
58
67
  /** Arity check with the same intent as the Rust `check_arity`: a selector with
59
68
  * the wrong element count is malformed, not silently truncated. */
60
69
  function arity(items, n, selector) {
@@ -84,6 +93,14 @@ function decodeSelectorLeaf(items, sym) {
84
93
  element: expectU32(items[2], 'call_arg_field element'),
85
94
  field: expectSymbol(items[3], 'call_arg_field field'),
86
95
  };
96
+ case 'call_arg_scaled':
97
+ arity(items, 4, sym);
98
+ return {
99
+ kind: 'call_arg_scaled',
100
+ index: expectU32(items[1], 'call_arg_scaled index'),
101
+ num: expectI128(items[2], 'call_arg_scaled num'),
102
+ den: expectI128(items[3], 'call_arg_scaled den'),
103
+ };
87
104
  default:
88
105
  // Deliberately NOT a literal_vec fallback - see the header note.
89
106
  throw malformed(`unknown selector symbol '${sym}'`);
@@ -120,7 +137,8 @@ export function decodeNode(v) {
120
137
  if (op === null)
121
138
  throw malformed('node does not start with an operator symbol');
122
139
  switch (op) {
123
- case 'and': {
140
+ case 'and':
141
+ case 'or': {
124
142
  arity(items, 2, op);
125
143
  const children = expectVec(items[1], `${op} children`).map(decodeNode);
126
144
  if (children.length === 0)
@@ -128,7 +146,10 @@ export function decodeNode(v) {
128
146
  return { op, children };
129
147
  }
130
148
  case 'eq':
149
+ case 'lt':
131
150
  case 'lte':
151
+ case 'gt':
152
+ case 'gte':
132
153
  arity(items, 3, op);
133
154
  return {
134
155
  op,
@@ -80,7 +80,8 @@ function computeStats(node) {
80
80
  }
81
81
  function walk(node, inCounts, counters) {
82
82
  switch (node.op) {
83
- case 'and': {
83
+ case 'and':
84
+ case 'or': {
84
85
  if (node.children.length === 0) {
85
86
  throw capError('MALFORMED_PREDICATE', `\`${node.op}\` with no children: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed`);
86
87
  }
@@ -95,7 +96,10 @@ function walk(node, inCounts, counters) {
95
96
  return { depth: maxChildDepth + 1, leaves: totalLeaves };
96
97
  }
97
98
  case 'eq':
98
- case 'lte': {
99
+ case 'lt':
100
+ case 'lte':
101
+ case 'gt':
102
+ case 'gte': {
99
103
  collectSelector(node.left, counters);
100
104
  collectSelector(node.right, counters);
101
105
  return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) };
@@ -143,14 +147,20 @@ function collectSelector(leaf, counters) {
143
147
  }
144
148
  function encodeNode(node) {
145
149
  switch (node.op) {
146
- case 'and': {
150
+ case 'and':
151
+ case 'or': {
147
152
  const encoded = node.children.map(encodeNode);
148
- // sort children by their canonical XDR bytes ascending.
153
+ // sort children by their canonical XDR bytes ascending. Both operators
154
+ // are commutative for the permit decision, so sorting costs no meaning
155
+ // and buys a stable hash for logically-identical predicates.
149
156
  const sorted = sortByCanonicalBytes(encoded);
150
157
  return xdr.ScVal.scvVec([symbol(node.op), xdr.ScVal.scvVec(sorted)]);
151
158
  }
152
159
  case 'eq':
153
- case 'lte': {
160
+ case 'lt':
161
+ case 'lte':
162
+ case 'gt':
163
+ case 'gte': {
154
164
  return xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)]);
155
165
  }
156
166
  case 'in': {
@@ -181,6 +191,15 @@ function encodeLeaf(leaf) {
181
191
  xdr.ScVal.scvU32(leaf.element),
182
192
  xdr.ScVal.scvSymbol(leaf.field),
183
193
  ]);
194
+ case 'call_arg_scaled':
195
+ // num/den are i128 on the wire. The Rust decoder type-checks both
196
+ // slots, so a u32 here would be refused rather than widened.
197
+ return xdr.ScVal.scvVec([
198
+ symbol('call_arg_scaled'),
199
+ xdr.ScVal.scvU32(leaf.index),
200
+ scvI128FromDecimal(leaf.num),
201
+ scvI128FromDecimal(leaf.den),
202
+ ]);
184
203
  case 'literal_address':
185
204
  return scvAddressFromStrkey(leaf.value);
186
205
  case 'literal_i128':
@@ -266,6 +285,30 @@ function validateLeafValues(node) {
266
285
  throw malformed(`call_arg_field.element out of u32 range at ${path}`);
267
286
  }
268
287
  return;
288
+ case 'call_arg_scaled': {
289
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
290
+ throw malformed(`call_arg_scaled.index out of u32 range at ${path}`);
291
+ }
292
+ // Mirror of the contract's install gate (214). Without it the TS
293
+ // self-verify would green-light a ratio the chain refuses, which is
294
+ // exactly the divergence this validator exists to prevent.
295
+ let num;
296
+ let den;
297
+ try {
298
+ num = BigInt(leaf.num);
299
+ den = BigInt(leaf.den);
300
+ }
301
+ catch {
302
+ throw malformed(`call_arg_scaled num/den must be i128 decimal strings at ${path}`);
303
+ }
304
+ if (den === 0n) {
305
+ throw malformed(`call_arg_scaled.den is zero at ${path}: the contract refuses it at install (INVALID_SCALED_RATIO)`);
306
+ }
307
+ if (num <= 0n || den < 0n) {
308
+ throw malformed(`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)`);
309
+ }
310
+ return;
311
+ }
269
312
  case 'call_contract':
270
313
  case 'call_fn':
271
314
  case 'literal_address':
@@ -276,12 +319,16 @@ function validateLeafValues(node) {
276
319
  function walkNode(n, path) {
277
320
  switch (n.op) {
278
321
  case 'and':
322
+ case 'or':
279
323
  n.children.forEach((c, i) => {
280
324
  walkNode(c, `${path}.children[${i}]`);
281
325
  });
282
326
  return;
283
327
  case 'eq':
328
+ case 'lt':
284
329
  case 'lte':
330
+ case 'gt':
331
+ case 'gte':
285
332
  walkLeaf(n.left, `${path}.left`);
286
333
  walkLeaf(n.right, `${path}.right`);
287
334
  return;
@@ -17,9 +17,13 @@ export function jsonToAst(value) {
17
17
  const v = value;
18
18
  switch (v.op) {
19
19
  case 'and':
20
- return { op: 'and', children: arrayOf(v.children, jsonToAst) };
20
+ case 'or':
21
+ return { op: v.op, children: arrayOf(v.children, jsonToAst) };
21
22
  case 'eq':
23
+ case 'lt':
22
24
  case 'lte':
25
+ case 'gt':
26
+ case 'gte':
23
27
  return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) };
24
28
  case 'in':
25
29
  return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) };
@@ -44,6 +48,15 @@ function jsonToLeaf(value) {
44
48
  return { kind: 'call_arg', index: numberField(v, 'index') };
45
49
  case 'call_arg_len':
46
50
  return { kind: 'call_arg_len', index: numberField(v, 'index') };
51
+ case 'call_arg_scaled':
52
+ // num/den stay decimal STRINGS: an i128 ratio does not survive a JS
53
+ // number, and silently rounding one would change the floor.
54
+ return {
55
+ kind: 'call_arg_scaled',
56
+ index: numberField(v, 'index'),
57
+ num: stringField(v, 'num'),
58
+ den: stringField(v, 'den'),
59
+ };
47
60
  case 'literal_address':
48
61
  return { kind: 'literal_address', value: stringField(v, 'value') };
49
62
  case 'literal_i128':