@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.
- package/dist/adapters/oz/adapter.d.ts +20 -0
- package/dist/adapters/oz/adapter.js +282 -0
- package/dist/adapters/oz/index.d.ts +1 -0
- package/dist/adapters/oz/index.js +2 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/ir/index.d.ts +1 -0
- package/dist/ir/index.js +2 -0
- package/dist/ir/types.d.ts +97 -0
- package/dist/ir/types.js +11 -0
- package/dist/mandate/index.d.ts +2 -0
- package/dist/mandate/index.js +2 -0
- package/dist/mandate/to-ir.d.ts +3 -0
- package/dist/mandate/to-ir.js +60 -0
- package/dist/mandate/types.d.ts +20 -0
- package/dist/mandate/types.js +8 -0
- package/dist/record/decode.d.ts +76 -0
- package/dist/record/decode.js +372 -0
- package/dist/record/freshness.d.ts +17 -0
- package/dist/record/freshness.js +50 -0
- package/dist/record/index.d.ts +21 -0
- package/dist/record/index.js +163 -0
- package/dist/record/movements.d.ts +20 -0
- package/dist/record/movements.js +187 -0
- package/dist/record/rpc.d.ts +22 -0
- package/dist/record/rpc.js +70 -0
- package/dist/record/validate.d.ts +22 -0
- package/dist/record/validate.js +60 -0
- package/dist/registry/identify.d.ts +11 -0
- package/dist/registry/identify.js +84 -0
- package/dist/registry/index.d.ts +3 -0
- package/dist/registry/index.js +4 -0
- package/dist/registry/known-addresses.d.ts +16 -0
- package/dist/registry/known-addresses.js +49 -0
- package/dist/registry/protocols.d.ts +38 -0
- package/dist/registry/protocols.js +149 -0
- package/dist/seams/index.d.ts +1 -0
- package/dist/seams/index.js +2 -0
- package/dist/seams/types.d.ts +66 -0
- package/dist/seams/types.js +11 -0
- package/dist/synth/compose-from-recording.d.ts +36 -0
- package/dist/synth/compose-from-recording.js +162 -0
- package/dist/synth/index.d.ts +5 -0
- package/dist/synth/index.js +6 -0
- package/dist/synth/lower.d.ts +23 -0
- package/dist/synth/lower.js +116 -0
- package/dist/synth/scope.d.ts +26 -0
- package/dist/synth/scope.js +77 -0
- package/dist/synth/synthesize-from-mandate.d.ts +5 -0
- package/dist/synth/synthesize-from-mandate.js +34 -0
- package/dist/synth/synthesize-from-recording.d.ts +17 -0
- package/dist/synth/synthesize-from-recording.js +178 -0
- package/dist/types.d.ts +249 -0
- package/dist/types.js +35 -0
- package/package.json +37 -0
- package/src/adapters/oz/adapter.ts +363 -0
- package/src/adapters/oz/index.ts +9 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +9 -0
- package/src/ir/index.ts +13 -0
- package/src/ir/types.ts +94 -0
- package/src/mandate/index.ts +4 -0
- package/src/mandate/to-ir.ts +71 -0
- package/src/mandate/types.ts +21 -0
- package/src/record/decode.ts +500 -0
- package/src/record/freshness.ts +63 -0
- package/src/record/index.ts +224 -0
- package/src/record/movements.ts +188 -0
- package/src/record/rpc.ts +88 -0
- package/src/record/validate.ts +75 -0
- package/src/registry/identify.ts +99 -0
- package/src/registry/index.ts +24 -0
- package/src/registry/known-addresses.ts +71 -0
- package/src/registry/protocols.ts +176 -0
- package/src/seams/index.ts +11 -0
- package/src/seams/types.ts +81 -0
- package/src/synth/compose-from-recording.ts +226 -0
- package/src/synth/index.ts +19 -0
- package/src/synth/lower.ts +144 -0
- package/src/synth/scope.ts +108 -0
- package/src/synth/synthesize-from-mandate.ts +46 -0
- package/src/synth/synthesize-from-recording.ts +226 -0
- package/src/types.ts +218 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// src/synth/lower.ts - lower a RecordedTransaction to IntentFacts.
|
|
2
|
+
//
|
|
3
|
+
// Reads the top-level invocations only (the single authorized call that
|
|
4
|
+
// `Policy::enforce` receives; v1 grammar does not walk sub-invocations) and
|
|
5
|
+
// the recorded token movements. Produces the deterministic facts the synth
|
|
6
|
+
// reasons over: distinct call targets, functions per target, cumulative spend
|
|
7
|
+
// per token (outgoing movements from the recorded source account only),
|
|
8
|
+
// authorising signers, an optional shared-router marker when multiple top-level
|
|
9
|
+
// invocations hit the same contract, and the per-contract hop-path lists for
|
|
10
|
+
// router calls (SoroSwap `path`).
|
|
11
|
+
|
|
12
|
+
import type { ContractInvocation, RecordedTransaction, ScVal, TokenMovement } from '../types.ts'
|
|
13
|
+
|
|
14
|
+
/** Facts the recording-path synth reasons over. */
|
|
15
|
+
export interface IntentFacts {
|
|
16
|
+
callTargets: string[]
|
|
17
|
+
functionsByContract: Record<string, string[]>
|
|
18
|
+
/** Aggregate outgoing spend per token (i128-safe decimal string). A single
|
|
19
|
+
* token entry is the Path-A prerequisite for `spending_limit`. Movements
|
|
20
|
+
* with from != sourceAccount are NOT counted as "spend" by this synth
|
|
21
|
+
* (incoming yield, refund, etc.). */
|
|
22
|
+
spendByToken: Record<string, string>
|
|
23
|
+
signers: string[]
|
|
24
|
+
/** Set when more than one top-level invocation shares a target contract
|
|
25
|
+
* (a router); decideScope uses this to scope by the router instead of
|
|
26
|
+
* returning SCOPE_UNRESOLVED. */
|
|
27
|
+
sharedRouter?: string
|
|
28
|
+
/** Router hop paths, keyed by target contract. SoroSwap exposes its `path`
|
|
29
|
+
* arg directly on the top-level call; this is the input the compose step
|
|
30
|
+
* passes to a per-arg `in` condition (flagged Path-B by the OZ adapter). */
|
|
31
|
+
allowedPaths?: Record<string, string[][]>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lower a recorded transaction to the canonical IntentFacts. Pure (no
|
|
35
|
+
* randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
|
|
36
|
+
export function lower(tx: RecordedTransaction): IntentFacts {
|
|
37
|
+
const invocations = tx.invocations
|
|
38
|
+
const callTargets = uniqueOrdered(invocations.map((i) => i.contract))
|
|
39
|
+
const functionsByContract = groupFunctionsByContract(invocations)
|
|
40
|
+
const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, tx.sourceAccount)
|
|
41
|
+
const allowedPaths = extractPathsByContract(invocations)
|
|
42
|
+
const sharedRouter = inferSharedRouter(invocations)
|
|
43
|
+
|
|
44
|
+
const facts: IntentFacts = {
|
|
45
|
+
callTargets,
|
|
46
|
+
functionsByContract,
|
|
47
|
+
spendByToken,
|
|
48
|
+
signers: [...tx.signers],
|
|
49
|
+
}
|
|
50
|
+
if (sharedRouter) facts.sharedRouter = sharedRouter
|
|
51
|
+
if (Object.keys(allowedPaths).length > 0) facts.allowedPaths = allowedPaths
|
|
52
|
+
return facts
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Preserve first-seen ordering, drop duplicates. */
|
|
56
|
+
function uniqueOrdered(items: string[]): string[] {
|
|
57
|
+
const seen = new Set<string>()
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
if (seen.has(item)) continue
|
|
61
|
+
seen.add(item)
|
|
62
|
+
out.push(item)
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Group the function names invoked per top-level contract. Order preserved
|
|
68
|
+
* per contract in first-seen order. */
|
|
69
|
+
function groupFunctionsByContract(invocations: ContractInvocation[]): Record<string, string[]> {
|
|
70
|
+
const out: Record<string, string[]> = {}
|
|
71
|
+
for (const inv of invocations) {
|
|
72
|
+
const existing = out[inv.contract] ?? []
|
|
73
|
+
if (!existing.includes(inv.fn)) existing.push(inv.fn)
|
|
74
|
+
out[inv.contract] = existing
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Sum outgoing TokenMovement amounts per token, where `from === source`.
|
|
80
|
+
* BigInt throughout; never lossy. Movements whose `from` does not match the
|
|
81
|
+
* recorded source account are NOT counted (incoming yield, refund, etc.). */
|
|
82
|
+
function aggregateOutgoingSpend(
|
|
83
|
+
movements: TokenMovement[],
|
|
84
|
+
sourceAccount: string
|
|
85
|
+
): Record<string, string> {
|
|
86
|
+
const totals = new Map<string, bigint>()
|
|
87
|
+
for (const m of movements) {
|
|
88
|
+
if (m.from !== sourceAccount) continue
|
|
89
|
+
const current = totals.get(m.token) ?? 0n
|
|
90
|
+
totals.set(m.token, current + BigInt(m.amount))
|
|
91
|
+
}
|
|
92
|
+
const out: Record<string, string> = {}
|
|
93
|
+
for (const [token, total] of totals) {
|
|
94
|
+
out[token] = total.toString()
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Walk the top-level invocations looking for a `vec` arg whose elements are
|
|
100
|
+
* addresses (the SoroSwap `path` shape). Only the FIRST arg in each
|
|
101
|
+
* invocation is examined in v1 - the recorder emits the top-level args
|
|
102
|
+
* in declaration order and the only contract-call convention we bind by is
|
|
103
|
+
* the router's first vec-arg path. */
|
|
104
|
+
function extractPathsByContract(invocations: ContractInvocation[]): Record<string, string[][]> {
|
|
105
|
+
const out: Record<string, string[][]> = {}
|
|
106
|
+
for (const inv of invocations) {
|
|
107
|
+
const path = findAddressVec(inv.args)
|
|
108
|
+
if (path) {
|
|
109
|
+
const existing = out[inv.contract] ?? []
|
|
110
|
+
existing.push(path)
|
|
111
|
+
out[inv.contract] = existing
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findAddressVec(args: ScVal[]): string[] | null {
|
|
118
|
+
for (const arg of args) {
|
|
119
|
+
if (arg.type === 'vec') {
|
|
120
|
+
const addresses = arg.value.filter(
|
|
121
|
+
(v): v is Extract<ScVal, { type: 'address' }> => v.type === 'address'
|
|
122
|
+
)
|
|
123
|
+
if (addresses.length === arg.value.length && addresses.length > 0) {
|
|
124
|
+
return addresses.map((a) => a.value)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** When multiple top-level invocations share the same target contract, that
|
|
132
|
+
* contract is the shared router. Single-call transactions return undefined
|
|
133
|
+
* (the default flow doesn't need the marker). */
|
|
134
|
+
function inferSharedRouter(invocations: ContractInvocation[]): string | undefined {
|
|
135
|
+
if (invocations.length < 2) return undefined
|
|
136
|
+
const counts = new Map<string, number>()
|
|
137
|
+
for (const inv of invocations) {
|
|
138
|
+
counts.set(inv.contract, (counts.get(inv.contract) ?? 0) + 1)
|
|
139
|
+
}
|
|
140
|
+
for (const [contract, count] of counts) {
|
|
141
|
+
if (count >= 2) return contract
|
|
142
|
+
}
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/synth/scope.ts - decide the OZ context-rule scope from IntentFacts.
|
|
2
|
+
//
|
|
3
|
+
// Pure decision function. Three branches:
|
|
4
|
+
// - single call target -> CallContract(target)
|
|
5
|
+
// - multiple targets w/ sharedRouter -> CallContract(sharedRouter)
|
|
6
|
+
// - multiple unrelated targets -> SCOPE_UNRESOLVED ToolError
|
|
7
|
+
//
|
|
8
|
+
// `decideScope` also surfaces the `DURATION_UNSPECIFIED` ambiguity when the
|
|
9
|
+
// caller has NOT supplied a validUntilLedger - the synth never fabricates a
|
|
10
|
+
// window. The orchestrator is responsible for assembling any
|
|
11
|
+
// `AmbiguityPrompt[]` from this and other decision points into the
|
|
12
|
+
// `ProposedPolicy.ambiguities` field.
|
|
13
|
+
|
|
14
|
+
import type { ToolError, ToolResponse } from '../errors.ts'
|
|
15
|
+
import type { AmbiguityPrompt, ContextRuleDraft, Network } from '../types.ts'
|
|
16
|
+
import type { IntentFacts } from './lower.ts'
|
|
17
|
+
|
|
18
|
+
export type ScopeDecision =
|
|
19
|
+
| { kind: 'call_contract'; contract: string; ambiguities: AmbiguityPrompt[] }
|
|
20
|
+
| { kind: 'default'; ambiguities: AmbiguityPrompt[] }
|
|
21
|
+
|
|
22
|
+
/** Options for the scope decision. `validUntilLedger` may be supplied via the
|
|
23
|
+
* synth opts; when absent the DURATION_UNSPECIFIED ambiguity is surfaced so
|
|
24
|
+
* the LLM can ask the user rather than the synth fabricating a window. */
|
|
25
|
+
export interface DecideScopeOptions {
|
|
26
|
+
network: Network
|
|
27
|
+
validUntilLedger?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Decide the OZ context-rule scope. Returns `ScopeDecision` on success or a
|
|
31
|
+
* `ToolError` with code `SCOPE_UNRESOLVED` when the recorded tx spans
|
|
32
|
+
* multiple unrelated call targets. */
|
|
33
|
+
export function decideScope(
|
|
34
|
+
facts: IntentFacts,
|
|
35
|
+
opts: DecideScopeOptions
|
|
36
|
+
): ToolResponse<ScopeDecision> {
|
|
37
|
+
const ambiguities: AmbiguityPrompt[] = []
|
|
38
|
+
if (opts.validUntilLedger === undefined) {
|
|
39
|
+
ambiguities.push({
|
|
40
|
+
code: 'DURATION_UNSPECIFIED',
|
|
41
|
+
question:
|
|
42
|
+
'No expiry supplied for this policy. What `validUntilLedger` should the policy carry?',
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Single target -> scope directly.
|
|
47
|
+
if (facts.callTargets.length === 1) {
|
|
48
|
+
const contract = facts.callTargets[0]
|
|
49
|
+
if (contract === undefined) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: scopeUnresolvedError(facts, 'no contract address resolvable from the recording'),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
data: { kind: 'call_contract', contract, ambiguities },
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Multiple targets with a shared router -> scope by the router.
|
|
62
|
+
if (facts.sharedRouter) {
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
data: {
|
|
66
|
+
kind: 'call_contract',
|
|
67
|
+
contract: facts.sharedRouter,
|
|
68
|
+
ambiguities,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Multiple unrelated targets -> fail closed.
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: scopeUnresolvedError(
|
|
77
|
+
facts,
|
|
78
|
+
`multiple unrelated call targets: ${facts.callTargets.join(', ')}`
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Helper to project a `ScopeDecision` into the OZ `ContextRuleDraft`
|
|
84
|
+
* contextRuleType shape consumed by `composeFromRecording` and the OZ
|
|
85
|
+
* adapter. */
|
|
86
|
+
export function scopeToContextRuleType(scope: ScopeDecision): ContextRuleDraft['contextRuleType'] {
|
|
87
|
+
return scope.kind === 'call_contract'
|
|
88
|
+
? { kind: 'call_contract', contract: scope.contract }
|
|
89
|
+
: { kind: 'default' }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scopeUnresolvedError(facts: IntentFacts, message: string): ToolError {
|
|
93
|
+
return {
|
|
94
|
+
code: 'SCOPE_UNRESOLVED',
|
|
95
|
+
message,
|
|
96
|
+
severity: 'error',
|
|
97
|
+
retryable: false,
|
|
98
|
+
details: { callTargets: facts.callTargets, sharedRouter: facts.sharedRouter ?? null },
|
|
99
|
+
remediation: {
|
|
100
|
+
userQuestion: {
|
|
101
|
+
code: 'MULTIPLE_UNRELATED_TARGETS',
|
|
102
|
+
question: `Recording spans ${facts.callTargets.length} call targets (${facts.callTargets.join(
|
|
103
|
+
', '
|
|
104
|
+
)}). Which contract (or shared router) should this policy scope to?`,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/synth/synthesize-from-mandate.ts - the deterministic Mandate front-end.
|
|
2
|
+
//
|
|
3
|
+
// synthesizeFromMandate is the clean end-to-end demo path: a declarative
|
|
4
|
+
// MandateSpec is lowered deterministically to a PolicyIR and compiled by the OZ
|
|
5
|
+
// adapter to a ProposedPolicy. No decoding, no inference, so parseConfidence is
|
|
6
|
+
// the full/not-applicable value. Constructs the OZ built-in primitives cannot
|
|
7
|
+
// express (compile's `uncovered`) are surfaced in `ProposedPolicy.warnings` as
|
|
8
|
+
// Path-B markers rather than failing the call - the covered primitives still
|
|
9
|
+
// install; the uncovered ones are reported.
|
|
10
|
+
|
|
11
|
+
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
|
|
12
|
+
import { createOzAdapter } from '../adapters/oz/adapter.ts'
|
|
13
|
+
import type { ToolResponse } from '../errors.ts'
|
|
14
|
+
import { mandateToPolicyIR } from '../mandate/to-ir.ts'
|
|
15
|
+
import type { MandateSpec } from '../mandate/types.ts'
|
|
16
|
+
import type { ProposedPolicy } from '../types.ts'
|
|
17
|
+
|
|
18
|
+
const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): '
|
|
19
|
+
|
|
20
|
+
export function synthesizeFromMandate(
|
|
21
|
+
spec: MandateSpec,
|
|
22
|
+
ozConfig: OzAdapterConfig
|
|
23
|
+
): ToolResponse<ProposedPolicy> {
|
|
24
|
+
const ir = mandateToPolicyIR(spec)
|
|
25
|
+
const adapter = createOzAdapter(ozConfig)
|
|
26
|
+
const result = adapter.compile(ir)
|
|
27
|
+
|
|
28
|
+
if (!result.proposed) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: {
|
|
32
|
+
code: 'SYNTHESIS_ERROR',
|
|
33
|
+
message: `mandate lowered to no installable OZ policy: ${result.uncovered.join('; ')}`,
|
|
34
|
+
severity: 'error',
|
|
35
|
+
retryable: false,
|
|
36
|
+
details: { uncovered: result.uncovered },
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const proposed: ProposedPolicy = {
|
|
42
|
+
...result.proposed,
|
|
43
|
+
warnings: result.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, data: proposed }
|
|
46
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/synth/synthesize-from-recording.ts - recording-path orchestrator.
|
|
2
|
+
//
|
|
3
|
+
// `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
|
|
4
|
+
// bounded policy from a `RecordedTransaction` via the same `PolicyIR` +
|
|
5
|
+
// `createOzAdapter` pair used by the deterministic Mandate path. The flow:
|
|
6
|
+
//
|
|
7
|
+
// 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
|
|
8
|
+
// the caller may relax via `confidenceOverride.threshold`).
|
|
9
|
+
// 2. `lower(tx)` -> IntentFacts.
|
|
10
|
+
// 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
|
|
11
|
+
// 4. `composeFromRecording(facts, scope, opts)` -> PolicyIR + ambiguities.
|
|
12
|
+
// 5. `ozAdapter.compile(ir)` -> CompileResult.
|
|
13
|
+
// 6. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
|
|
14
|
+
// (the OZ `uncovered` Path-B markers, prefixed), and ambiguities
|
|
15
|
+
// (DURATION_UNSPECIFIED etc.).
|
|
16
|
+
//
|
|
17
|
+
// Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
|
|
18
|
+
// No randomness, no clock, no globals.
|
|
19
|
+
|
|
20
|
+
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
|
|
21
|
+
import { createOzAdapter } from '../adapters/oz/adapter.ts'
|
|
22
|
+
import type { ToolError, ToolResponse } from '../errors.ts'
|
|
23
|
+
import {
|
|
24
|
+
type Network,
|
|
25
|
+
type ProposedPolicy,
|
|
26
|
+
type RecordedTransaction,
|
|
27
|
+
SOROBAN_LIMITS,
|
|
28
|
+
} from '../types.ts'
|
|
29
|
+
import {
|
|
30
|
+
type ComposeOptions,
|
|
31
|
+
type ComposeUserResponses,
|
|
32
|
+
composeFromRecording,
|
|
33
|
+
} from './compose-from-recording.ts'
|
|
34
|
+
import { lower } from './lower.ts'
|
|
35
|
+
import { decideScope, type ScopeDecision } from './scope.ts'
|
|
36
|
+
|
|
37
|
+
const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): '
|
|
38
|
+
|
|
39
|
+
/** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
|
|
40
|
+
* answers to the ambiguity prompts (windowSeconds, validUntilLedger,
|
|
41
|
+
* limitAmount, invocationLimit). `confidenceOverride` relaxes the recorder's
|
|
42
|
+
* gate. */
|
|
43
|
+
export interface SynthesizeFromRecordingOptions {
|
|
44
|
+
network: Network
|
|
45
|
+
userResponses?: ComposeUserResponses
|
|
46
|
+
confidenceOverride?: { threshold: number }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
50
|
+
export function synthesizeFromRecording(
|
|
51
|
+
tx: RecordedTransaction,
|
|
52
|
+
opts: SynthesizeFromRecordingOptions,
|
|
53
|
+
ozConfig: OzAdapterConfig
|
|
54
|
+
): ToolResponse<ProposedPolicy> {
|
|
55
|
+
// 0. validate inputs (fail closed - never synthesize from garbage).
|
|
56
|
+
const invalid = validateOptions(opts)
|
|
57
|
+
if (invalid) return { ok: false, error: invalid }
|
|
58
|
+
|
|
59
|
+
// 1. parseConfidence gate.
|
|
60
|
+
const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed
|
|
61
|
+
if (tx.parseConfidence.overall < threshold) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: {
|
|
65
|
+
code: 'RECORDING_VALIDATION_FAILED',
|
|
66
|
+
message: `parseConfidence ${tx.parseConfidence.overall} < threshold ${threshold}`,
|
|
67
|
+
severity: 'error',
|
|
68
|
+
retryable: false,
|
|
69
|
+
details: tx.parseConfidence,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Bound the recording size fail-closed (defense-in-depth for direct callers;
|
|
75
|
+
// the MCP schema caps this too).
|
|
76
|
+
if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: {
|
|
80
|
+
code: 'RECORDING_VALIDATION_FAILED',
|
|
81
|
+
message: `recorded transaction has ${tx.invocations.length} invocations, exceeding the cap of ${SOROBAN_LIMITS.maxInvocations}`,
|
|
82
|
+
severity: 'error',
|
|
83
|
+
retryable: false,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. lower.
|
|
89
|
+
const facts = lower(tx)
|
|
90
|
+
|
|
91
|
+
// 3. decideScope.
|
|
92
|
+
const scopeRes = decideScope(facts, {
|
|
93
|
+
network: opts.network,
|
|
94
|
+
...(opts.userResponses?.validUntilLedger !== undefined
|
|
95
|
+
? { validUntilLedger: opts.userResponses.validUntilLedger }
|
|
96
|
+
: {}),
|
|
97
|
+
})
|
|
98
|
+
if (!scopeRes.ok) return scopeRes
|
|
99
|
+
const scope: ScopeDecision = scopeRes.data
|
|
100
|
+
if (scope.kind !== 'call_contract') {
|
|
101
|
+
// A default scope on the recording-path is not currently a Path-A flow;
|
|
102
|
+
// surface SYNTHESIS_ERROR so the orchestrator never silently emits an
|
|
103
|
+
// over-broad default-scoped policy.
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: {
|
|
107
|
+
code: 'SYNTHESIS_ERROR',
|
|
108
|
+
message: 'recording path requires a scoped contract; no default-scoped policies emitted',
|
|
109
|
+
severity: 'error',
|
|
110
|
+
retryable: false,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 4. compose.
|
|
116
|
+
const topLevel = tx.invocations[0] ?? null
|
|
117
|
+
const composeOpts: ComposeOptions = {
|
|
118
|
+
network: opts.network,
|
|
119
|
+
...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
|
|
120
|
+
}
|
|
121
|
+
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
|
|
122
|
+
|
|
123
|
+
// 5. OZ compile.
|
|
124
|
+
const adapter = createOzAdapter(ozConfig)
|
|
125
|
+
const compileRes = adapter.compile(composed.ir)
|
|
126
|
+
|
|
127
|
+
if (!compileRes.proposed) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: {
|
|
131
|
+
code: 'SYNTHESIS_ERROR',
|
|
132
|
+
message: `recording lowered to no installable OZ policy: ${compileRes.uncovered.join('; ')}`,
|
|
133
|
+
severity: 'error',
|
|
134
|
+
retryable: false,
|
|
135
|
+
details: { uncovered: compileRes.uncovered },
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 6. assemble ProposedPolicy.
|
|
141
|
+
const proposed: ProposedPolicy = {
|
|
142
|
+
...compileRes.proposed,
|
|
143
|
+
parseConfidence: { ...tx.parseConfidence },
|
|
144
|
+
warnings: [
|
|
145
|
+
...compileRes.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
|
|
146
|
+
...composed.warnings.map((w) => `${PATH_B_PREFIX}${w}`),
|
|
147
|
+
],
|
|
148
|
+
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, data: proposed }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reject non-sane inputs before any policy is synthesized. windowSeconds /
|
|
154
|
+
* validUntilLedger / invocationLimit must be positive integers; limitAmount a
|
|
155
|
+
* positive i128 decimal string; network mainnet|testnet. */
|
|
156
|
+
function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null {
|
|
157
|
+
if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
|
|
158
|
+
return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`)
|
|
159
|
+
}
|
|
160
|
+
// A confidenceOverride outside [0, 1] would disable the recorder gate (a
|
|
161
|
+
// negative threshold can never be exceeded), so reject it fail-closed.
|
|
162
|
+
const co = opts.confidenceOverride?.threshold
|
|
163
|
+
if (co !== undefined && (!Number.isFinite(co) || co < 0 || co > 1)) {
|
|
164
|
+
return synthesisError(
|
|
165
|
+
`confidenceOverride.threshold must be a finite number within [0, 1], got: ${co}`
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const ur = opts.userResponses
|
|
169
|
+
if (ur) {
|
|
170
|
+
if (ur.windowSeconds !== undefined && !isPositiveInt(ur.windowSeconds)) {
|
|
171
|
+
return synthesisError(`windowSeconds must be a positive integer, got: ${ur.windowSeconds}`)
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
ur.validUntilLedger !== undefined &&
|
|
175
|
+
(!isPositiveInt(ur.validUntilLedger) || ur.validUntilLedger > SOROBAN_LIMITS.u32Max)
|
|
176
|
+
) {
|
|
177
|
+
return synthesisError(
|
|
178
|
+
`validUntilLedger must be a positive u32 ledger sequence (<= ${SOROBAN_LIMITS.u32Max}), got: ${ur.validUntilLedger}`
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
if (ur.invocationLimit !== undefined && !isPositiveInt(ur.invocationLimit)) {
|
|
182
|
+
return synthesisError(
|
|
183
|
+
`invocationLimit must be a positive integer, got: ${ur.invocationLimit}`
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
|
|
187
|
+
return synthesisError(
|
|
188
|
+
`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isPositiveInt(n: number): boolean {
|
|
196
|
+
return Number.isInteger(n) && n > 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
|
|
200
|
+
function isPositiveI128(s: string): boolean {
|
|
201
|
+
if (!/^[0-9]+$/.test(s)) return false
|
|
202
|
+
try {
|
|
203
|
+
return BigInt(s) > 0n
|
|
204
|
+
} catch {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function synthesisError(message: string): ToolError {
|
|
210
|
+
return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function mergeAmbiguities(
|
|
214
|
+
...lists: ProposedPolicy['ambiguities'][]
|
|
215
|
+
): ProposedPolicy['ambiguities'] {
|
|
216
|
+
const out: ProposedPolicy['ambiguities'] = []
|
|
217
|
+
const seen = new Set<string>()
|
|
218
|
+
for (const list of lists) {
|
|
219
|
+
for (const a of list) {
|
|
220
|
+
if (seen.has(a.code)) continue
|
|
221
|
+
seen.add(a.code)
|
|
222
|
+
out.push(a)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out
|
|
226
|
+
}
|