@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,116 @@
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
+ /** Lower a recorded transaction to the canonical IntentFacts. Pure (no
12
+ * randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
13
+ export function lower(tx) {
14
+ const invocations = tx.invocations;
15
+ const callTargets = uniqueOrdered(invocations.map((i) => i.contract));
16
+ const functionsByContract = groupFunctionsByContract(invocations);
17
+ const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, tx.sourceAccount);
18
+ const allowedPaths = extractPathsByContract(invocations);
19
+ const sharedRouter = inferSharedRouter(invocations);
20
+ const facts = {
21
+ callTargets,
22
+ functionsByContract,
23
+ spendByToken,
24
+ signers: [...tx.signers],
25
+ };
26
+ if (sharedRouter)
27
+ facts.sharedRouter = sharedRouter;
28
+ if (Object.keys(allowedPaths).length > 0)
29
+ facts.allowedPaths = allowedPaths;
30
+ return facts;
31
+ }
32
+ /** Preserve first-seen ordering, drop duplicates. */
33
+ function uniqueOrdered(items) {
34
+ const seen = new Set();
35
+ const out = [];
36
+ for (const item of items) {
37
+ if (seen.has(item))
38
+ continue;
39
+ seen.add(item);
40
+ out.push(item);
41
+ }
42
+ return out;
43
+ }
44
+ /** Group the function names invoked per top-level contract. Order preserved
45
+ * per contract in first-seen order. */
46
+ function groupFunctionsByContract(invocations) {
47
+ const out = {};
48
+ for (const inv of invocations) {
49
+ const existing = out[inv.contract] ?? [];
50
+ if (!existing.includes(inv.fn))
51
+ existing.push(inv.fn);
52
+ out[inv.contract] = existing;
53
+ }
54
+ return out;
55
+ }
56
+ /** Sum outgoing TokenMovement amounts per token, where `from === source`.
57
+ * BigInt throughout; never lossy. Movements whose `from` does not match the
58
+ * recorded source account are NOT counted (incoming yield, refund, etc.). */
59
+ function aggregateOutgoingSpend(movements, sourceAccount) {
60
+ const totals = new Map();
61
+ for (const m of movements) {
62
+ if (m.from !== sourceAccount)
63
+ continue;
64
+ const current = totals.get(m.token) ?? 0n;
65
+ totals.set(m.token, current + BigInt(m.amount));
66
+ }
67
+ const out = {};
68
+ for (const [token, total] of totals) {
69
+ out[token] = total.toString();
70
+ }
71
+ return out;
72
+ }
73
+ /** Walk the top-level invocations looking for a `vec` arg whose elements are
74
+ * addresses (the SoroSwap `path` shape). Only the FIRST arg in each
75
+ * invocation is examined in v1 - the recorder emits the top-level args
76
+ * in declaration order and the only contract-call convention we bind by is
77
+ * the router's first vec-arg path. */
78
+ function extractPathsByContract(invocations) {
79
+ const out = {};
80
+ for (const inv of invocations) {
81
+ const path = findAddressVec(inv.args);
82
+ if (path) {
83
+ const existing = out[inv.contract] ?? [];
84
+ existing.push(path);
85
+ out[inv.contract] = existing;
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+ function findAddressVec(args) {
91
+ for (const arg of args) {
92
+ if (arg.type === 'vec') {
93
+ const addresses = arg.value.filter((v) => v.type === 'address');
94
+ if (addresses.length === arg.value.length && addresses.length > 0) {
95
+ return addresses.map((a) => a.value);
96
+ }
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ /** When multiple top-level invocations share the same target contract, that
102
+ * contract is the shared router. Single-call transactions return undefined
103
+ * (the default flow doesn't need the marker). */
104
+ function inferSharedRouter(invocations) {
105
+ if (invocations.length < 2)
106
+ return undefined;
107
+ const counts = new Map();
108
+ for (const inv of invocations) {
109
+ counts.set(inv.contract, (counts.get(inv.contract) ?? 0) + 1);
110
+ }
111
+ for (const [contract, count] of counts) {
112
+ if (count >= 2)
113
+ return contract;
114
+ }
115
+ return undefined;
116
+ }
@@ -0,0 +1,26 @@
1
+ import type { ToolResponse } from '../errors.ts';
2
+ import type { AmbiguityPrompt, ContextRuleDraft, Network } from '../types.ts';
3
+ import type { IntentFacts } from './lower.ts';
4
+ export type ScopeDecision = {
5
+ kind: 'call_contract';
6
+ contract: string;
7
+ ambiguities: AmbiguityPrompt[];
8
+ } | {
9
+ kind: 'default';
10
+ ambiguities: AmbiguityPrompt[];
11
+ };
12
+ /** Options for the scope decision. `validUntilLedger` may be supplied via the
13
+ * synth opts; when absent the DURATION_UNSPECIFIED ambiguity is surfaced so
14
+ * the LLM can ask the user rather than the synth fabricating a window. */
15
+ export interface DecideScopeOptions {
16
+ network: Network;
17
+ validUntilLedger?: number;
18
+ }
19
+ /** Decide the OZ context-rule scope. Returns `ScopeDecision` on success or a
20
+ * `ToolError` with code `SCOPE_UNRESOLVED` when the recorded tx spans
21
+ * multiple unrelated call targets. */
22
+ export declare function decideScope(facts: IntentFacts, opts: DecideScopeOptions): ToolResponse<ScopeDecision>;
23
+ /** Helper to project a `ScopeDecision` into the OZ `ContextRuleDraft`
24
+ * contextRuleType shape consumed by `composeFromRecording` and the OZ
25
+ * adapter. */
26
+ export declare function scopeToContextRuleType(scope: ScopeDecision): ContextRuleDraft['contextRuleType'];
@@ -0,0 +1,77 @@
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
+ /** Decide the OZ context-rule scope. Returns `ScopeDecision` on success or a
14
+ * `ToolError` with code `SCOPE_UNRESOLVED` when the recorded tx spans
15
+ * multiple unrelated call targets. */
16
+ export function decideScope(facts, opts) {
17
+ const ambiguities = [];
18
+ if (opts.validUntilLedger === undefined) {
19
+ ambiguities.push({
20
+ code: 'DURATION_UNSPECIFIED',
21
+ question: 'No expiry supplied for this policy. What `validUntilLedger` should the policy carry?',
22
+ });
23
+ }
24
+ // Single target -> scope directly.
25
+ if (facts.callTargets.length === 1) {
26
+ const contract = facts.callTargets[0];
27
+ if (contract === undefined) {
28
+ return {
29
+ ok: false,
30
+ error: scopeUnresolvedError(facts, 'no contract address resolvable from the recording'),
31
+ };
32
+ }
33
+ return {
34
+ ok: true,
35
+ data: { kind: 'call_contract', contract, ambiguities },
36
+ };
37
+ }
38
+ // Multiple targets with a shared router -> scope by the router.
39
+ if (facts.sharedRouter) {
40
+ return {
41
+ ok: true,
42
+ data: {
43
+ kind: 'call_contract',
44
+ contract: facts.sharedRouter,
45
+ ambiguities,
46
+ },
47
+ };
48
+ }
49
+ // Multiple unrelated targets -> fail closed.
50
+ return {
51
+ ok: false,
52
+ error: scopeUnresolvedError(facts, `multiple unrelated call targets: ${facts.callTargets.join(', ')}`),
53
+ };
54
+ }
55
+ /** Helper to project a `ScopeDecision` into the OZ `ContextRuleDraft`
56
+ * contextRuleType shape consumed by `composeFromRecording` and the OZ
57
+ * adapter. */
58
+ export function scopeToContextRuleType(scope) {
59
+ return scope.kind === 'call_contract'
60
+ ? { kind: 'call_contract', contract: scope.contract }
61
+ : { kind: 'default' };
62
+ }
63
+ function scopeUnresolvedError(facts, message) {
64
+ return {
65
+ code: 'SCOPE_UNRESOLVED',
66
+ message,
67
+ severity: 'error',
68
+ retryable: false,
69
+ details: { callTargets: facts.callTargets, sharedRouter: facts.sharedRouter ?? null },
70
+ remediation: {
71
+ userQuestion: {
72
+ code: 'MULTIPLE_UNRELATED_TARGETS',
73
+ question: `Recording spans ${facts.callTargets.length} call targets (${facts.callTargets.join(', ')}). Which contract (or shared router) should this policy scope to?`,
74
+ },
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,5 @@
1
+ import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
2
+ import type { ToolResponse } from '../errors.ts';
3
+ import type { MandateSpec } from '../mandate/types.ts';
4
+ import type { ProposedPolicy } from '../types.ts';
5
+ export declare function synthesizeFromMandate(spec: MandateSpec, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy>;
@@ -0,0 +1,34 @@
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
+ import { createOzAdapter } from "../adapters/oz/adapter.js";
11
+ import { mandateToPolicyIR } from "../mandate/to-ir.js";
12
+ const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): ';
13
+ export function synthesizeFromMandate(spec, ozConfig) {
14
+ const ir = mandateToPolicyIR(spec);
15
+ const adapter = createOzAdapter(ozConfig);
16
+ const result = adapter.compile(ir);
17
+ if (!result.proposed) {
18
+ return {
19
+ ok: false,
20
+ error: {
21
+ code: 'SYNTHESIS_ERROR',
22
+ message: `mandate lowered to no installable OZ policy: ${result.uncovered.join('; ')}`,
23
+ severity: 'error',
24
+ retryable: false,
25
+ details: { uncovered: result.uncovered },
26
+ },
27
+ };
28
+ }
29
+ const proposed = {
30
+ ...result.proposed,
31
+ warnings: result.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
32
+ };
33
+ return { ok: true, data: proposed };
34
+ }
@@ -0,0 +1,17 @@
1
+ import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
2
+ import type { ToolResponse } from '../errors.ts';
3
+ import { type Network, type ProposedPolicy, type RecordedTransaction } from '../types.ts';
4
+ import { type ComposeUserResponses } from './compose-from-recording.ts';
5
+ /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
6
+ * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
7
+ * limitAmount, invocationLimit). `confidenceOverride` relaxes the recorder's
8
+ * gate. */
9
+ export interface SynthesizeFromRecordingOptions {
10
+ network: Network;
11
+ userResponses?: ComposeUserResponses;
12
+ confidenceOverride?: {
13
+ threshold: number;
14
+ };
15
+ }
16
+ /** Synthesize a ProposedPolicy from a recorded transaction. */
17
+ export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy>;
@@ -0,0 +1,178 @@
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
+ import { createOzAdapter } from "../adapters/oz/adapter.js";
20
+ import { SOROBAN_LIMITS, } from "../types.js";
21
+ import { composeFromRecording, } from "./compose-from-recording.js";
22
+ import { lower } from "./lower.js";
23
+ import { decideScope } from "./scope.js";
24
+ const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): ';
25
+ /** Synthesize a ProposedPolicy from a recorded transaction. */
26
+ export function synthesizeFromRecording(tx, opts, ozConfig) {
27
+ // 0. validate inputs (fail closed - never synthesize from garbage).
28
+ const invalid = validateOptions(opts);
29
+ if (invalid)
30
+ return { ok: false, error: invalid };
31
+ // 1. parseConfidence gate.
32
+ const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed;
33
+ if (tx.parseConfidence.overall < threshold) {
34
+ return {
35
+ ok: false,
36
+ error: {
37
+ code: 'RECORDING_VALIDATION_FAILED',
38
+ message: `parseConfidence ${tx.parseConfidence.overall} < threshold ${threshold}`,
39
+ severity: 'error',
40
+ retryable: false,
41
+ details: tx.parseConfidence,
42
+ },
43
+ };
44
+ }
45
+ // Bound the recording size fail-closed (defense-in-depth for direct callers;
46
+ // the MCP schema caps this too).
47
+ if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
48
+ return {
49
+ ok: false,
50
+ error: {
51
+ code: 'RECORDING_VALIDATION_FAILED',
52
+ message: `recorded transaction has ${tx.invocations.length} invocations, exceeding the cap of ${SOROBAN_LIMITS.maxInvocations}`,
53
+ severity: 'error',
54
+ retryable: false,
55
+ },
56
+ };
57
+ }
58
+ // 2. lower.
59
+ const facts = lower(tx);
60
+ // 3. decideScope.
61
+ const scopeRes = decideScope(facts, {
62
+ network: opts.network,
63
+ ...(opts.userResponses?.validUntilLedger !== undefined
64
+ ? { validUntilLedger: opts.userResponses.validUntilLedger }
65
+ : {}),
66
+ });
67
+ if (!scopeRes.ok)
68
+ return scopeRes;
69
+ const scope = scopeRes.data;
70
+ if (scope.kind !== 'call_contract') {
71
+ // A default scope on the recording-path is not currently a Path-A flow;
72
+ // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
73
+ // over-broad default-scoped policy.
74
+ return {
75
+ ok: false,
76
+ error: {
77
+ code: 'SYNTHESIS_ERROR',
78
+ message: 'recording path requires a scoped contract; no default-scoped policies emitted',
79
+ severity: 'error',
80
+ retryable: false,
81
+ },
82
+ };
83
+ }
84
+ // 4. compose.
85
+ const topLevel = tx.invocations[0] ?? null;
86
+ const composeOpts = {
87
+ network: opts.network,
88
+ ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
89
+ };
90
+ const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
91
+ // 5. OZ compile.
92
+ const adapter = createOzAdapter(ozConfig);
93
+ const compileRes = adapter.compile(composed.ir);
94
+ if (!compileRes.proposed) {
95
+ return {
96
+ ok: false,
97
+ error: {
98
+ code: 'SYNTHESIS_ERROR',
99
+ message: `recording lowered to no installable OZ policy: ${compileRes.uncovered.join('; ')}`,
100
+ severity: 'error',
101
+ retryable: false,
102
+ details: { uncovered: compileRes.uncovered },
103
+ },
104
+ };
105
+ }
106
+ // 6. assemble ProposedPolicy.
107
+ const proposed = {
108
+ ...compileRes.proposed,
109
+ parseConfidence: { ...tx.parseConfidence },
110
+ warnings: [
111
+ ...compileRes.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
112
+ ...composed.warnings.map((w) => `${PATH_B_PREFIX}${w}`),
113
+ ],
114
+ ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
115
+ };
116
+ return { ok: true, data: proposed };
117
+ }
118
+ /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
119
+ * validUntilLedger / invocationLimit must be positive integers; limitAmount a
120
+ * positive i128 decimal string; network mainnet|testnet. */
121
+ function validateOptions(opts) {
122
+ if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
123
+ return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`);
124
+ }
125
+ // A confidenceOverride outside [0, 1] would disable the recorder gate (a
126
+ // negative threshold can never be exceeded), so reject it fail-closed.
127
+ const co = opts.confidenceOverride?.threshold;
128
+ if (co !== undefined && (!Number.isFinite(co) || co < 0 || co > 1)) {
129
+ return synthesisError(`confidenceOverride.threshold must be a finite number within [0, 1], got: ${co}`);
130
+ }
131
+ const ur = opts.userResponses;
132
+ if (ur) {
133
+ if (ur.windowSeconds !== undefined && !isPositiveInt(ur.windowSeconds)) {
134
+ return synthesisError(`windowSeconds must be a positive integer, got: ${ur.windowSeconds}`);
135
+ }
136
+ if (ur.validUntilLedger !== undefined &&
137
+ (!isPositiveInt(ur.validUntilLedger) || ur.validUntilLedger > SOROBAN_LIMITS.u32Max)) {
138
+ return synthesisError(`validUntilLedger must be a positive u32 ledger sequence (<= ${SOROBAN_LIMITS.u32Max}), got: ${ur.validUntilLedger}`);
139
+ }
140
+ if (ur.invocationLimit !== undefined && !isPositiveInt(ur.invocationLimit)) {
141
+ return synthesisError(`invocationLimit must be a positive integer, got: ${ur.invocationLimit}`);
142
+ }
143
+ if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
144
+ return synthesisError(`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`);
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+ function isPositiveInt(n) {
150
+ return Number.isInteger(n) && n > 0;
151
+ }
152
+ /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
153
+ function isPositiveI128(s) {
154
+ if (!/^[0-9]+$/.test(s))
155
+ return false;
156
+ try {
157
+ return BigInt(s) > 0n;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ function synthesisError(message) {
164
+ return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false };
165
+ }
166
+ function mergeAmbiguities(...lists) {
167
+ const out = [];
168
+ const seen = new Set();
169
+ for (const list of lists) {
170
+ for (const a of list) {
171
+ if (seen.has(a.code))
172
+ continue;
173
+ seen.add(a.code);
174
+ out.push(a);
175
+ }
176
+ }
177
+ return out;
178
+ }