@crediolabs/policy-synth 0.1.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 (85) hide show
  1. package/dist/adapters/oz/adapter.d.ts +20 -0
  2. package/dist/adapters/oz/adapter.js +282 -0
  3. package/dist/adapters/oz/index.d.ts +1 -0
  4. package/dist/adapters/oz/index.js +2 -0
  5. package/dist/errors.d.ts +37 -0
  6. package/dist/errors.js +2 -0
  7. package/dist/index.d.ts +9 -0
  8. package/dist/index.js +9 -0
  9. package/dist/ir/index.d.ts +1 -0
  10. package/dist/ir/index.js +2 -0
  11. package/dist/ir/types.d.ts +97 -0
  12. package/dist/ir/types.js +11 -0
  13. package/dist/mandate/index.d.ts +2 -0
  14. package/dist/mandate/index.js +2 -0
  15. package/dist/mandate/to-ir.d.ts +3 -0
  16. package/dist/mandate/to-ir.js +60 -0
  17. package/dist/mandate/types.d.ts +20 -0
  18. package/dist/mandate/types.js +8 -0
  19. package/dist/record/decode.d.ts +76 -0
  20. package/dist/record/decode.js +372 -0
  21. package/dist/record/freshness.d.ts +17 -0
  22. package/dist/record/freshness.js +50 -0
  23. package/dist/record/index.d.ts +21 -0
  24. package/dist/record/index.js +163 -0
  25. package/dist/record/movements.d.ts +20 -0
  26. package/dist/record/movements.js +187 -0
  27. package/dist/record/rpc.d.ts +22 -0
  28. package/dist/record/rpc.js +70 -0
  29. package/dist/record/validate.d.ts +22 -0
  30. package/dist/record/validate.js +60 -0
  31. package/dist/registry/identify.d.ts +11 -0
  32. package/dist/registry/identify.js +84 -0
  33. package/dist/registry/index.d.ts +3 -0
  34. package/dist/registry/index.js +4 -0
  35. package/dist/registry/known-addresses.d.ts +16 -0
  36. package/dist/registry/known-addresses.js +49 -0
  37. package/dist/registry/protocols.d.ts +38 -0
  38. package/dist/registry/protocols.js +149 -0
  39. package/dist/seams/index.d.ts +1 -0
  40. package/dist/seams/index.js +2 -0
  41. package/dist/seams/types.d.ts +66 -0
  42. package/dist/seams/types.js +11 -0
  43. package/dist/synth/compose-from-recording.d.ts +36 -0
  44. package/dist/synth/compose-from-recording.js +162 -0
  45. package/dist/synth/index.d.ts +5 -0
  46. package/dist/synth/index.js +6 -0
  47. package/dist/synth/lower.d.ts +23 -0
  48. package/dist/synth/lower.js +116 -0
  49. package/dist/synth/scope.d.ts +26 -0
  50. package/dist/synth/scope.js +77 -0
  51. package/dist/synth/synthesize-from-mandate.d.ts +5 -0
  52. package/dist/synth/synthesize-from-mandate.js +34 -0
  53. package/dist/synth/synthesize-from-recording.d.ts +17 -0
  54. package/dist/synth/synthesize-from-recording.js +178 -0
  55. package/dist/types.d.ts +249 -0
  56. package/dist/types.js +35 -0
  57. package/package.json +37 -0
  58. package/src/adapters/oz/adapter.ts +363 -0
  59. package/src/adapters/oz/index.ts +9 -0
  60. package/src/errors.ts +79 -0
  61. package/src/index.ts +9 -0
  62. package/src/ir/index.ts +13 -0
  63. package/src/ir/types.ts +94 -0
  64. package/src/mandate/index.ts +4 -0
  65. package/src/mandate/to-ir.ts +71 -0
  66. package/src/mandate/types.ts +21 -0
  67. package/src/record/decode.ts +500 -0
  68. package/src/record/freshness.ts +63 -0
  69. package/src/record/index.ts +224 -0
  70. package/src/record/movements.ts +188 -0
  71. package/src/record/rpc.ts +88 -0
  72. package/src/record/validate.ts +75 -0
  73. package/src/registry/identify.ts +99 -0
  74. package/src/registry/index.ts +24 -0
  75. package/src/registry/known-addresses.ts +71 -0
  76. package/src/registry/protocols.ts +176 -0
  77. package/src/seams/index.ts +11 -0
  78. package/src/seams/types.ts +81 -0
  79. package/src/synth/compose-from-recording.ts +226 -0
  80. package/src/synth/index.ts +19 -0
  81. package/src/synth/lower.ts +144 -0
  82. package/src/synth/scope.ts +108 -0
  83. package/src/synth/synthesize-from-mandate.ts +46 -0
  84. package/src/synth/synthesize-from-recording.ts +226 -0
  85. package/src/types.ts +218 -0
@@ -0,0 +1,249 @@
1
+ export type Network = 'mainnet' | 'testnet';
2
+ /** Decoded Soroban value - the normalised subset synthesis needs. */
3
+ export type ScVal = {
4
+ type: 'address';
5
+ value: string;
6
+ } | {
7
+ type: 'i128';
8
+ value: string;
9
+ } | {
10
+ type: 'u64';
11
+ value: string;
12
+ } | {
13
+ type: 'u32';
14
+ value: string;
15
+ } | {
16
+ type: 'symbol';
17
+ value: string;
18
+ } | {
19
+ type: 'vec';
20
+ value: ScVal[];
21
+ } | {
22
+ type: 'bytes';
23
+ value: string;
24
+ } | {
25
+ type: 'other';
26
+ value: string;
27
+ };
28
+ /** OZ Accounts framework hard limits (verified against canonical OZ source). */
29
+ export declare const OZ_LIMITS: {
30
+ readonly maxPoliciesPerRule: 5;
31
+ readonly maxSignersPerRule: 15;
32
+ };
33
+ /** Soroban / Stellar protocol limits used to bound inputs fail-closed. Single
34
+ * source so the schema boundary and the core validators cannot drift. */
35
+ export declare const SOROBAN_LIMITS: {
36
+ /** `valid_until` and other ledger-sequence fields are u32. */
37
+ readonly u32Max: 4294967295;
38
+ /** Upper bound on top-level invocations in a recorded transaction; a real
39
+ * Stellar tx caps operations well below this. Bounds a hand-crafted payload. */
40
+ readonly maxInvocations: 512;
41
+ /** Stellar targets a ~5s ledger close time (DAY_IN_LEDGERS = 17280 = 86400/5),
42
+ * used to convert a spend window in seconds to OZ `period_ledgers`. */
43
+ readonly secondsPerLedger: 5;
44
+ };
45
+ /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
46
+ * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
47
+ * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
48
+ * disagree, the interpreter is authoritative. Rationale per cap:
49
+ * - MAX_DEPTH = 5: 3 walkthroughs need depth 2; budget = 2x growth + 1 for `not` chain.
50
+ * - MAX_LEAVES = 200: allowlists (recipients, paths) typically < 100; budget = 2x + 1 nested allowlist.
51
+ * - MAX_PREDICATE_BYTES = 32 KB: well under the per-ledger-entry 64 KB persistent-storage limit.
52
+ * - 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.
53
+ * - MAX_IN_OPERAND_COUNT = 32: allowlist < 32 is a UX-natural bound. */
54
+ export declare const PREDICATE_CAPS: {
55
+ readonly MAX_DEPTH: 5;
56
+ readonly MAX_LEAVES: 200;
57
+ readonly MAX_PREDICATE_BYTES: number;
58
+ readonly MAX_ORACLE_READS: 6;
59
+ readonly MAX_IN_OPERAND_COUNT: 32;
60
+ };
61
+ /** OZ context-rule draft - the unit the install builder builds add_context_rule from. */
62
+ export type ContextRuleDraft = {
63
+ contextRuleType: {
64
+ kind: 'default';
65
+ } | {
66
+ kind: 'call_contract';
67
+ contract: string;
68
+ } | {
69
+ kind: 'create_contract';
70
+ wasmHash: string;
71
+ };
72
+ name: string;
73
+ /** Ledger sequence (u32). Soroban `valid_until: Option<u32>`. */
74
+ validUntilLedger: number | null;
75
+ /** Mirrors OZ `Signer` enum exactly: `Delegated(Address) | External(Address, Bytes)`.
76
+ * The verifier address + key bytes for an external signer are carried in the
77
+ * `External` variant - there is no `Key` or `SmartAccount` variant, and no
78
+ * master/non-master discriminator lives on the type itself. */
79
+ signers: SignerDraft[];
80
+ policies: PolicyRef[];
81
+ };
82
+ /** Mirrors OZ `Signer` exactly: `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
83
+ export type SignerDraft = {
84
+ kind: 'delegated';
85
+ address: string;
86
+ } | {
87
+ kind: 'external';
88
+ verifier: string;
89
+ keyBytes: string;
90
+ };
91
+ /** Reference to one policy attached to a context rule. */
92
+ export type PolicyRef = {
93
+ kind: 'oz_builtin';
94
+ primitive: OZPrimitiveConfig;
95
+ instanceAddress: string;
96
+ } | {
97
+ kind: 'interpreter';
98
+ interpreterAddress: string;
99
+ predicateBlobBase64: string;
100
+ };
101
+ /** A serialised predicate document stored against a (smart_account, rule_id) pair. */
102
+ export interface PolicyDocument {
103
+ /** Grammar version baked into the interpreter wasm. Fail-closed strict match at install
104
+ * AND at evaluate (defence in depth). NOT the per-edit revision. */
105
+ grammarVersion: 1;
106
+ /** Per-rule install nonce. Must equal the interpreter's stored nonce + 1 at install.
107
+ * First install accepts nonce = 1 with stored nonce = 0. Re-install is structurally
108
+ * possible by incrementing the nonce; uninstall removes the nonce with state so a
109
+ * subsequent install returns to nonce = 1. Replay of an old install denied. */
110
+ installNonce: number;
111
+ /** Canonical ScVal encoding (NOT JSON) - the version-match bypass is closed by the
112
+ * on-chain serialisation, not by a parser. */
113
+ encodedPredicate: string;
114
+ /** Hash of the post-canonicalisation bytes of encodedPredicate. Stored alongside the
115
+ * doc and re-verified at evaluate. */
116
+ predicateHash: string;
117
+ /** Per-policy overrides of oracle thresholds; absent = use defaults.
118
+ * Overrides may TIGHTEN only (never widen) - the interpreter rejects any per-policy
119
+ * value looser than the wasm-level defaults at install. */
120
+ oracleParams?: {
121
+ /** Seconds; must be <= MAX_ORACLE_STALENESS_SECONDS (600). */
122
+ maxStalenessSeconds?: number;
123
+ /** Basis points; must be <= MAX_ORACLE_DEVIATION_BPS (200). */
124
+ maxDeviationBps?: number;
125
+ };
126
+ }
127
+ /** The versioned predicate AST (interpreter side). Tagged union. */
128
+ export type PredicateNode = {
129
+ op: 'and';
130
+ children: PredicateNode[];
131
+ } | {
132
+ op: 'or';
133
+ children: PredicateNode[];
134
+ } | {
135
+ op: 'not';
136
+ child: PredicateNode;
137
+ } | {
138
+ op: 'eq';
139
+ left: PredicateLeaf;
140
+ right: PredicateLeaf;
141
+ } | {
142
+ op: 'lt' | 'lte' | 'gt' | 'gte';
143
+ left: PredicateLeaf;
144
+ right: PredicateLeaf;
145
+ } | {
146
+ op: 'in';
147
+ needle: PredicateLeaf;
148
+ haystack: PredicateLeaf[];
149
+ };
150
+ export type PredicateLeaf = {
151
+ kind: 'call_contract';
152
+ } | {
153
+ kind: 'call_fn';
154
+ } | {
155
+ kind: 'call_arg';
156
+ index: number;
157
+ } | {
158
+ kind: 'amount';
159
+ token: string;
160
+ } | {
161
+ kind: 'window_spent';
162
+ token: string;
163
+ } | {
164
+ kind: 'now';
165
+ } | {
166
+ kind: 'valid_until';
167
+ } | {
168
+ kind: 'invocation_count_in_window';
169
+ windowSecs: number;
170
+ } | {
171
+ kind: 'oracle_price';
172
+ asset: string;
173
+ };
174
+ export interface OZPrimitiveConfig {
175
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold';
176
+ params: Record<string, unknown>;
177
+ }
178
+ export interface ContractInvocation {
179
+ contract: string;
180
+ fn: string;
181
+ args: ScVal[];
182
+ subInvocations: ContractInvocation[];
183
+ }
184
+ export interface TokenMovement {
185
+ token: string;
186
+ from: string;
187
+ to: string;
188
+ amount: string;
189
+ }
190
+ export interface OnChainEvent {
191
+ contract: string;
192
+ topics: string[];
193
+ data: ScVal;
194
+ }
195
+ export interface RecordedTransaction {
196
+ network: Network;
197
+ /** Authorising signers (the agent / owner set). */
198
+ signers: string[];
199
+ /** Top-level call + nested sub-invocations captured for analysis:
200
+ * v1 grammar matches only the top-level call's own `(contract, fn_name, args)`
201
+ * that `Policy::enforce` receives - sub-invocations are NOT walked in v1. */
202
+ invocations: ContractInvocation[];
203
+ /** All token movements (from events). */
204
+ tokenMovements: TokenMovement[];
205
+ /** Raw on-chain events from getTransaction (used for validation). */
206
+ events: OnChainEvent[];
207
+ /** Soroban auth entries (which signers, which contracts, which fns). */
208
+ authEntries: unknown[];
209
+ ledgerSequence: number;
210
+ fetchedAt: number;
211
+ /** The parsed-decoding freshness, computed by the recorder. The synth refuses on
212
+ * parseConfidence.overall < parseConfidence.thresholdUsed. */
213
+ parseConfidence: ParseConfidence;
214
+ /** Source account (already recorded, needed by install + reviewer). */
215
+ sourceAccount: string;
216
+ }
217
+ export interface ProposedPolicy {
218
+ contextRule: ContextRuleDraft;
219
+ policyDocuments: PolicyDocument[];
220
+ policyRefs: PolicyRef[];
221
+ /** Parsed-decoding freshness mirror; the orchestrator refuses when below threshold. */
222
+ parseConfidence: ParseConfidence;
223
+ warnings: string[];
224
+ ambiguities: AmbiguityPrompt[];
225
+ }
226
+ export interface AmbiguityPrompt {
227
+ code: AmbiguityCode;
228
+ question: string;
229
+ }
230
+ /** Catalogue of named ambiguity codes (the skill's prompt files are one-per-code).
231
+ * Adding a code is a synthesizer decision; the prompt authoring must not
232
+ * invent codes outside this union. */
233
+ export type AmbiguityCode = 'DURATION_UNSPECIFIED' | 'AMOUNT_BOUND_MISSING' | 'RECIPIENT_ALLOWLIST_EMPTY' | 'FREQUENCY_BOUND_MISSING' | 'ORACLE_ASSET_UNKNOWN' | 'MULTIPLE_UNRELATED_TARGETS';
234
+ /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
235
+ * breakdown is the diagnostic the user / agent sees when the gate fires. The
236
+ * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`. */
237
+ export interface ParseConfidence {
238
+ overall: number;
239
+ knownContracts: string[];
240
+ unknownContracts: Array<{
241
+ contract: string;
242
+ reason: 'no-abi' | 'version-mismatch' | 'opaque-result';
243
+ }>;
244
+ opaqueScVals: Array<{
245
+ path: string;
246
+ type: string;
247
+ }>;
248
+ thresholdUsed: number;
249
+ }
package/dist/types.js ADDED
@@ -0,0 +1,35 @@
1
+ // src/types.ts - shared domain types (single source of truth)
2
+ /** OZ Accounts framework hard limits (verified against canonical OZ source). */
3
+ export const OZ_LIMITS = {
4
+ maxPoliciesPerRule: 5,
5
+ maxSignersPerRule: 15,
6
+ // NO per-account rule cap - OZ imposes none.
7
+ };
8
+ /** Soroban / Stellar protocol limits used to bound inputs fail-closed. Single
9
+ * source so the schema boundary and the core validators cannot drift. */
10
+ export const SOROBAN_LIMITS = {
11
+ /** `valid_until` and other ledger-sequence fields are u32. */
12
+ u32Max: 4294967295,
13
+ /** Upper bound on top-level invocations in a recorded transaction; a real
14
+ * Stellar tx caps operations well below this. Bounds a hand-crafted payload. */
15
+ maxInvocations: 512,
16
+ /** Stellar targets a ~5s ledger close time (DAY_IN_LEDGERS = 17280 = 86400/5),
17
+ * used to convert a spend window in seconds to OZ `period_ledgers`. */
18
+ secondsPerLedger: 5,
19
+ };
20
+ /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
21
+ * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
22
+ * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
23
+ * disagree, the interpreter is authoritative. Rationale per cap:
24
+ * - MAX_DEPTH = 5: 3 walkthroughs need depth 2; budget = 2x growth + 1 for `not` chain.
25
+ * - MAX_LEAVES = 200: allowlists (recipients, paths) typically < 100; budget = 2x + 1 nested allowlist.
26
+ * - MAX_PREDICATE_BYTES = 32 KB: well under the per-ledger-entry 64 KB persistent-storage limit.
27
+ * - 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.
28
+ * - MAX_IN_OPERAND_COUNT = 32: allowlist < 32 is a UX-natural bound. */
29
+ export const PREDICATE_CAPS = {
30
+ MAX_DEPTH: 5,
31
+ MAX_LEAVES: 200,
32
+ MAX_PREDICATE_BYTES: 32 * 1024,
33
+ MAX_ORACLE_READS: 6,
34
+ MAX_IN_OPERAND_COUNT: 32,
35
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@crediolabs/policy-synth",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
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
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "bun": "./src/index.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "!src/**/*.test.ts"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "bun test",
27
+ "build": "tsc -p tsconfig.build.json"
28
+ },
29
+ "dependencies": {
30
+ "@stellar/stellar-sdk": "14.4.0"
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "2.5.5",
34
+ "@types/node": "^26.1.1",
35
+ "typescript": "5.9.3"
36
+ }
37
+ }
@@ -0,0 +1,363 @@
1
+ // src/adapters/oz/adapter.ts - the OZ Accounts CustodyAdapter (Path-A lowering).
2
+ //
3
+ // Compiles a PolicyIR to an OZ `ProposedPolicy` using OZ built-in policy
4
+ // primitives (Path A). Only the constructs OZ can express natively are lowered:
5
+ // scope.contract -> ContextRuleType.call_contract (else default)
6
+ // expiry.validUntilLedger -> ContextRuleDraft.validUntilLedger
7
+ // window_spent(t,w) <= L -> `spending_limit` primitive
8
+ // approval.threshold -> `simple_threshold` / `weighted_threshold`
9
+ // Anything needing a capability this backend lacks (oracle price, invocation
10
+ // count, per-arg comparison/allowlist, guard, nested boolean predicate) is NOT
11
+ // emitted: it is named in `uncovered` and `covered` is set false. Nothing is
12
+ // silently dropped - the uncovered constructs are the Path-B markers.
13
+ //
14
+ // OZ built-in policy instance addresses are per-network deploy artifacts we do
15
+ // not have yet (install is a later phase). They are injected via config; week-1
16
+ // ships a clearly-labelled [VERIFY] placeholder so nothing invents an address.
17
+
18
+ import type { IRCondition, IRPolicyRule, IRSelector, PolicyIR } from '../../ir/types.ts'
19
+ import type {
20
+ CompileResult,
21
+ CustodyAdapter,
22
+ CustodyCapabilities,
23
+ SimulationResult,
24
+ } from '../../seams/types.ts'
25
+ import type {
26
+ ContextRuleDraft,
27
+ Network,
28
+ ParseConfidence,
29
+ PolicyRef,
30
+ ProposedPolicy,
31
+ } from '../../types.ts'
32
+ import { OZ_LIMITS, SOROBAN_LIMITS } from '../../types.ts'
33
+
34
+ /** OZ built-in policy contract instance addresses, per primitive. */
35
+ export interface OzPrimitiveInstances {
36
+ spending_limit: string
37
+ simple_threshold: string
38
+ weighted_threshold: string
39
+ }
40
+
41
+ /** Per-network config for the OZ adapter. */
42
+ export interface OzAdapterConfig {
43
+ network: Network
44
+ instances: OzPrimitiveInstances
45
+ }
46
+
47
+ /** [VERIFY] NOT real deployed addresses. The OZ built-in policy instances are
48
+ * per-network deploy artifacts we do not have yet; install is a later phase.
49
+ * Injected so the adapter never invents a Stellar contract address. */
50
+ export const PLACEHOLDER_OZ_INSTANCES: OzPrimitiveInstances = {
51
+ spending_limit: 'VERIFY-oz-spending-limit-instance-address',
52
+ simple_threshold: 'VERIFY-oz-simple-threshold-instance-address',
53
+ weighted_threshold: 'VERIFY-oz-weighted-threshold-instance-address',
54
+ }
55
+
56
+ /** Week-1 OZ adapter config with placeholder instance addresses. */
57
+ export function placeholderOzConfig(network: Network): OzAdapterConfig {
58
+ return { network, instances: PLACEHOLDER_OZ_INSTANCES }
59
+ }
60
+
61
+ /** Parse confidence for a deterministic (non-decoded) input: full, with an
62
+ * empty unknown/opaque breakdown. A mandate needs no decoding, so the gate is
63
+ * not applicable and confidence is 1. */
64
+ const FULL_PARSE_CONFIDENCE: ParseConfidence = {
65
+ overall: 1,
66
+ knownContracts: [],
67
+ unknownContracts: [],
68
+ opaqueScVals: [],
69
+ thresholdUsed: 1,
70
+ }
71
+
72
+ const CAPABILITIES: CustodyCapabilities = {
73
+ supportsSpendWindow: true,
74
+ supportsThreshold: true,
75
+ supportsTimeExpiry: true,
76
+ supportsOraclePrice: false,
77
+ supportsInvocationCount: false,
78
+ supportsGeneralPredicate: false,
79
+ }
80
+
81
+ export function createOzAdapter(config: OzAdapterConfig): CustodyAdapter {
82
+ return {
83
+ name: 'oz-accounts',
84
+ mode: 'enforce',
85
+ capabilities: () => ({ ...CAPABILITIES }),
86
+ compile: (ir) => compile(ir, config),
87
+ simulate: () => simulateStub(),
88
+ export: (ir) => canonicalStringify(ir),
89
+ }
90
+ }
91
+
92
+ function compile(ir: PolicyIR, config: OzAdapterConfig): CompileResult {
93
+ const uncovered: string[] = []
94
+
95
+ const firstRule = ir.rules[0]
96
+ if (!firstRule) {
97
+ return { covered: false, uncovered: ['empty PolicyIR (no rules to compile)'] }
98
+ }
99
+ if (ir.rules.length > 1) {
100
+ uncovered.push(
101
+ `multi-rule PolicyIR: ${ir.rules.length - 1} rule(s) beyond the first are not compiled (a ProposedPolicy carries a single context rule in this slice)`
102
+ )
103
+ }
104
+
105
+ const lowered = lowerRule(firstRule, config)
106
+ uncovered.push(...lowered.uncovered)
107
+
108
+ const result: CompileResult = { covered: uncovered.length === 0, uncovered }
109
+ if (!lowered.capExceeded) {
110
+ const proposed: ProposedPolicy = {
111
+ contextRule: lowered.contextRule,
112
+ policyDocuments: [],
113
+ policyRefs: lowered.policyRefs,
114
+ parseConfidence: { ...FULL_PARSE_CONFIDENCE },
115
+ warnings: [],
116
+ ambiguities: [],
117
+ }
118
+ result.proposed = proposed
119
+ }
120
+ return result
121
+ }
122
+
123
+ interface LoweredRule {
124
+ contextRule: ContextRuleDraft
125
+ policyRefs: PolicyRef[]
126
+ uncovered: string[]
127
+ capExceeded: boolean
128
+ }
129
+
130
+ function lowerRule(rule: IRPolicyRule, config: OzAdapterConfig): LoweredRule {
131
+ const uncovered: string[] = []
132
+ const policyRefs: PolicyRef[] = []
133
+
134
+ // scope -> context rule type. OZ scopes by contract (CallContract); a finer
135
+ // method-level restriction is a predicate concern and must be flagged Path-B
136
+ // because CallContract alone permits other methods on the same contract
137
+ // (e.g. an unbounded approve alongside a capped transfer).
138
+ const contextRuleType: ContextRuleDraft['contextRuleType'] =
139
+ rule.scope.contract !== undefined
140
+ ? { kind: 'call_contract', contract: rule.scope.contract }
141
+ : { kind: 'default' }
142
+
143
+ if (rule.scope.method !== undefined && rule.scope.contract !== undefined) {
144
+ uncovered.push(
145
+ `per-method scoping to \`${rule.scope.method}\` (OZ CallContract scopes by contract only; requires the interpreter predicate)`
146
+ )
147
+ }
148
+
149
+ if (rule.scope.chainId !== undefined) {
150
+ uncovered.push(
151
+ `chainId \`${rule.scope.chainId}\` not bindable by the Stellar OZ adapter (network is per-context, not per-rule)`
152
+ )
153
+ }
154
+
155
+ if (rule.roles.length > 0) {
156
+ uncovered.push(
157
+ `roles [${rule.roles.join(', ')}] dropped (role-to-signer mapping is a later phase; OZ signers carry addresses, not role names)`
158
+ )
159
+ }
160
+
161
+ // expiry -> validUntilLedger. Unix-timestamp expiry cannot be expressed by an
162
+ // OZ context rule (it expires by ledger sequence), so flag it.
163
+ let validUntilLedger: number | null = null
164
+ if (rule.expiry) {
165
+ if (rule.expiry.validUntilLedger !== undefined) {
166
+ validUntilLedger = rule.expiry.validUntilLedger
167
+ } else if (rule.expiry.validUntilUnixSeconds !== undefined) {
168
+ uncovered.push(
169
+ 'time expiry given as a unix timestamp (OZ context rules expire by ledger sequence; supply expiry.validUntilLedger)'
170
+ )
171
+ }
172
+ }
173
+
174
+ // guard -> Path B (applicability predicates are not an OZ built-in).
175
+ if (rule.guard) {
176
+ uncovered.push(`guard: ${describeCondition(rule.guard)}`)
177
+ }
178
+
179
+ // constraints -> spending_limit where they match; else Path B. The OZ
180
+ // spending_limit policy takes `{ spending_limit: i128, period_ledgers: u32 }`
181
+ // and has NO token param: it only accepts a CallContract context rule
182
+ // (OnlyCallContractAllowed) and limits transfers of that context's contract,
183
+ // so the spent token must equal the scope contract, and the window is a
184
+ // ledger count (~5s/ledger), not seconds.
185
+ for (const c of rule.constraints) {
186
+ const spend = matchSpendingLimit(c)
187
+ if (!spend) {
188
+ uncovered.push(describeCondition(c))
189
+ continue
190
+ }
191
+ if (contextRuleType.kind !== 'call_contract' || spend.token !== contextRuleType.contract) {
192
+ uncovered.push(
193
+ `spending_limit on token ${spend.token} needs a CallContract context scoped to that token (OZ pins the limit to the context contract, not a token param)`
194
+ )
195
+ continue
196
+ }
197
+ policyRefs.push({
198
+ kind: 'oz_builtin',
199
+ primitive: {
200
+ primitive: 'spending_limit',
201
+ params: {
202
+ spending_limit: spend.limit,
203
+ period_ledgers: secondsToLedgers(spend.windowSeconds),
204
+ },
205
+ },
206
+ instanceAddress: config.instances.spending_limit,
207
+ })
208
+ }
209
+
210
+ // approval.threshold -> simple/weighted threshold primitive. A threshold < 1
211
+ // is not a real M-of-N gate (0 approvals authorises everything), so refuse to
212
+ // emit a no-op primitive and flag it Path-B instead.
213
+ if (rule.approval) {
214
+ if (!Number.isInteger(rule.approval.threshold) || rule.approval.threshold < 1) {
215
+ uncovered.push(
216
+ `approval threshold ${rule.approval.threshold} is not a positive integer (a 0 or negative threshold is not an M-of-N gate)`
217
+ )
218
+ } else {
219
+ const weights = rule.approval.weights
220
+ if (weights && Object.keys(weights).length > 0) {
221
+ policyRefs.push({
222
+ kind: 'oz_builtin',
223
+ primitive: {
224
+ primitive: 'weighted_threshold',
225
+ params: { threshold: rule.approval.threshold, weights },
226
+ },
227
+ instanceAddress: config.instances.weighted_threshold,
228
+ })
229
+ } else {
230
+ policyRefs.push({
231
+ kind: 'oz_builtin',
232
+ primitive: {
233
+ primitive: 'simple_threshold',
234
+ params: { threshold: rule.approval.threshold },
235
+ },
236
+ instanceAddress: config.instances.simple_threshold,
237
+ })
238
+ }
239
+ }
240
+ }
241
+
242
+ const capExceeded = policyRefs.length > OZ_LIMITS.maxPoliciesPerRule
243
+ if (capExceeded) {
244
+ uncovered.push(
245
+ `policy count ${policyRefs.length} exceeds OZ maxPoliciesPerRule (${OZ_LIMITS.maxPoliciesPerRule})`
246
+ )
247
+ }
248
+
249
+ const contextRule: ContextRuleDraft = {
250
+ contextRuleType,
251
+ name:
252
+ contextRuleType.kind === 'call_contract'
253
+ ? `call_contract:${contextRuleType.contract}`
254
+ : 'default',
255
+ validUntilLedger,
256
+ signers: [],
257
+ policies: policyRefs,
258
+ }
259
+
260
+ return { contextRule, policyRefs, uncovered, capExceeded }
261
+ }
262
+
263
+ /** Convert a spend window in seconds to OZ `period_ledgers` (u32, >= 1).
264
+ * Stellar targets a ~5s ledger close time. */
265
+ function secondsToLedgers(windowSeconds: number): number {
266
+ return Math.max(1, Math.round(windowSeconds / SOROBAN_LIMITS.secondsPerLedger))
267
+ }
268
+
269
+ interface SpendingLimitMatch {
270
+ token: string
271
+ limit: string
272
+ windowSeconds: number
273
+ }
274
+
275
+ /** Match the `window_spent(token, window) <= limit` compare that lowers to the
276
+ * OZ `spending_limit` primitive. Only `lte` matches (the spend-cap semantic). */
277
+ function matchSpendingLimit(c: IRCondition): SpendingLimitMatch | null {
278
+ if (c.op !== 'compare') return null
279
+ const { selector, operator, value } = c.compare
280
+ if (selector.kind !== 'window_spent' || operator !== 'lte') return null
281
+ return { token: selector.token, limit: value, windowSeconds: selector.windowSeconds }
282
+ }
283
+
284
+ /** Human-readable descriptor for a construct the OZ Path-A backend cannot
285
+ * express, used to populate `uncovered` (the Path-B markers). */
286
+ function describeCondition(cond: IRCondition): string {
287
+ switch (cond.op) {
288
+ case 'in':
289
+ return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist, Path B)`
290
+ case 'not':
291
+ return 'negated condition (predicate DSL, Path B)'
292
+ case 'and':
293
+ case 'or':
294
+ return `nested ${cond.op} condition (predicate DSL, Path B)`
295
+ case 'compare': {
296
+ const s = cond.compare.selector
297
+ switch (s.kind) {
298
+ case 'oracle_price':
299
+ return `oracle price condition on ${s.asset} (oracle price not supported in week-1)`
300
+ case 'invocation_count':
301
+ return `invocation-count window (${s.windowSeconds}s) condition (not supported in week-1)`
302
+ case 'window_spent':
303
+ return `spend-window comparison with operator '${cond.compare.operator}' (only 'lte' lowers to spending_limit)`
304
+ case 'amount':
305
+ return `per-call amount comparison on ${s.token} (predicate DSL, Path B)`
306
+ case 'arg':
307
+ return `argument comparison on arg ${s.argIndex} (predicate DSL, Path B)`
308
+ case 'calldata':
309
+ return 'EVM calldata comparison (predicate DSL, Path B)'
310
+ case 'value':
311
+ return 'tx.value comparison (predicate DSL, Path B)'
312
+ case 'now':
313
+ case 'valid_until':
314
+ return 'time comparison (predicate DSL, Path B)'
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ function describeSelector(s: IRSelector): string {
321
+ switch (s.kind) {
322
+ case 'arg':
323
+ return `arg ${s.argIndex}`
324
+ case 'amount':
325
+ return `amount(${s.token})`
326
+ case 'window_spent':
327
+ return `window_spent(${s.token})`
328
+ case 'oracle_price':
329
+ return `oracle_price(${s.asset})`
330
+ case 'invocation_count':
331
+ return `invocation_count(${s.windowSeconds}s)`
332
+ case 'calldata':
333
+ return `calldata[${s.offset}:${s.offset + s.length}]`
334
+ default:
335
+ return s.kind
336
+ }
337
+ }
338
+
339
+ function simulateStub(): SimulationResult {
340
+ return {
341
+ backend: 'ts-model',
342
+ permitted: null,
343
+ evaluations: [],
344
+ notes: ['stub: real permit/deny semantics wiring is a later phase'],
345
+ }
346
+ }
347
+
348
+ /** Canonical JSON with recursively sorted object keys (stable across runs). */
349
+ function canonicalStringify(value: unknown): string {
350
+ return JSON.stringify(sortKeys(value))
351
+ }
352
+
353
+ function sortKeys(value: unknown): unknown {
354
+ if (Array.isArray(value)) return value.map(sortKeys)
355
+ if (value && typeof value === 'object') {
356
+ const out: Record<string, unknown> = {}
357
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
358
+ out[key] = sortKeys((value as Record<string, unknown>)[key])
359
+ }
360
+ return out
361
+ }
362
+ return value
363
+ }
@@ -0,0 +1,9 @@
1
+ // src/adapters/oz/index.ts - re-export the OZ Accounts CustodyAdapter.
2
+
3
+ export {
4
+ createOzAdapter,
5
+ type OzAdapterConfig,
6
+ type OzPrimitiveInstances,
7
+ PLACEHOLDER_OZ_INSTANCES,
8
+ placeholderOzConfig,
9
+ } from './adapter.ts'