@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,24 @@
1
+ // src/registry/index.ts - re-export the protocol adjacency registry.
2
+
3
+ export {
4
+ type IdentifiedProtocol,
5
+ identifyProtocol,
6
+ } from './identify.ts'
7
+
8
+ export {
9
+ addressToProtocol,
10
+ KNOWN_ADDRESSES,
11
+ type KnownAddressesByNetwork,
12
+ type KnownAddressesByProtocol,
13
+ } from './known-addresses.ts'
14
+ export {
15
+ type AbiArg,
16
+ type AbiEntry,
17
+ BLEND_ABI,
18
+ getAbi,
19
+ PROTOCOL_ABIS,
20
+ type ProtocolAbi,
21
+ type ProtocolId,
22
+ SEP41_ABI,
23
+ SOROSWAP_ABI,
24
+ } from './protocols.ts'
@@ -0,0 +1,71 @@
1
+ // src/registry/known-addresses.ts - pinned real protocol contract addresses.
2
+ //
3
+ // Addresses here are fetched from authoritative public sources:
4
+ // - Blend mainnet: docs.blend.capital/mainnet-deployments
5
+ // https://docs.blend.capital/mainnet-deployments
6
+ // - SoroSwap mainnet: github.com/soroswap (organisation README)
7
+ // https://github.com/soroswap
8
+ //
9
+ // Testnet addresses are intentionally NOT pinned: the public docs do not
10
+ // publish them, and per the task brief, unconfirmed addresses are marked
11
+ // [VERIFY] and excluded from the active set rather than invented.
12
+ //
13
+ // SEP-41 is recognised by interface (any token), so no address list is needed
14
+ // for it - see identify.ts.
15
+
16
+ import type { ProtocolId } from './protocols.ts'
17
+
18
+ /** Pinned contract addresses per (network, protocol). Empty list = no
19
+ * addresses pinned for that cell. */
20
+ export interface KnownAddressesByProtocol {
21
+ blend: string[]
22
+ soroswap: string[]
23
+ }
24
+
25
+ export interface KnownAddressesByNetwork {
26
+ mainnet: KnownAddressesByProtocol
27
+ testnet: KnownAddressesByProtocol
28
+ }
29
+
30
+ // Source: https://docs.blend.capital/mainnet-deployments (Blend Pool Factory
31
+ // mainnet deployment, fetched 2026-07-23).
32
+ const BLEND_MAINNET_POOL_FACTORY = 'CDSYOAVXFY7SM5S64IZPPPYB4GVGGLMQVFREPSQQEZVIWXX5R23G4QSU'
33
+
34
+ // Source: https://github.com/soroswap (organisation README, fetched 2026-07-23)
35
+ // SoroswapRouter mainnet deployment.
36
+ const SOROSWAP_MAINNET_ROUTER = 'CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH'
37
+
38
+ // Source: https://github.com/soroswap (organisation README, fetched 2026-07-23)
39
+ // SoroswapFactory mainnet deployment.
40
+ const SOROSWAP_MAINNET_FACTORY = 'CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2'
41
+
42
+ export const KNOWN_ADDRESSES: KnownAddressesByNetwork = {
43
+ mainnet: {
44
+ blend: [BLEND_MAINNET_POOL_FACTORY],
45
+ soroswap: [SOROSWAP_MAINNET_ROUTER, SOROSWAP_MAINNET_FACTORY],
46
+ },
47
+ testnet: {
48
+ // [VERIFY] Blend testnet pool factory address: not published in the
49
+ // public docs as of 2026-07-23. Excluded from the active set until an
50
+ // authoritative source is found.
51
+ blend: [],
52
+ // [VERIFY] SoroSwap testnet router / factory addresses: not published in
53
+ // the public docs as of 2026-07-23. Excluded from the active set.
54
+ soroswap: [],
55
+ },
56
+ }
57
+
58
+ /** Map a (contract, network) pair to its pinned protocol id. Returns null
59
+ * when the address is not pinned for that network - callers MUST preserve
60
+ * the fail-closed posture for unpinned contracts. */
61
+ export function addressToProtocol(
62
+ contract: string,
63
+ network: 'mainnet' | 'testnet'
64
+ ): ProtocolId | null {
65
+ for (const protocol of ['blend', 'soroswap'] as const) {
66
+ if (KNOWN_ADDRESSES[network][protocol].includes(contract)) {
67
+ return protocol
68
+ }
69
+ }
70
+ return null
71
+ }
@@ -0,0 +1,176 @@
1
+ // src/registry/protocols.ts - protocol ABI definitions (no addresses).
2
+ //
3
+ // A protocol is identified by EITHER:
4
+ // - by interface (function-name signature + arg shape): a contract calling
5
+ // one of these well-known fns with the expected arg shape IS that
6
+ // protocol, regardless of address. SEP-41 is the canonical case - ANY
7
+ // token contract calling `transfer` with the right arg shape is a
8
+ // SEP-41 token. Blend pool `submit`/`claim` are similarly recognised by
9
+ // interface (the pool factory address doesn't cover per-pool instances).
10
+ // - by address: the contract is in the pinned `known-addresses` set (see
11
+ // known-addresses.ts). SoroSwap uses address recognition because the
12
+ // router is a single deployed contract; Blend uses it to recognise factory
13
+ // calls in addition to its interface recognition.
14
+ //
15
+ // Args entries pin a ScVal subset type (matching the normalised `ScVal`
16
+ // vocabulary in src/types.ts) plus a human meaning string. The registry
17
+ // fails closed: a method call whose decoded args do NOT match the ABI
18
+ // signature is treated as unknown.
19
+
20
+ import type { ScVal } from '../types.ts'
21
+
22
+ export type ProtocolId = 'sep41' | 'blend' | 'soroswap'
23
+
24
+ /** ScVal subset type vocabulary the ABI uses. Mirrors `ScVal['type']` minus
25
+ * `other` (which by definition cannot be matched against an ABI arg). */
26
+ export type AbiArgType = Exclude<ScVal['type'], 'other'>
27
+
28
+ export interface AbiArg {
29
+ name: string
30
+ /** ScVal subset type the decoded arg must conform to. */
31
+ type: AbiArgType
32
+ /** Human-readable meaning for the downstream reviewer. */
33
+ meaning: string
34
+ }
35
+
36
+ export interface AbiEntry {
37
+ args: AbiArg[]
38
+ }
39
+
40
+ export type ProtocolAbi = Record<string, AbiEntry>
41
+
42
+ /** SEP-41 token interface - pinned from the SEP-41 spec (any token). */
43
+ export const SEP41_ABI: ProtocolAbi = {
44
+ transfer: {
45
+ args: [
46
+ {
47
+ name: 'from',
48
+ type: 'address',
49
+ meaning: 'source address (the `from` of the token transfer)',
50
+ },
51
+ {
52
+ name: 'to',
53
+ type: 'address',
54
+ meaning: 'destination address (the `to` of the token transfer)',
55
+ },
56
+ {
57
+ name: 'amount',
58
+ type: 'i128',
59
+ meaning: 'amount of tokens to move (i128, signed-decimal string)',
60
+ },
61
+ ],
62
+ },
63
+ mint: {
64
+ args: [
65
+ { name: 'to', type: 'address', meaning: 'recipient of the newly minted tokens' },
66
+ { name: 'amount', type: 'i128', meaning: 'amount to mint (i128, signed-decimal string)' },
67
+ ],
68
+ },
69
+ burn: {
70
+ args: [
71
+ { name: 'from', type: 'address', meaning: 'address whose balance is decremented' },
72
+ { name: 'amount', type: 'i128', meaning: 'amount to burn (i128, signed-decimal string)' },
73
+ ],
74
+ },
75
+ approve: {
76
+ args: [
77
+ { name: 'from', type: 'address', meaning: 'token holder granting the allowance' },
78
+ {
79
+ name: 'spender',
80
+ type: 'address',
81
+ meaning: 'address authorised to move tokens on behalf of `from`',
82
+ },
83
+ { name: 'amount', type: 'i128', meaning: 'allowance amount (i128, signed-decimal string)' },
84
+ {
85
+ name: 'expiration_ledger',
86
+ type: 'u32',
87
+ meaning: 'ledger sequence after which the allowance is invalid',
88
+ },
89
+ ],
90
+ },
91
+ }
92
+
93
+ /** Blend pool interface (v2). Pinning the two fns the recorder surfaces.
94
+ * Pool-level recognition is by INTERFACE (real pool calls hit per-pool
95
+ * instances, not the factory). Arg shapes verified against the Blend v2
96
+ * pool contract source (blend-capital/blend-contracts-v2 `pool/src/contract.rs`,
97
+ * fetched 2026-07-23):
98
+ * submit(env, from: Address, spender: Address, to: Address, requests: Vec<Request>)
99
+ * -> Positions
100
+ * claim(env, from: Address, reserve_token_ids: Vec<u32>, to: Address) -> i128
101
+ * Other pool fns (add_reserves, withdraw, etc.) are intentionally omitted -
102
+ * they are too version-sensitive to bind by interface. */
103
+ export const BLEND_ABI: ProtocolAbi = {
104
+ submit: {
105
+ args: [
106
+ { name: 'from', type: 'address', meaning: 'address supplying collateral or repaying debt' },
107
+ { name: 'spender', type: 'address', meaning: 'address authorising the supply / repay' },
108
+ { name: 'to', type: 'address', meaning: 'address receiving the resulting position shares' },
109
+ { name: 'requests', type: 'vec', meaning: 'vec<Request> describing the action per reserve' },
110
+ ],
111
+ },
112
+ claim: {
113
+ args: [
114
+ { name: 'from', type: 'address', meaning: 'address whose position is being claimed against' },
115
+ {
116
+ name: 'reserve_token_ids',
117
+ type: 'vec',
118
+ meaning: 'vec<u32> reserve token ids to claim emissions for',
119
+ },
120
+ { name: 'to', type: 'address', meaning: 'address receiving the claimed tokens' },
121
+ ],
122
+ },
123
+ }
124
+
125
+ /** SoroSwap router interface. Standard Uniswap-V2-style swap fn signatures
126
+ * pinned from the SoroSwap router source. Recognised by address only (the
127
+ * router is a single pinned contract); FIX 4 requires the method to also be
128
+ * in this ABI for address recognition to succeed. The `path` arg is a
129
+ * `vec<address>` - we record it as the outer `vec` subset and leave the
130
+ * per-element shape to the downstream reviewer. */
131
+ export const SOROSWAP_ABI: ProtocolAbi = {
132
+ swap_exact_tokens_for_tokens: {
133
+ args: [
134
+ { name: 'amount_in', type: 'i128', meaning: 'exact input amount (i128)' },
135
+ { name: 'amount_out_min', type: 'i128', meaning: 'minimum acceptable output (i128)' },
136
+ { name: 'path', type: 'vec', meaning: 'vec<address> hop path through pools' },
137
+ { name: 'to', type: 'address', meaning: 'recipient of the output tokens' },
138
+ {
139
+ name: 'deadline',
140
+ type: 'u64',
141
+ meaning: 'unix-seconds deadline after which the swap is refused',
142
+ },
143
+ ],
144
+ },
145
+ swap_tokens_for_exact_tokens: {
146
+ args: [
147
+ { name: 'amount_out', type: 'i128', meaning: 'exact output amount desired (i128)' },
148
+ { name: 'amount_in_max', type: 'i128', meaning: 'maximum input willing to spend (i128)' },
149
+ { name: 'path', type: 'vec', meaning: 'vec<address> hop path through pools' },
150
+ { name: 'to', type: 'address', meaning: 'recipient of the output tokens' },
151
+ {
152
+ name: 'deadline',
153
+ type: 'u64',
154
+ meaning: 'unix-seconds deadline after which the swap is refused',
155
+ },
156
+ ],
157
+ },
158
+ swap_exact_in_for_tokens: {
159
+ args: [
160
+ { name: 'amount_in', type: 'i128', meaning: 'exact input amount (i128)' },
161
+ { name: 'amount_out_min', type: 'i128', meaning: 'minimum acceptable output (i128)' },
162
+ { name: 'path', type: 'vec', meaning: 'vec<address> hop path through pools' },
163
+ { name: 'to', type: 'address', meaning: 'recipient of the output tokens' },
164
+ ],
165
+ },
166
+ }
167
+
168
+ export const PROTOCOL_ABIS: Record<ProtocolId, ProtocolAbi> = {
169
+ sep41: SEP41_ABI,
170
+ blend: BLEND_ABI,
171
+ soroswap: SOROSWAP_ABI,
172
+ }
173
+
174
+ export function getAbi(protocol: ProtocolId): ProtocolAbi {
175
+ return PROTOCOL_ABIS[protocol]
176
+ }
@@ -0,0 +1,11 @@
1
+ // src/seams/index.ts - re-export the three custody-seam contracts.
2
+
3
+ export type {
4
+ ChainDecoder,
5
+ CompileResult,
6
+ CustodyAdapter,
7
+ CustodyCapabilities,
8
+ CustodyMode,
9
+ PolicySource,
10
+ SimulationResult,
11
+ } from './types.ts'
@@ -0,0 +1,81 @@
1
+ // src/seams/types.ts - the three custody seams (contracts, not implementations).
2
+ //
3
+ // The synthesizer is decoupled from its edges by three seams:
4
+ // - PolicySource (INPUT): produces a normalized RecordedTransaction. The
5
+ // recorder is the reference source; a MandateSpec
6
+ // lowering is a second, deterministic source.
7
+ // - ChainDecoder (DECODE): chain-specific call data -> normalized facts.
8
+ // - CustodyAdapter (OUTPUT): compile a PolicyIR to a backend-native policy +
9
+ // verify/export it (OZ is the first adapter).
10
+ // These are INTERFACES only. Implementations live in their own modules.
11
+
12
+ import type { ToolResponse } from '../errors.ts'
13
+ import type { PolicyIR } from '../ir/types.ts'
14
+ import type { Network, ProposedPolicy, RecordedTransaction, ScVal } from '../types.ts'
15
+
16
+ /** INPUT: something that produces a normalized RecordedTransaction. */
17
+ export interface PolicySource {
18
+ readonly name: string
19
+ capture(input: {
20
+ hash?: string
21
+ xdr?: string
22
+ network: Network
23
+ }): Promise<ToolResponse<RecordedTransaction>>
24
+ }
25
+
26
+ /** DECODE: chain-specific call data -> the normalized facts the synth reasons
27
+ * over. */
28
+ export interface ChainDecoder {
29
+ readonly chain: 'stellar' | 'evm'
30
+ decodeInvocation(tx: RecordedTransaction): { contract: string; method: string; args: ScVal[] }
31
+ }
32
+
33
+ /** OZ = enforce; `log_only` is reserved for shadow mode (out of scope). */
34
+ export type CustodyMode = 'enforce' | 'log_only'
35
+
36
+ /** What a backend can express. A construct needing a false flag is flagged
37
+ * `uncovered` by the adapter, never silently dropped. */
38
+ export interface CustodyCapabilities {
39
+ supportsOraclePrice: boolean
40
+ supportsSpendWindow: boolean
41
+ supportsInvocationCount: boolean
42
+ supportsTimeExpiry: boolean
43
+ supportsThreshold: boolean
44
+ supportsGeneralPredicate: boolean
45
+ }
46
+
47
+ /** The result of compiling a PolicyIR for one backend. */
48
+ export interface CompileResult {
49
+ /** false => some IR construct this backend cannot express (see `uncovered`). */
50
+ covered: boolean
51
+ /** Human-readable list of unsupported constructs (the Path-B markers). */
52
+ uncovered: string[]
53
+ /** The backend-native installable policy, assembled when a rule lowered. */
54
+ proposed?: ProposedPolicy
55
+ }
56
+
57
+ /** Result of a simulate() dry-run. `ts-model` is the off-chain TS evaluator.
58
+ * Real permit/deny semantics wiring is a later phase; week-1 returns a
59
+ * clearly-marked stub (empty `evaluations`, `permitted: null`). */
60
+ export interface SimulationResult {
61
+ backend: 'ts-model'
62
+ /** Whether the permit tx would be allowed; null until real semantics land. */
63
+ permitted: boolean | null
64
+ /** Per-construct evaluation trace; empty placeholder in this slice. */
65
+ evaluations: unknown[]
66
+ /** Diagnostics; marks the stub explicitly. */
67
+ notes: string[]
68
+ }
69
+
70
+ /** OUTPUT: compile a PolicyIR to a backend-specific installable policy. */
71
+ export interface CustodyAdapter {
72
+ readonly name: string
73
+ readonly mode: CustodyMode
74
+ capabilities(): CustodyCapabilities
75
+ compile(ir: PolicyIR): CompileResult
76
+ /** Phase-03 wiring; a clearly-marked stub in week-1. */
77
+ simulate(ir: PolicyIR, permitTx: RecordedTransaction): SimulationResult
78
+ /** Canonical JSON of the IR (portability / audit). */
79
+ export(ir: PolicyIR): string
80
+ // install(...) lands with a later phase; not part of this slice.
81
+ }
@@ -0,0 +1,226 @@
1
+ // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (Path A).
2
+ //
3
+ // Composes the canonical IR rule the OZ Path-A backend can compile. Fail-closed
4
+ // composition rules:
5
+ //
6
+ // - identify the protocol of the top-level call (registry.identifyProtocol).
7
+ // When it is unknown (null), emit NO OZ-primitive-producing IR node: the
8
+ // scope is kept (CallContract + method) and every inferred bound is surfaced
9
+ // as a descriptive Path-B warning. An unrecognised call never compiles to a
10
+ // permissive OZ primitive.
11
+ //
12
+ // - carry the recorded top-level function into `rule.scope.method` so the OZ
13
+ // adapter flags per-method scoping Path-B (CallContract permits every method
14
+ // on the contract; a per-method restriction needs the interpreter predicate).
15
+ //
16
+ // - a `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
17
+ // the caller supplies BOTH the limit (userResponses.limitAmount) and the
18
+ // window (userResponses.windowSeconds). A single recorded spend does NOT
19
+ // authorise that amount every window, so the observed amount is NEVER used as
20
+ // an auto-ceiling: a missing limit surfaces AMOUNT_BOUND_MISSING (the observed
21
+ // amount is offered as a suggestion), a missing window surfaces
22
+ // DURATION_UNSPECIFIED. EVERY spent token is handled - none is silently
23
+ // dropped; a single caller limit binds only an unambiguous single-token spend.
24
+ //
25
+ // - incoming-only flows (e.g. Blend yield claim) emit an `invocation_count`
26
+ // bound ONLY when the caller supplies the count (userResponses.invocationLimit)
27
+ // and a window; otherwise FREQUENCY_BOUND_MISSING is surfaced with no
28
+ // fabricated count.
29
+ //
30
+ // - the IR carries ONLY constraints justified by the recording (observed
31
+ // recipient allowlist) + explicit user input. Nothing invented: no oracle
32
+ // price fabricated from a slippage bound, no synthetic exact-path compare.
33
+ // Those needs are surfaced as descriptive Path-B warnings instead.
34
+ //
35
+ // The default behavior is `deny_all` (OZ context rules are deny-by-default).
36
+
37
+ import type { IRCondition, IRPolicyRule, PolicyIR } from '../ir/types.ts'
38
+ import { type IdentifiedProtocol, identifyProtocol } from '../registry/identify.ts'
39
+ import type { AmbiguityPrompt, ContractInvocation, Network } from '../types.ts'
40
+ import type { IntentFacts } from './lower.ts'
41
+
42
+ /** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
43
+ * synth might apply must come from here - the recording supplies observed
44
+ * amounts (offered only as suggestions), never authorised ceilings. */
45
+ export interface ComposeUserResponses {
46
+ /** Rolling window (seconds) for a spending_limit / invocation_count. */
47
+ windowSeconds?: number
48
+ /** OZ context-rule expiry (ledger sequence). */
49
+ validUntilLedger?: number
50
+ /** Per-window spend ceiling (i128 decimal string). Required to emit a
51
+ * spending_limit; absent -> AMOUNT_BOUND_MISSING. */
52
+ limitAmount?: string
53
+ /** Max invocations per window for an incoming-only flow. Required to emit an
54
+ * invocation_count bound; absent -> FREQUENCY_BOUND_MISSING. */
55
+ invocationLimit?: number
56
+ }
57
+
58
+ /** Composition options. */
59
+ export interface ComposeOptions {
60
+ network: Network
61
+ userResponses?: ComposeUserResponses
62
+ }
63
+
64
+ /** Result of composition: the PolicyIR, any ambiguities surfaced during
65
+ * inference, and descriptive Path-B warnings for needs that are NOT expressed
66
+ * as an IR node (so no fabricated constraint is emitted). The orchestrator
67
+ * carries ambiguities into `ProposedPolicy.ambiguities` and merges warnings
68
+ * into `ProposedPolicy.warnings`. */
69
+ export interface ComposeResult {
70
+ ir: PolicyIR
71
+ ambiguities: AmbiguityPrompt[]
72
+ warnings: string[]
73
+ }
74
+
75
+ /** Compose a PolicyIR from the lowered facts + the resolved scope.
76
+ * Pure (no randomness, no clock); same inputs -> byte-identical result. */
77
+ export function composeFromRecording(
78
+ facts: IntentFacts,
79
+ scopeContract: string,
80
+ topLevel: ContractInvocation | null,
81
+ opts: ComposeOptions
82
+ ): ComposeResult {
83
+ const ambiguities: AmbiguityPrompt[] = []
84
+ const warnings: string[] = []
85
+ const constraints: IRCondition[] = []
86
+
87
+ const protocol = topLevel
88
+ ? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
89
+ : null
90
+ const known = protocol !== null
91
+
92
+ const windowSeconds = opts.userResponses?.windowSeconds
93
+ const limitAmount = opts.userResponses?.limitAmount
94
+ const spendTokens = Object.keys(facts.spendByToken)
95
+
96
+ // Outgoing spend -> one spending_limit per spent token. A single caller limit
97
+ // binds only an unambiguous single-token spend; a multi-token flow needs a
98
+ // per-token limit, so each unmatched token surfaces AMOUNT_BOUND_MISSING.
99
+ if (spendTokens.length > 0 && topLevel) {
100
+ let durationFlagged = false
101
+ for (const token of spendTokens) {
102
+ const observed = facts.spendByToken[token]
103
+ if (observed === undefined) continue
104
+
105
+ if (!known) {
106
+ warnings.push(
107
+ `spend of ${observed} (token ${token}) not bounded: unrecognised protocol, spend cap needs the interpreter predicate (Path B)`
108
+ )
109
+ continue
110
+ }
111
+
112
+ const limit = spendTokens.length === 1 ? limitAmount : undefined
113
+ if (windowSeconds === undefined && !durationFlagged) {
114
+ ambiguities.push({
115
+ code: 'DURATION_UNSPECIFIED',
116
+ question: `Recording shows a ${observed}-${token} spend. What rolling window (seconds) should the spending_limit use?`,
117
+ })
118
+ durationFlagged = true
119
+ }
120
+ if (limit === undefined) {
121
+ ambiguities.push({
122
+ code: 'AMOUNT_BOUND_MISSING',
123
+ question: `Recording shows a ${observed}-${token} spend, but a single spend does not authorise that amount every window. What per-window spending_limit should apply? (observed amount, suggestion only: ${observed})`,
124
+ })
125
+ continue
126
+ }
127
+ if (windowSeconds !== undefined) {
128
+ constraints.push({
129
+ op: 'compare',
130
+ compare: {
131
+ selector: { kind: 'window_spent', token, windowSeconds },
132
+ operator: 'lte',
133
+ value: limit,
134
+ },
135
+ })
136
+ }
137
+ }
138
+ }
139
+
140
+ // Incoming-only / frequency intent: an invocation_count bound is emitted ONLY
141
+ // when the caller supplies the count AND a window - never a fabricated `<= 1`.
142
+ if (spendTokens.length === 0 && topLevel) {
143
+ const invocationLimit = opts.userResponses?.invocationLimit
144
+ if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
145
+ constraints.push({
146
+ op: 'compare',
147
+ compare: {
148
+ selector: { kind: 'invocation_count', windowSeconds },
149
+ operator: 'lte',
150
+ value: String(invocationLimit),
151
+ },
152
+ })
153
+ } else {
154
+ ambiguities.push({
155
+ code: 'FREQUENCY_BOUND_MISSING',
156
+ question: 'Incoming-only flow - what max invocations per window should the policy enforce?',
157
+ })
158
+ warnings.push(
159
+ 'frequency bound needed for the incoming-only flow (interpreter/Path B); no invocation cap inferred'
160
+ )
161
+ }
162
+ }
163
+
164
+ // Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
165
+ // adapter flags Path-B. Unknown protocols emit nothing here.
166
+ if (topLevel && protocol !== null) {
167
+ appendProtocolSpecificConstraints(constraints, warnings, facts, topLevel, protocol)
168
+ }
169
+
170
+ const scope: IRPolicyRule['scope'] = { contract: scopeContract }
171
+ if (topLevel?.fn) scope.method = topLevel.fn
172
+
173
+ const rule: IRPolicyRule = {
174
+ roles: [],
175
+ scope,
176
+ constraints,
177
+ }
178
+ if (opts.userResponses?.validUntilLedger !== undefined) {
179
+ rule.expiry = { validUntilLedger: opts.userResponses.validUntilLedger }
180
+ }
181
+
182
+ const ir: PolicyIR = {
183
+ chain: 'stellar',
184
+ defaultBehavior: 'deny_all',
185
+ rules: [rule],
186
+ }
187
+ return { ir, ambiguities, warnings }
188
+ }
189
+
190
+ /** Add the constraints justified by the recording that the OZ backend cannot
191
+ * express natively (the SEP-41 recipient the transfer moved to). The OZ adapter
192
+ * flags them Path-B. SoroSwap's slippage / oracle / exact-path needs are
193
+ * surfaced as descriptive warnings, NOT as fabricated comparison nodes. */
194
+ function appendProtocolSpecificConstraints(
195
+ constraints: IRCondition[],
196
+ warnings: string[],
197
+ facts: IntentFacts,
198
+ topLevel: ContractInvocation,
199
+ protocol: IdentifiedProtocol
200
+ ): void {
201
+ // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
202
+ // a single-element allowlist; the OZ adapter flags it Path-B.
203
+ if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
204
+ const toArg = topLevel.args[1]
205
+ if (toArg && toArg.type === 'address') {
206
+ constraints.push({
207
+ op: 'in',
208
+ selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
209
+ values: [toArg.value],
210
+ })
211
+ }
212
+ }
213
+
214
+ // SoroSwap: the swap's slippage / oracle-price bound and its exact hop path
215
+ // need the interpreter predicate. We do NOT fabricate a comparison node
216
+ // (oracle price and amountOutMin are different units; a vec path is not a
217
+ // scalar compare). Surface the observed path descriptively instead.
218
+ if (protocol.protocol === 'soroswap') {
219
+ const paths = facts.allowedPaths?.[topLevel.contract]
220
+ const route = paths?.length === 1 ? paths[0] : undefined
221
+ const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : ''
222
+ warnings.push(
223
+ `SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate (Path B)${pathText}`
224
+ )
225
+ }
226
+ }
@@ -0,0 +1,19 @@
1
+ // src/synth/index.ts - re-export the synthesizer front-ends.
2
+
3
+ export {
4
+ type ComposeOptions,
5
+ type ComposeResult,
6
+ composeFromRecording,
7
+ } from './compose-from-recording.ts'
8
+ export { type IntentFacts, lower } from './lower.ts'
9
+ export {
10
+ type DecideScopeOptions,
11
+ decideScope,
12
+ type ScopeDecision,
13
+ scopeToContextRuleType,
14
+ } from './scope.ts'
15
+ export { synthesizeFromMandate } from './synthesize-from-mandate.ts'
16
+ export {
17
+ type SynthesizeFromRecordingOptions,
18
+ synthesizeFromRecording,
19
+ } from './synthesize-from-recording.ts'