@crediolabs/policy-synth 0.1.5 → 0.1.7

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 (62) hide show
  1. package/dist/record/decode.js +43 -0
  2. package/dist/record/freshness.d.ts +14 -1
  3. package/dist/record/freshness.js +32 -2
  4. package/dist/record/index.d.ts +11 -0
  5. package/dist/record/index.js +25 -0
  6. package/dist/record/movements.d.ts +15 -3
  7. package/dist/record/movements.js +42 -7
  8. package/dist/run/index.d.ts +13 -1
  9. package/dist/run/index.js +76 -18
  10. package/dist/run/schemas.d.ts +13 -0
  11. package/dist/run/schemas.js +15 -0
  12. package/dist/synth/address.d.ts +7 -0
  13. package/dist/synth/address.js +12 -0
  14. package/dist/synth/compose-from-recording.d.ts +4 -3
  15. package/dist/synth/compose-from-recording.js +16 -6
  16. package/dist/synth/deny-cases.d.ts +12 -2
  17. package/dist/synth/deny-cases.js +58 -2
  18. package/dist/synth/index.d.ts +1 -0
  19. package/dist/synth/index.js +4 -0
  20. package/dist/synth/minimize.d.ts +1 -1
  21. package/dist/synth/minimize.js +3 -3
  22. package/dist/synth/synthesize-from-recording.js +54 -6
  23. package/dist/types.d.ts +24 -1
  24. package/dist-cjs/record/decode.js +43 -0
  25. package/dist-cjs/record/freshness.d.ts +14 -1
  26. package/dist-cjs/record/freshness.js +32 -2
  27. package/dist-cjs/record/index.d.ts +11 -0
  28. package/dist-cjs/record/index.js +25 -0
  29. package/dist-cjs/record/movements.d.ts +15 -3
  30. package/dist-cjs/record/movements.js +42 -7
  31. package/dist-cjs/run/index.d.ts +13 -1
  32. package/dist-cjs/run/index.js +76 -17
  33. package/dist-cjs/run/schemas.d.ts +13 -0
  34. package/dist-cjs/run/schemas.js +15 -0
  35. package/dist-cjs/synth/address.d.ts +7 -0
  36. package/dist-cjs/synth/address.js +15 -0
  37. package/dist-cjs/synth/compose-from-recording.d.ts +4 -3
  38. package/dist-cjs/synth/compose-from-recording.js +16 -6
  39. package/dist-cjs/synth/deny-cases.d.ts +12 -2
  40. package/dist-cjs/synth/deny-cases.js +60 -2
  41. package/dist-cjs/synth/index.d.ts +1 -0
  42. package/dist-cjs/synth/index.js +6 -1
  43. package/dist-cjs/synth/minimize.d.ts +1 -1
  44. package/dist-cjs/synth/minimize.js +3 -3
  45. package/dist-cjs/synth/synthesize-from-recording.js +53 -5
  46. package/dist-cjs/types.d.ts +24 -1
  47. package/package.json +1 -1
  48. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
  49. package/src/record/corpus-fixtures.json +532 -0
  50. package/src/record/decode.ts +42 -0
  51. package/src/record/freshness.ts +34 -2
  52. package/src/record/index.ts +40 -0
  53. package/src/record/movements.ts +40 -7
  54. package/src/run/index.ts +80 -16
  55. package/src/run/schemas.ts +15 -0
  56. package/src/synth/address.ts +14 -0
  57. package/src/synth/compose-from-recording.ts +20 -9
  58. package/src/synth/deny-cases.ts +66 -2
  59. package/src/synth/index.ts +4 -0
  60. package/src/synth/minimize.ts +7 -3
  61. package/src/synth/synthesize-from-recording.ts +59 -6
  62. package/src/types.ts +18 -1
@@ -8,5 +8,15 @@ export interface GeneratedCases {
8
8
  permit: EvalContext;
9
9
  denies: DenyCase[];
10
10
  }
11
- /** Build deterministic model-evaluated alternatives without mutating the intended call. */
12
- export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext): GeneratedCases;
11
+ declare const OVERPERMISSIVE_DIMENSIONS: readonly ["argument_reorder"];
12
+ declare const ORIGINAL_DIMENSIONS: string[];
13
+ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
14
+ /** Build deterministic model-evaluated alternatives without mutating the intended call.
15
+ *
16
+ * @param predicate - the synthesized predicate
17
+ * @param permitCtx - EvalContext for the intended (permitted) call
18
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
19
+ * all known dimensions (including over-permissiveness mutations)
20
+ * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
21
+ * existing fixtures do not regress. */
22
+ export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
@@ -11,8 +11,38 @@ const ADJACENT_ASSETS = [
11
11
  'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
12
12
  'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
13
13
  ];
14
- /** Build deterministic model-evaluated alternatives without mutating the intended call. */
15
- export function generateCases(predicate, permitCtx) {
14
+ // Phase 1 property-harness mutation dimensions excluded from the synth pipeline's
15
+ // self-verify call so existing fixtures still emit policies. The harness tests
16
+ // them as FINDINGS against the already-emitted policy.
17
+ const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
18
+ // The 15 dimensions the synth pipeline uses for self-verify and minimise.
19
+ const ORIGINAL_DIMENSIONS = [
20
+ 'amount',
21
+ 'asset',
22
+ 'contract',
23
+ 'function',
24
+ 'timing',
25
+ 'time_window',
26
+ 'invocation_count',
27
+ 'arg_amount_bound',
28
+ 'arg_bound',
29
+ 'scope_contract_fn_arg',
30
+ 'oracle_stale',
31
+ 'oracle_missing',
32
+ 'oracle_deviation_exceeded',
33
+ 'oracle_paused',
34
+ 'soroswap_allowed_path',
35
+ ];
36
+ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
37
+ /** Build deterministic model-evaluated alternatives without mutating the intended call.
38
+ *
39
+ * @param predicate - the synthesized predicate
40
+ * @param permitCtx - EvalContext for the intended (permitted) call
41
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
42
+ * all known dimensions (including over-permissiveness mutations)
43
+ * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
44
+ * existing fixtures do not regress. */
45
+ export function generateCases(predicate, permitCtx, dimensions) {
16
46
  const facts = inspectPredicate(predicate);
17
47
  const denies = [];
18
48
  for (const comparison of facts.comparisons) {
@@ -144,6 +174,32 @@ export function generateCases(predicate, permitCtx) {
144
174
  ctx.args[comparison.left.index] = differentVector(ctx.args[comparison.left.index]);
145
175
  denies.push({ dimension: 'soroswap_allowed_path', ctx });
146
176
  }
177
+ // --- argument_reorder: swap first two address args ---
178
+ // Skipped when dimensions filter is active so the synth pipeline can emit a policy;
179
+ // the over-permissiveness harness then tests this mutation as a FINDING.
180
+ const constrainedArgIndices = new Set();
181
+ for (const comparison of facts.comparisons) {
182
+ if (comparison.left.kind === 'call_arg')
183
+ constrainedArgIndices.add(comparison.left.index);
184
+ }
185
+ for (const membership of facts.memberships) {
186
+ if (membership.needle.kind === 'call_arg')
187
+ constrainedArgIndices.add(membership.needle.index);
188
+ }
189
+ const hasConstrainedArg = constrainedArgIndices.size > 0;
190
+ if ((!dimensions || dimensions.includes('argument_reorder')) &&
191
+ hasConstrainedArg &&
192
+ permitCtx.args.length >= 2 &&
193
+ permitCtx.args[0]?.type === 'address' &&
194
+ permitCtx.args[1]?.type === 'address') {
195
+ const ctx = cloneContext(permitCtx);
196
+ // Guard above guarantees args[0] and args[1] exist and are address-typed.
197
+ const a0 = ctx.args[0];
198
+ const a1 = ctx.args[1];
199
+ ctx.args[0] = a1;
200
+ ctx.args[1] = a0;
201
+ denies.push({ dimension: 'argument_reorder', ctx });
202
+ }
147
203
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
148
204
  return { permit: cloneContext(permitCtx), denies };
149
205
  }
@@ -1,3 +1,4 @@
1
+ export { isStellarAddress } from './address.ts';
1
2
  export { type ComposeOptions, type ComposeResult, composeFromRecording, } from './compose-from-recording.ts';
2
3
  export { type DenyCase, type GeneratedCases, generateCases, } from './deny-cases.ts';
3
4
  export { type EvalContext, type EvalResult, evaluate } from './evaluate.ts';
@@ -1,4 +1,8 @@
1
1
  // src/synth/index.ts - re-export the synthesizer front-ends.
2
+ // Exported because `swapRecipientAllowlist` is part of the public synthesis
3
+ // input: a caller assembling one needs the same validator the schema applies,
4
+ // and re-deriving it elsewhere means a second address check that can drift.
5
+ export { isStellarAddress } from "./address.js";
2
6
  export { composeFromRecording, } from "./compose-from-recording.js";
3
7
  export { generateCases, } from "./deny-cases.js";
4
8
  export { evaluate } from "./evaluate.js";
@@ -1,4 +1,4 @@
1
1
  import type { PredicateNode } from '../types.ts';
2
2
  import type { EvalContext } from './evaluate.ts';
3
3
  /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
4
- export declare function minimize(predicate: PredicateNode, permitCtx: EvalContext): PredicateNode;
4
+ export declare function minimize(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): PredicateNode;
@@ -1,17 +1,17 @@
1
1
  import { generateCases } from "./deny-cases.js";
2
2
  import { runHarness } from "./harness.js";
3
3
  /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
4
- export function minimize(predicate, permitCtx) {
4
+ export function minimize(predicate, permitCtx, dimensions) {
5
5
  if (predicate.op !== 'and')
6
6
  return predicate;
7
7
  let children = [...predicate.children];
8
8
  let index = 0;
9
9
  while (index < children.length) {
10
10
  const current = { op: 'and', children };
11
- const currentCases = generateCases(current, permitCtx);
11
+ const currentCases = generateCases(current, permitCtx, dimensions);
12
12
  const candidateChildren = children.filter((_, childIndex) => childIndex !== index);
13
13
  const candidate = { op: 'and', children: candidateChildren };
14
- const candidateCases = generateCases(candidate, permitCtx);
14
+ const candidateCases = generateCases(candidate, permitCtx, dimensions);
15
15
  const verificationCases = {
16
16
  permit: candidateCases.permit,
17
17
  denies: mergeDenyCases(currentCases.denies, candidateCases.denies),
@@ -35,7 +35,7 @@ import { createOzAdapter } from "../adapters/oz/adapter.js";
35
35
  import { encodePredicate } from "../predicate/encode.js";
36
36
  import { MAX_SCVAL_CLONE_DEPTH, OZ_LIMITS, SOROBAN_LIMITS, } from "../types.js";
37
37
  import { composeFromRecording, } from "./compose-from-recording.js";
38
- import { generateCases } from "./deny-cases.js";
38
+ import { generateCases, ORIGINAL_DIMENSIONS } from "./deny-cases.js";
39
39
  import { evaluate } from "./evaluate.js";
40
40
  import { runHarness } from "./harness.js";
41
41
  import { lower } from "./lower.js";
@@ -126,6 +126,31 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
126
126
  },
127
127
  };
128
128
  }
129
+ // 1a. Zero-invocation refusal (item 1). A recording with zero contract
130
+ // invocations cleared the parseConfidence gate legitimately (the
131
+ // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
132
+ // policy must scope to an authorized contract call. Refuse before any
133
+ // lower/scope work so the failure is specific and actionable. The
134
+ // recorder's silence is also made visible via `parseConfidence.noInvocations`
135
+ // so consumers can pattern-match without inferring from `invocations: []`
136
+ // next to `overall: 1.0`. The message does NOT ask for an ABI - the
137
+ // failure mode is the recording shape, not decoding coverage.
138
+ const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0;
139
+ if (hasNoInvocations) {
140
+ return {
141
+ ok: false,
142
+ error: {
143
+ code: 'SYNTHESIS_ERROR',
144
+ message: 'Recording contains no contract invocation to scope a policy to. Re-record a transaction that invokes a Soroban contract function (e.g. an SAC/SEP-41 transfer, a Blend yield-claim, or a SoroSwap swap).',
145
+ severity: 'error',
146
+ retryable: false,
147
+ details: {
148
+ invocations: tx.invocations.length,
149
+ noInvocationsMarker: tx.parseConfidence.noInvocations === true,
150
+ },
151
+ },
152
+ };
153
+ }
129
154
  // Bound the recording size fail-closed (defense-in-depth for direct callers;
130
155
  // the MCP schema caps this too).
131
156
  if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
@@ -317,8 +342,10 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
317
342
  };
318
343
  }
319
344
  const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
320
- const finalPredicate = startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate;
321
- const harnessCases = generateCases(finalPredicate, permitCtx);
345
+ const finalPredicate = startingPredicate.op === 'and'
346
+ ? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
347
+ : startingPredicate;
348
+ const harnessCases = generateCases(finalPredicate, permitCtx, ORIGINAL_DIMENSIONS);
322
349
  const harnessResult = runHarness(finalPredicate, harnessCases);
323
350
  if (!harnessResult.ok) {
324
351
  return {
@@ -419,12 +446,24 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
419
446
  ...compileRes.proposed.contextRule,
420
447
  policies: mergedRefs,
421
448
  };
449
+ // When nothing installable was synthesised (no interpreter doc AND no OZ
450
+ // policy refs), an empty `policies` array reads as "no restrictions" rather
451
+ // than "I synthesised nothing". Surface that explicitly so the empty result
452
+ // is never mistaken for a permissive policy - the context rule still exists,
453
+ // but it constrains nothing. (Kept as `{ok:true}` so the documented
454
+ // Path-A/Path-B demo behaviour is preserved - see F3.)
455
+ const zeroPolicyWarning = mergedRefs.length === 0 && !interpreterPolicyDocument
456
+ ? [
457
+ 'No policy constraints were synthesised: the call to this contract is UNCONSTRAINED by this policy. Enable the interpreter (supply a smart account) to enforce the surfaced constraints.',
458
+ ]
459
+ : [];
422
460
  const proposed = {
423
461
  contextRule: mergedContextRule,
424
462
  policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
425
463
  policyRefs: mergedRefs,
426
464
  parseConfidence: { ...tx.parseConfidence },
427
465
  warnings: [
466
+ ...zeroPolicyWarning,
428
467
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
429
468
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
430
469
  ],
@@ -473,7 +512,7 @@ function validateOptions(opts) {
473
512
  return synthesisError(`invocationLimit must be a positive integer, got: ${ur.invocationLimit}`);
474
513
  }
475
514
  if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
476
- return synthesisError(`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`);
515
+ return synthesisError(`limitAmount must be a positive i128 decimal string within [1, ${I128_MAX}] (2^127-1), got: ${ur.limitAmount}`);
477
516
  }
478
517
  }
479
518
  if (opts.interpreter) {
@@ -523,15 +562,24 @@ function validateOptions(opts) {
523
562
  * contract address derivable from the on-chain account, never a
524
563
  * fixture/LLM-seam marker. */
525
564
  const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
565
+ /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
566
+ * cannot be represented on-chain, so reject it at the synthesis boundary
567
+ * (fail-closed) instead of passing it through as an over-broad spending_limit.
568
+ * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
569
+ const I128_MAX = 2n ** 127n - 1n;
526
570
  function isPositiveInt(n) {
527
571
  return Number.isInteger(n) && n > 0;
528
572
  }
529
- /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
573
+ /** True when `s` is a canonical positive decimal integer inside the signed-i128
574
+ * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
575
+ * it cannot be installed on-chain, and accepting it would emit a spending_limit
576
+ * with an effectively unbounded cap. */
530
577
  function isPositiveI128(s) {
531
578
  if (!/^[0-9]+$/.test(s))
532
579
  return false;
533
580
  try {
534
- return BigInt(s) > 0n;
581
+ const v = BigInt(s);
582
+ return v > 0n && v <= I128_MAX;
535
583
  }
536
584
  catch {
537
585
  return false;
package/dist/types.d.ts CHANGED
@@ -21,6 +21,17 @@ export type ScVal = {
21
21
  } | {
22
22
  type: 'bytes';
23
23
  value: string;
24
+ }
25
+ /** Map<Symbol, ScVal> carried forward for SAC/SEP-41 transfer event data,
26
+ * which uses a Map shape (key "amount" -> I128) on Stellar mainnet. The
27
+ * entry key is captured as its symbol/form string so readAmount can route
28
+ * to the conventional field name without re-decoding the full XDR. */
29
+ | {
30
+ type: 'map';
31
+ value: Array<{
32
+ key: string;
33
+ val: ScVal;
34
+ }>;
24
35
  } | {
25
36
  type: 'other';
26
37
  value: string;
@@ -272,7 +283,15 @@ export interface AmbiguityPrompt {
272
283
  export type AmbiguityCode = 'DURATION_UNSPECIFIED' | 'AMOUNT_BOUND_MISSING' | 'RECIPIENT_ALLOWLIST_EMPTY' | 'FREQUENCY_BOUND_MISSING' | 'ORACLE_ASSET_UNKNOWN' | 'MULTIPLE_UNRELATED_TARGETS';
273
284
  /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
274
285
  * breakdown is the diagnostic the user / agent sees when the gate fires. The
275
- * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`. */
286
+ * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`.
287
+ *
288
+ * `noInvocations` (item 1) is a recorder-side marker set ONLY when the envelope
289
+ * contained zero `invokeContract` host functions (e.g. a `createContract` or
290
+ * `uploadContractWasm` op). The math is unchanged: that case still scores 1.0
291
+ * via the `denom === 0` guard. The marker exists so a consumer does not have
292
+ * to infer the situation from `invocations.length === 0` next to `overall: 1.0`
293
+ * — a downstream synth refuses such recordings with a specific, actionable
294
+ * error rather than silently producing a useless empty scope. */
276
295
  export interface ParseConfidence {
277
296
  overall: number;
278
297
  knownContracts: string[];
@@ -285,4 +304,8 @@ export interface ParseConfidence {
285
304
  type: string;
286
305
  }>;
287
306
  thresholdUsed: number;
307
+ /** True iff the recorder decoded zero contract invocations. Absent on
308
+ * recordings that pre-date this field; consumers should treat `undefined`
309
+ * as "no marker" and rely on `invocations.length` as the fallback. */
310
+ noInvocations?: true;
288
311
  }
@@ -283,6 +283,26 @@ function scValToSubset(val, path, opaqueScVals, depth = 0) {
283
283
  value: arr.map((v, i) => scValToSubset(v, `${path}[${i}]`, opaqueScVals, depth + 1)),
284
284
  };
285
285
  }
286
+ case 'scvMap': {
287
+ if (depth >= types_ts_1.MAX_SCVAL_DEPTH) {
288
+ opaqueScVals.push({ path, type: 'depth-exceeded' });
289
+ return { type: 'other', value: 'depth-exceeded' };
290
+ }
291
+ // Map<Symbol, ScVal> is the canonical data shape for SAC/SEP-41
292
+ // transfer/mint/burn events on Stellar mainnet (verified empirically
293
+ // against txs 112d2392..., eb39f493..., 50d36f5c...). The entry key is
294
+ // carried forward so readAmount can route to the conventional field
295
+ // name without re-decoding the full XDR. Non-symbol / non-string keys
296
+ // are stringified via the same key-to-string rule used for topics.
297
+ const entries = (val.map() ?? []);
298
+ return {
299
+ type: 'map',
300
+ value: entries.map((entry, i) => ({
301
+ key: mapKeyToString(entry.key(), `${path}.key[${i}]`, opaqueScVals),
302
+ val: scValToSubset(entry.val(), `${path}.val[${i}]`, opaqueScVals, depth + 1),
303
+ })),
304
+ };
305
+ }
286
306
  case 'scvBytes': {
287
307
  return { type: 'bytes', value: Buffer.from(val.bytes()).toString('hex') };
288
308
  }
@@ -360,6 +380,29 @@ function scValToTopicString(val) {
360
380
  return Buffer.from(val.bytes()).toString('hex');
361
381
  return `<${kind}>`;
362
382
  }
383
+ /** Convert a Map<Symbol, ...> key ScVal to the string used in the normalised
384
+ * ScVal map representation. Uses the same string-form rule as
385
+ * `scValToTopicString` so SAC/SEP-41 event Map keys ("amount", "to_muxed_id")
386
+ * resolve to the same string in both the topic path and the map-entry path.
387
+ * Non-symbol / non-string keys record an opaque diagnostic and return a
388
+ * placeholder so the Map entry is still carried forward. */
389
+ function mapKeyToString(val, path, opaqueScVals) {
390
+ const kind = val.switch().name;
391
+ if (kind === 'scvSymbol')
392
+ return val.sym().toString();
393
+ if (kind === 'scvString')
394
+ return val.str().toString();
395
+ if (kind === 'scvU64')
396
+ return u64PartsToBigInt(val.u64()).toString();
397
+ if (kind === 'scvU32')
398
+ return val.u32().toString();
399
+ if (kind === 'scvI128')
400
+ return i128PartsToBigInt(val.i128()).toString();
401
+ if (kind === 'scvBytes')
402
+ return Buffer.from(val.bytes()).toString('hex');
403
+ opaqueScVals.push({ path, type: `unsupported-map-key:${kind}` });
404
+ return `<${kind}>`;
405
+ }
363
406
  /** Build the OnChainEvent[] view from raw contract events. Diagnostic-only:
364
407
  * captures the topics + data + emitter contract for the downstream
365
408
  * validator. */
@@ -13,5 +13,18 @@ export declare function computeParseConfidence(input: FreshnessInput): ParseConf
13
13
  * ToolError with the ParseConfidence payload attached as `details`. */
14
14
  export declare function isBelowThreshold(c: ParseConfidence): boolean;
15
15
  /** Convenience: build the user-facing remediation question for an
16
- * under-confidence recording. */
16
+ * under-confidence recording.
17
+ *
18
+ * The remediation text branches on which diagnostic bucket is non-empty:
19
+ * - unknownContracts only -> user MUST supply an ABI (or re-capture
20
+ * against a known protocol version). The contract is real, the
21
+ * recorder just cannot decode it.
22
+ * - opaqueScVals only -> the recorder encountered a value shape it
23
+ * should support but did not decode. The user cannot supply an ABI
24
+ * to fix a decoder bug; the guidance explicitly says so and points at
25
+ * re-running after a tool upgrade / reporting the path.
26
+ * - both -> list both diagnostics AND chain the right remediation for
27
+ * each (the user supplies an ABI AND reports the decoder gap).
28
+ * - neither (the "denom === 0" path) -> unchanged from the pre-fix
29
+ * text; the user did not actually hit a code-level barrier. */
17
30
  export declare function buildLowConfidenceQuestion(c: ParseConfidence): string;
@@ -41,8 +41,23 @@ function isBelowThreshold(c) {
41
41
  return c.overall < c.thresholdUsed;
42
42
  }
43
43
  /** Convenience: build the user-facing remediation question for an
44
- * under-confidence recording. */
44
+ * under-confidence recording.
45
+ *
46
+ * The remediation text branches on which diagnostic bucket is non-empty:
47
+ * - unknownContracts only -> user MUST supply an ABI (or re-capture
48
+ * against a known protocol version). The contract is real, the
49
+ * recorder just cannot decode it.
50
+ * - opaqueScVals only -> the recorder encountered a value shape it
51
+ * should support but did not decode. The user cannot supply an ABI
52
+ * to fix a decoder bug; the guidance explicitly says so and points at
53
+ * re-running after a tool upgrade / reporting the path.
54
+ * - both -> list both diagnostics AND chain the right remediation for
55
+ * each (the user supplies an ABI AND reports the decoder gap).
56
+ * - neither (the "denom === 0" path) -> unchanged from the pre-fix
57
+ * text; the user did not actually hit a code-level barrier. */
45
58
  function buildLowConfidenceQuestion(c) {
59
+ const hasUnknown = c.unknownContracts.length > 0;
60
+ const hasOpaque = c.opaqueScVals.length > 0;
46
61
  const reasons = [];
47
62
  for (const u of c.unknownContracts) {
48
63
  reasons.push(`unknown contract ${u.contract} (${u.reason})`);
@@ -51,5 +66,20 @@ function buildLowConfidenceQuestion(c) {
51
66
  reasons.push(`opaque ScVal at ${o.path} (${o.type})`);
52
67
  }
53
68
  const why = reasons.length === 0 ? 'no diagnostic reason available' : reasons.join('; ');
54
- return `Recording refused: parseConfidence ${c.overall.toFixed(3)} is below the threshold ${c.thresholdUsed.toFixed(3)}. Diagnostic: ${why}. Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version, then re-run record_transaction.`;
69
+ const header = `Recording refused: parseConfidence ${c.overall.toFixed(3)} is below the threshold ${c.thresholdUsed.toFixed(3)}. Diagnostic: ${why}.`;
70
+ // The two remediation branches are explicit so the user (or the agent)
71
+ // can dispatch on the verb. The "unknown contract" branch is the only
72
+ // one that asks for an ABI; the "opaque ScVal" branch asks the user to
73
+ // report the path and re-run after a tool upgrade.
74
+ if (hasUnknown && hasOpaque) {
75
+ return (`${header} Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version; ` +
76
+ `also file the opaque ScVal path against the recorder (decoder limitation) and re-run record_transaction after a tool upgrade.`);
77
+ }
78
+ if (hasUnknown) {
79
+ return `${header} Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version, then re-run record_transaction.`;
80
+ }
81
+ if (hasOpaque) {
82
+ return `${header} This is a recorder decoder limitation (the value shape is supported in principle but not yet decoded) - the agent cannot fix it by supplying an ABI. Report the opaque ScVal path above against the recorder and re-run record_transaction after a tool upgrade.`;
83
+ }
84
+ return `${header} No actionable diagnostic was recorded; re-run record_transaction against a fresh fetch and inspect the events.`;
55
85
  }
@@ -5,6 +5,9 @@ import { type RpcFetcher } from './rpc.ts';
5
5
  * - exactly one of `hash` / `xdr`
6
6
  * - `network` for hash mode (selects the public RPC)
7
7
  * - optional injected `fetcher` for tests + custom RPC endpoints
8
+ * - optional `crossNetworkFetcher` (item 2) for tests + offline use;
9
+ * production builds the cross-network probe from `createRpcServer` when
10
+ * this field is absent
8
11
  * - optional `confidenceOverride` to relax the gate (default 1.0)
9
12
  *
10
13
  * In xdr mode `network` is still required so the caller documents intent
@@ -15,6 +18,14 @@ export interface RecordInput {
15
18
  xdr?: string;
16
19
  network: Network;
17
20
  fetcher?: RpcFetcher;
21
+ /** Test-only seam + explicit production override for the cross-network
22
+ * probe (item 2). When the primary fetcher returns null on hash mode,
23
+ * the recorder also tries this fetcher against the OTHER network. If it
24
+ * finds the hash there, the recorder returns a specific actionable
25
+ * error mentioning the actual network. When unset, the recorder builds
26
+ * this fetcher from `createRpcServer(otherNetwork)` so the help fires
27
+ * automatically; tests can pass a deterministic stub. */
28
+ crossNetworkFetcher?: RpcFetcher;
18
29
  confidenceOverride?: number;
19
30
  }
20
31
  export type RecordResult = ToolResponse<RecordedTransaction>;
@@ -53,6 +53,21 @@ async function recordTransaction(input) {
53
53
  return err('RECORDING_FAILED', 'hash required for on-chain mode', false);
54
54
  const fetched = await fetcher(hash);
55
55
  if (!fetched) {
56
+ // Item 2: cross-network sanity check. The default NOT_FOUND message
57
+ // ("transaction X not found on <network>") is not actionable when the
58
+ // user just used the wrong --network. Probe the OTHER network's
59
+ // fetcher before giving up: if the hash actually exists there, the
60
+ // user almost certainly passed the wrong network flag. Trade-off:
61
+ // one extra RPC round-trip ONLY on the NOT_FOUND path (the happy
62
+ // path is unchanged; NOT_FOUND is rare). The probe is auto-built
63
+ // from createRpcServer(otherNetwork) when the caller did not inject
64
+ // one; tests pass an explicit stub for offline determinism.
65
+ const otherNetwork = input.network === 'mainnet' ? 'testnet' : 'mainnet';
66
+ const crossFetcher = input.crossNetworkFetcher ?? (0, rpc_ts_1.createRpcServer)(otherNetwork);
67
+ const crossFetched = await crossFetcher(hash);
68
+ if (crossFetched) {
69
+ return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}; it exists on ${otherNetwork}. Re-run with --network ${otherNetwork} (or the corresponding MCP / API field).`, true);
70
+ }
56
71
  return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}`, true);
57
72
  }
58
73
  const events = combineEvents(fetched.events);
@@ -128,6 +143,16 @@ function finish(network, decoded, confidenceOverride) {
128
143
  unknownContracts: decoded.unknownContracts,
129
144
  opaqueScVals: decoded.opaqueScVals,
130
145
  });
146
+ // Item 1: surface the "zero contract invocations decoded" case explicitly.
147
+ // The math already pins overall = 1.0 via the `denom === 0` guard; the
148
+ // marker just makes the silence visible so a downstream synth can refuse
149
+ // the recording (a policy must scope to an authorized contract call)
150
+ // without inferring the situation from `invocations: []` next to a
151
+ // confidence of 1.0. Only set when zero invocations were actually decoded
152
+ // - the marker is a positive signal, not a default.
153
+ if (decoded.invocations.length === 0) {
154
+ confidence = { ...confidence, noInvocations: true };
155
+ }
131
156
  if (typeof confidenceOverride === 'number') {
132
157
  confidence = { ...confidence, thresholdUsed: confidenceOverride };
133
158
  }
@@ -9,9 +9,21 @@ export declare function extractTokenMovements(events: OnChainEvent[], opaqueScVa
9
9
  }>): TokenMovement[];
10
10
  /** Read an I128 amount from an event data ScVal. Supports:
11
11
  * - data is I128 directly
12
- * - data is a Vec whose [0] (or [1] for SAC transfer) is I128
13
- * - data is a Vec whose [0] is a Map { 'amount': I128 }
14
- * - data is U64 (fallback for amount fields encoded as u64) */
12
+ * - data is U64 (fallback for amount fields encoded as u64)
13
+ * - data is a Vec whose [0] (or [1] for SAC transfer) is I128 / U64
14
+ * - data is a Vec whose [0] is a Map { 'amount': I128 | U64 }
15
+ * - data is a Map whose entry with key "amount" is I128 / U64
16
+ *
17
+ * The Map shapes are the canonical data layout for SAC/SEP-41 `transfer`,
18
+ * `mint`, and `burn` events on Stellar mainnet, e.g. the USDC SAC and every
19
+ * Stellar Asset Contract. The key `amount` is the SEP-41-defined field name
20
+ * (verified empirically against txs 112d2392..., eb39f493..., 50d36f5c...
21
+ * where the data is `Map { amount: I128, to_muxed_id: ... }`). The other
22
+ * Map entries we observe in the wild (to_muxed_id, from_muxed_id) are
23
+ * muxed-address adjuncts that the recorder does not support as topic
24
+ * addresses; they are ignored here on purpose. A Map without an `amount`
25
+ * entry, or with an `amount` entry that is not I128/U64, returns null
26
+ * (fail-closed). */
15
27
  export declare function readAmount(data: OnChainEvent['data']): string | null;
16
28
  /** Re-decode a single ContractEvent body into a TokenMovement-shaped tuple,
17
29
  * given the raw xdr.ScVal form (NOT the OnChainEvent subset). Used by the
@@ -86,25 +86,60 @@ function parseSingleEvent(evt) {
86
86
  }
87
87
  /** Read an I128 amount from an event data ScVal. Supports:
88
88
  * - data is I128 directly
89
- * - data is a Vec whose [0] (or [1] for SAC transfer) is I128
90
- * - data is a Vec whose [0] is a Map { 'amount': I128 }
91
- * - data is U64 (fallback for amount fields encoded as u64) */
89
+ * - data is U64 (fallback for amount fields encoded as u64)
90
+ * - data is a Vec whose [0] (or [1] for SAC transfer) is I128 / U64
91
+ * - data is a Vec whose [0] is a Map { 'amount': I128 | U64 }
92
+ * - data is a Map whose entry with key "amount" is I128 / U64
93
+ *
94
+ * The Map shapes are the canonical data layout for SAC/SEP-41 `transfer`,
95
+ * `mint`, and `burn` events on Stellar mainnet, e.g. the USDC SAC and every
96
+ * Stellar Asset Contract. The key `amount` is the SEP-41-defined field name
97
+ * (verified empirically against txs 112d2392..., eb39f493..., 50d36f5c...
98
+ * where the data is `Map { amount: I128, to_muxed_id: ... }`). The other
99
+ * Map entries we observe in the wild (to_muxed_id, from_muxed_id) are
100
+ * muxed-address adjuncts that the recorder does not support as topic
101
+ * addresses; they are ignored here on purpose. A Map without an `amount`
102
+ * entry, or with an `amount` entry that is not I128/U64, returns null
103
+ * (fail-closed). */
92
104
  function readAmount(data) {
93
105
  if (data.type === 'i128')
94
106
  return data.value;
95
107
  if (data.type === 'u64')
96
108
  return data.value;
109
+ if (data.type === 'map')
110
+ return readAmountFromMap(data.value);
97
111
  if (data.type !== 'vec')
98
112
  return null;
99
- const v = data.value;
100
- // SAC transfer event data shape (some clients): Vec<Address, I128>
101
- for (const item of v) {
113
+ // Vec shape: walk the elements and accept the first I128/U64 OR a Map whose
114
+ // 'amount' entry is I128/U64. SAC transfer event data is sometimes
115
+ // Vec<Address, I128> and sometimes Vec<Map{amount: I128}, ...>; both reach
116
+ // the same code path below.
117
+ for (const item of data.value) {
102
118
  if (item.type === 'i128')
103
119
  return item.value;
104
120
  if (item.type === 'u64')
105
121
  return item.value;
122
+ if (item.type === 'map') {
123
+ const fromMap = readAmountFromMap(item.value);
124
+ if (fromMap !== null)
125
+ return fromMap;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ /** Try to extract an I128/U64 amount from a Map's `amount` entry. Returns
131
+ * null when the Map lacks an `amount` key or the entry is not a numeric
132
+ * ScVal kind this recorder understands. */
133
+ function readAmountFromMap(entries) {
134
+ for (const entry of entries) {
135
+ if (entry.key !== 'amount')
136
+ continue;
137
+ if (entry.val.type === 'i128')
138
+ return entry.val.value;
139
+ if (entry.val.type === 'u64')
140
+ return entry.val.value;
141
+ return null;
106
142
  }
107
- // Some flavours wrap in a Map. We expose Map entries as other; bail.
108
143
  return null;
109
144
  }
110
145
  /** Extract a single address from an event data ScVal at the given vec index. */
@@ -1,4 +1,4 @@
1
- import { type ProposedPolicy, type RecordedTransaction, type ToolResponse } from '../index.ts';
1
+ import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
2
2
  import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
3
  export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
4
  export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
@@ -29,3 +29,15 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
29
29
  * XDR decode error in the adapter) would otherwise surface as
30
30
  * "[object Object]" in the MCP transport. */
31
31
  export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;
32
+ /** Build a canonical ToolError for a thrown exception caught by the tool
33
+ * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
34
+ * default, so we extract a string-friendly message and tag the original
35
+ * error in `details` for the agent to inspect. The `code` is the tool's
36
+ * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
37
+ * for `synthesize_policy`) so the agent dispatches on the same code the
38
+ * structured ToolError would carry.
39
+ *
40
+ * Exported as `_caughtError` (the leading underscore signals the test-only
41
+ * seam) so the suite in run/index.test.ts can drive the envelope path
42
+ * without standing up a full recordTransaction pipeline. */
43
+ export declare function caughtError(toolName: 'record_transaction' | 'synthesize_policy', code: ErrorCode, e: unknown): ToolError;