@crediolabs/policy-synth 0.1.4 → 0.1.6

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/dist/codegen/template.js +3 -3
  3. package/dist/record/decode.d.ts +6 -3
  4. package/dist/record/decode.js +56 -3
  5. package/dist/record/freshness.d.ts +14 -1
  6. package/dist/record/freshness.js +32 -2
  7. package/dist/record/index.d.ts +11 -0
  8. package/dist/record/index.js +25 -0
  9. package/dist/record/movements.d.ts +15 -3
  10. package/dist/record/movements.js +42 -7
  11. package/dist/run/index.d.ts +43 -0
  12. package/dist/run/index.js +222 -0
  13. package/dist/run/schemas.d.ts +2016 -0
  14. package/dist/run/schemas.js +223 -0
  15. package/dist/synth/address.d.ts +7 -0
  16. package/dist/synth/address.js +12 -0
  17. package/dist/synth/compose-from-recording.d.ts +4 -3
  18. package/dist/synth/compose-from-recording.js +16 -6
  19. package/dist/synth/deny-cases.d.ts +12 -2
  20. package/dist/synth/deny-cases.js +76 -4
  21. package/dist/synth/evaluate.js +2 -2
  22. package/dist/synth/index.d.ts +1 -0
  23. package/dist/synth/index.js +4 -0
  24. package/dist/synth/minimize.d.ts +1 -1
  25. package/dist/synth/minimize.js +3 -3
  26. package/dist/synth/synthesize-from-recording.d.ts +5 -2
  27. package/dist/synth/synthesize-from-recording.js +175 -69
  28. package/dist/types.d.ts +41 -1
  29. package/dist/types.js +17 -0
  30. package/dist/verify/simulate.js +18 -2
  31. package/dist/verify/verify.js +18 -2
  32. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  33. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  34. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  35. package/dist-cjs/adapters/interpreter/index.js +8 -0
  36. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  37. package/dist-cjs/adapters/oz/adapter.js +289 -0
  38. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  39. package/dist-cjs/adapters/oz/index.js +8 -0
  40. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  41. package/dist-cjs/codegen/compile-gate.js +123 -0
  42. package/dist-cjs/codegen/index.d.ts +2 -0
  43. package/dist-cjs/codegen/index.js +14 -0
  44. package/dist-cjs/codegen/template.d.ts +18 -0
  45. package/dist-cjs/codegen/template.js +134 -0
  46. package/dist-cjs/errors.d.ts +37 -0
  47. package/dist-cjs/errors.js +3 -0
  48. package/dist-cjs/index.d.ts +11 -0
  49. package/dist-cjs/index.js +27 -0
  50. package/dist-cjs/ir/index.d.ts +1 -0
  51. package/dist-cjs/ir/index.js +3 -0
  52. package/dist-cjs/ir/types.d.ts +108 -0
  53. package/dist-cjs/ir/types.js +12 -0
  54. package/dist-cjs/mandate/index.d.ts +2 -0
  55. package/dist-cjs/mandate/index.js +6 -0
  56. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  57. package/dist-cjs/mandate/to-ir.js +63 -0
  58. package/dist-cjs/mandate/types.d.ts +20 -0
  59. package/dist-cjs/mandate/types.js +9 -0
  60. package/dist-cjs/package.json +3 -0
  61. package/dist-cjs/predicate/encode.d.ts +10 -0
  62. package/dist-cjs/predicate/encode.js +252 -0
  63. package/dist-cjs/predicate/index.d.ts +1 -0
  64. package/dist-cjs/predicate/index.js +6 -0
  65. package/dist-cjs/record/decode.d.ts +79 -0
  66. package/dist-cjs/record/decode.js +441 -0
  67. package/dist-cjs/record/freshness.d.ts +30 -0
  68. package/dist-cjs/record/freshness.js +85 -0
  69. package/dist-cjs/record/index.d.ts +32 -0
  70. package/dist-cjs/record/index.js +191 -0
  71. package/dist-cjs/record/movements.d.ts +32 -0
  72. package/dist-cjs/record/movements.js +227 -0
  73. package/dist-cjs/record/rpc.d.ts +22 -0
  74. package/dist-cjs/record/rpc.js +74 -0
  75. package/dist-cjs/record/validate.d.ts +22 -0
  76. package/dist-cjs/record/validate.js +63 -0
  77. package/dist-cjs/registry/identify.d.ts +11 -0
  78. package/dist-cjs/registry/identify.js +87 -0
  79. package/dist-cjs/registry/index.d.ts +3 -0
  80. package/dist-cjs/registry/index.js +15 -0
  81. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  82. package/dist-cjs/registry/known-addresses.js +53 -0
  83. package/dist-cjs/registry/protocols.d.ts +38 -0
  84. package/dist-cjs/registry/protocols.js +153 -0
  85. package/dist-cjs/review-card/builder.d.ts +14 -0
  86. package/dist-cjs/review-card/builder.js +264 -0
  87. package/dist-cjs/review-card/conflict.d.ts +40 -0
  88. package/dist-cjs/review-card/conflict.js +114 -0
  89. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  90. package/dist-cjs/review-card/cross-check.js +151 -0
  91. package/dist-cjs/review-card/index.d.ts +3 -0
  92. package/dist-cjs/review-card/index.js +10 -0
  93. package/dist-cjs/run/index.d.ts +43 -0
  94. package/dist-cjs/run/index.js +237 -0
  95. package/dist-cjs/run/schemas.d.ts +2016 -0
  96. package/dist-cjs/run/schemas.js +226 -0
  97. package/dist-cjs/seams/index.d.ts +1 -0
  98. package/dist-cjs/seams/index.js +3 -0
  99. package/dist-cjs/seams/types.d.ts +66 -0
  100. package/dist-cjs/seams/types.js +12 -0
  101. package/dist-cjs/synth/address.d.ts +7 -0
  102. package/dist-cjs/synth/address.js +15 -0
  103. package/dist-cjs/synth/compose-from-recording.d.ts +65 -0
  104. package/dist-cjs/synth/compose-from-recording.js +366 -0
  105. package/dist-cjs/synth/deny-cases.d.ts +22 -0
  106. package/dist-cjs/synth/deny-cases.js +438 -0
  107. package/dist-cjs/synth/evaluate.d.ts +39 -0
  108. package/dist-cjs/synth/evaluate.js +428 -0
  109. package/dist-cjs/synth/harness.d.ts +16 -0
  110. package/dist-cjs/synth/harness.js +29 -0
  111. package/dist-cjs/synth/index.d.ts +10 -0
  112. package/dist-cjs/synth/index.js +28 -0
  113. package/dist-cjs/synth/lower.d.ts +23 -0
  114. package/dist-cjs/synth/lower.js +119 -0
  115. package/dist-cjs/synth/minimize.d.ts +4 -0
  116. package/dist-cjs/synth/minimize.js +41 -0
  117. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  118. package/dist-cjs/synth/predicate-literals.js +28 -0
  119. package/dist-cjs/synth/scope.d.ts +26 -0
  120. package/dist-cjs/synth/scope.js +81 -0
  121. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  122. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  123. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  124. package/dist-cjs/synth/synthesize-from-recording.js +759 -0
  125. package/dist-cjs/types.d.ts +311 -0
  126. package/dist-cjs/types.js +55 -0
  127. package/dist-cjs/verify/envelope.d.ts +15 -0
  128. package/dist-cjs/verify/envelope.js +23 -0
  129. package/dist-cjs/verify/index.d.ts +3 -0
  130. package/dist-cjs/verify/index.js +8 -0
  131. package/dist-cjs/verify/simulate.d.ts +31 -0
  132. package/dist-cjs/verify/simulate.js +261 -0
  133. package/dist-cjs/verify/verify.d.ts +21 -0
  134. package/dist-cjs/verify/verify.js +192 -0
  135. package/package.json +43 -3
  136. package/src/codegen/template.ts +3 -3
  137. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
  138. package/src/record/corpus-fixtures.json +532 -0
  139. package/src/record/decode.ts +63 -10
  140. package/src/record/freshness.ts +34 -2
  141. package/src/record/index.ts +40 -0
  142. package/src/record/movements.ts +40 -7
  143. package/src/run/index.ts +277 -0
  144. package/src/run/schemas.ts +250 -0
  145. package/src/synth/address.ts +14 -0
  146. package/src/synth/compose-from-recording.ts +20 -9
  147. package/src/synth/deny-cases.ts +87 -4
  148. package/src/synth/evaluate.ts +2 -2
  149. package/src/synth/index.ts +4 -0
  150. package/src/synth/minimize.ts +7 -3
  151. package/src/synth/synthesize-from-recording.ts +200 -68
  152. package/src/types.ts +37 -1
  153. package/src/verify/simulate.ts +21 -2
  154. package/src/verify/verify.ts +21 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Untangled Finance Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -51,10 +51,10 @@ function renderUncovered(uncovered) {
51
51
  *
52
52
  * Same `(spec, name)` always produces byte-identical output. */
53
53
  export function generateRust(spec, name) {
54
- const contract = spec.contract;
55
- const fnName = spec.fnName ?? 'enforce';
54
+ const _contract = spec.contract;
55
+ const _fnName = spec.fnName ?? 'enforce';
56
56
  const structName = asRustIdent(name);
57
- const dataKeyName = `${asRustIdent(name)}_data`;
57
+ const _dataKeyName = `${asRustIdent(name)}_data`;
58
58
  const uncoveredBlock = renderUncovered(spec.uncovered);
59
59
  return `// Auto-generated by the OZ Policy Builder codegen escape hatch.
60
60
  // THIS OUTPUT IS THE USER'S TO COMPLETE AND AUDIT.
@@ -1,5 +1,5 @@
1
1
  import { xdr } from '@stellar/stellar-sdk';
2
- import type { ContractInvocation, Network, OnChainEvent, ParseConfidence, ScVal, TokenMovement } from '../types.ts';
2
+ import { type ContractInvocation, type Network, type OnChainEvent, type ParseConfidence, type ScVal, type TokenMovement } from '../types.ts';
3
3
  /** Result of decoding an envelope XDR plus the surrounding metadata from
4
4
  * `getTransaction` (events + auth entries). All fields are plain JSON; the
5
5
  * caller decides how to interpret them. */
@@ -44,8 +44,11 @@ export declare function decodeScAddressToC(scAddr: xdr.ScAddress): string;
44
44
  /** Map an arbitrary ScVal into the normalised `ScVal` subset defined in
45
45
  * types.ts. Anything outside the subset is wrapped as `{type:'other', value}`
46
46
  * AND recorded in `opaqueScVals` so the freshness module can reduce
47
- * parseConfidence. */
48
- export declare function scValToSubset(val: xdr.ScVal, path: string, opaqueScVals: ParseConfidence['opaqueScVals']): ScVal;
47
+ * parseConfidence. Recursion is bounded by `MAX_SCVAL_DEPTH` so a
48
+ * hand-crafted nested-vec payload cannot RangeError the JS stack; the
49
+ * over-depth branch is collapsed to a single opaque `depth-exceeded` token
50
+ * (the recorder's parseConfidence gate then fails the recording closed). */
51
+ export declare function scValToSubset(val: xdr.ScVal, path: string, opaqueScVals: ParseConfidence['opaqueScVals'], depth?: number): ScVal;
49
52
  /** Convert Int128Parts (hi: Int64, lo: Uint64) to a BigInt.
50
53
  * hi is a signed 64-bit value; lo is unsigned. We use the SDK-provided
51
54
  * `toString()` form which gives the signed decimal representation of the 64-bit
@@ -17,6 +17,7 @@
17
17
  // No network calls. No randomness. No globals.
18
18
  import { Address, StrKey, xdr } from '@stellar/stellar-sdk';
19
19
  import { identifyProtocol } from "../registry/identify.js";
20
+ import { MAX_SCVAL_DEPTH, } from "../types.js";
20
21
  /** Decode a TransactionEnvelope XDR (base64) into a fully parsed invocation tree. */
21
22
  export function decodeEnvelopeXdr(envelopeXdrB64, events = [], _authEntries = [], ledgerSequence = 0, knownContracts = new Set(), network = null) {
22
23
  const envelope = xdr.TransactionEnvelope.fromXDR(envelopeXdrB64, 'base64');
@@ -226,8 +227,11 @@ function symbolValueFromBufferOrString(s) {
226
227
  /** Map an arbitrary ScVal into the normalised `ScVal` subset defined in
227
228
  * types.ts. Anything outside the subset is wrapped as `{type:'other', value}`
228
229
  * AND recorded in `opaqueScVals` so the freshness module can reduce
229
- * parseConfidence. */
230
- export function scValToSubset(val, path, opaqueScVals) {
230
+ * parseConfidence. Recursion is bounded by `MAX_SCVAL_DEPTH` so a
231
+ * hand-crafted nested-vec payload cannot RangeError the JS stack; the
232
+ * over-depth branch is collapsed to a single opaque `depth-exceeded` token
233
+ * (the recorder's parseConfidence gate then fails the recording closed). */
234
+ export function scValToSubset(val, path, opaqueScVals, depth = 0) {
231
235
  const kind = val.switch().name;
232
236
  switch (kind) {
233
237
  case 'scvAddress': {
@@ -252,10 +256,36 @@ export function scValToSubset(val, path, opaqueScVals) {
252
256
  return { type: 'symbol', value: val.sym().toString() };
253
257
  }
254
258
  case 'scvVec': {
259
+ if (depth >= MAX_SCVAL_DEPTH) {
260
+ // Over-depth branch: collapse to a single opaque terminal so the
261
+ // downstream parseConfidence gate sees the fail-closed freshness hit.
262
+ opaqueScVals.push({ path, type: 'depth-exceeded' });
263
+ return { type: 'other', value: 'depth-exceeded' };
264
+ }
255
265
  const arr = (val.vec() ?? []);
256
266
  return {
257
267
  type: 'vec',
258
- value: arr.map((v, i) => scValToSubset(v, `${path}[${i}]`, opaqueScVals)),
268
+ value: arr.map((v, i) => scValToSubset(v, `${path}[${i}]`, opaqueScVals, depth + 1)),
269
+ };
270
+ }
271
+ case 'scvMap': {
272
+ if (depth >= MAX_SCVAL_DEPTH) {
273
+ opaqueScVals.push({ path, type: 'depth-exceeded' });
274
+ return { type: 'other', value: 'depth-exceeded' };
275
+ }
276
+ // Map<Symbol, ScVal> is the canonical data shape for SAC/SEP-41
277
+ // transfer/mint/burn events on Stellar mainnet (verified empirically
278
+ // against txs 112d2392..., eb39f493..., 50d36f5c...). The entry key is
279
+ // carried forward so readAmount can route to the conventional field
280
+ // name without re-decoding the full XDR. Non-symbol / non-string keys
281
+ // are stringified via the same key-to-string rule used for topics.
282
+ const entries = (val.map() ?? []);
283
+ return {
284
+ type: 'map',
285
+ value: entries.map((entry, i) => ({
286
+ key: mapKeyToString(entry.key(), `${path}.key[${i}]`, opaqueScVals),
287
+ val: scValToSubset(entry.val(), `${path}.val[${i}]`, opaqueScVals, depth + 1),
288
+ })),
259
289
  };
260
290
  }
261
291
  case 'scvBytes': {
@@ -335,6 +365,29 @@ export function scValToTopicString(val) {
335
365
  return Buffer.from(val.bytes()).toString('hex');
336
366
  return `<${kind}>`;
337
367
  }
368
+ /** Convert a Map<Symbol, ...> key ScVal to the string used in the normalised
369
+ * ScVal map representation. Uses the same string-form rule as
370
+ * `scValToTopicString` so SAC/SEP-41 event Map keys ("amount", "to_muxed_id")
371
+ * resolve to the same string in both the topic path and the map-entry path.
372
+ * Non-symbol / non-string keys record an opaque diagnostic and return a
373
+ * placeholder so the Map entry is still carried forward. */
374
+ function mapKeyToString(val, path, opaqueScVals) {
375
+ const kind = val.switch().name;
376
+ if (kind === 'scvSymbol')
377
+ return val.sym().toString();
378
+ if (kind === 'scvString')
379
+ return val.str().toString();
380
+ if (kind === 'scvU64')
381
+ return u64PartsToBigInt(val.u64()).toString();
382
+ if (kind === 'scvU32')
383
+ return val.u32().toString();
384
+ if (kind === 'scvI128')
385
+ return i128PartsToBigInt(val.i128()).toString();
386
+ if (kind === 'scvBytes')
387
+ return Buffer.from(val.bytes()).toString('hex');
388
+ opaqueScVals.push({ path, type: `unsupported-map-key:${kind}` });
389
+ return `<${kind}>`;
390
+ }
338
391
  /** Build the OnChainEvent[] view from raw contract events. Diagnostic-only:
339
392
  * captures the topics + data + emitter contract for the downstream
340
393
  * 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;
@@ -36,8 +36,23 @@ export function isBelowThreshold(c) {
36
36
  return c.overall < c.thresholdUsed;
37
37
  }
38
38
  /** Convenience: build the user-facing remediation question for an
39
- * under-confidence recording. */
39
+ * under-confidence recording.
40
+ *
41
+ * The remediation text branches on which diagnostic bucket is non-empty:
42
+ * - unknownContracts only -> user MUST supply an ABI (or re-capture
43
+ * against a known protocol version). The contract is real, the
44
+ * recorder just cannot decode it.
45
+ * - opaqueScVals only -> the recorder encountered a value shape it
46
+ * should support but did not decode. The user cannot supply an ABI
47
+ * to fix a decoder bug; the guidance explicitly says so and points at
48
+ * re-running after a tool upgrade / reporting the path.
49
+ * - both -> list both diagnostics AND chain the right remediation for
50
+ * each (the user supplies an ABI AND reports the decoder gap).
51
+ * - neither (the "denom === 0" path) -> unchanged from the pre-fix
52
+ * text; the user did not actually hit a code-level barrier. */
40
53
  export function buildLowConfidenceQuestion(c) {
54
+ const hasUnknown = c.unknownContracts.length > 0;
55
+ const hasOpaque = c.opaqueScVals.length > 0;
41
56
  const reasons = [];
42
57
  for (const u of c.unknownContracts) {
43
58
  reasons.push(`unknown contract ${u.contract} (${u.reason})`);
@@ -46,5 +61,20 @@ export function buildLowConfidenceQuestion(c) {
46
61
  reasons.push(`opaque ScVal at ${o.path} (${o.type})`);
47
62
  }
48
63
  const why = reasons.length === 0 ? 'no diagnostic reason available' : reasons.join('; ');
49
- 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.`;
64
+ const header = `Recording refused: parseConfidence ${c.overall.toFixed(3)} is below the threshold ${c.thresholdUsed.toFixed(3)}. Diagnostic: ${why}.`;
65
+ // The two remediation branches are explicit so the user (or the agent)
66
+ // can dispatch on the verb. The "unknown contract" branch is the only
67
+ // one that asks for an ABI; the "opaque ScVal" branch asks the user to
68
+ // report the path and re-run after a tool upgrade.
69
+ if (hasUnknown && hasOpaque) {
70
+ return (`${header} Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version; ` +
71
+ `also file the opaque ScVal path against the recorder (decoder limitation) and re-run record_transaction after a tool upgrade.`);
72
+ }
73
+ if (hasUnknown) {
74
+ 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.`;
75
+ }
76
+ if (hasOpaque) {
77
+ 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.`;
78
+ }
79
+ return `${header} No actionable diagnostic was recorded; re-run record_transaction against a fresh fetch and inspect the events.`;
50
80
  }
@@ -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>;
@@ -50,6 +50,21 @@ export async function recordTransaction(input) {
50
50
  return err('RECORDING_FAILED', 'hash required for on-chain mode', false);
51
51
  const fetched = await fetcher(hash);
52
52
  if (!fetched) {
53
+ // Item 2: cross-network sanity check. The default NOT_FOUND message
54
+ // ("transaction X not found on <network>") is not actionable when the
55
+ // user just used the wrong --network. Probe the OTHER network's
56
+ // fetcher before giving up: if the hash actually exists there, the
57
+ // user almost certainly passed the wrong network flag. Trade-off:
58
+ // one extra RPC round-trip ONLY on the NOT_FOUND path (the happy
59
+ // path is unchanged; NOT_FOUND is rare). The probe is auto-built
60
+ // from createRpcServer(otherNetwork) when the caller did not inject
61
+ // one; tests pass an explicit stub for offline determinism.
62
+ const otherNetwork = input.network === 'mainnet' ? 'testnet' : 'mainnet';
63
+ const crossFetcher = input.crossNetworkFetcher ?? createRpcServer(otherNetwork);
64
+ const crossFetched = await crossFetcher(hash);
65
+ if (crossFetched) {
66
+ 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);
67
+ }
53
68
  return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}`, true);
54
69
  }
55
70
  const events = combineEvents(fetched.events);
@@ -125,6 +140,16 @@ function finish(network, decoded, confidenceOverride) {
125
140
  unknownContracts: decoded.unknownContracts,
126
141
  opaqueScVals: decoded.opaqueScVals,
127
142
  });
143
+ // Item 1: surface the "zero contract invocations decoded" case explicitly.
144
+ // The math already pins overall = 1.0 via the `denom === 0` guard; the
145
+ // marker just makes the silence visible so a downstream synth can refuse
146
+ // the recording (a policy must scope to an authorized contract call)
147
+ // without inferring the situation from `invocations: []` next to a
148
+ // confidence of 1.0. Only set when zero invocations were actually decoded
149
+ // - the marker is a positive signal, not a default.
150
+ if (decoded.invocations.length === 0) {
151
+ confidence = { ...confidence, noInvocations: true };
152
+ }
128
153
  if (typeof confidenceOverride === 'number') {
129
154
  confidence = { ...confidence, thresholdUsed: confidenceOverride };
130
155
  }
@@ -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
@@ -81,25 +81,60 @@ function parseSingleEvent(evt) {
81
81
  }
82
82
  /** Read an I128 amount from an event data ScVal. Supports:
83
83
  * - data is I128 directly
84
- * - data is a Vec whose [0] (or [1] for SAC transfer) is I128
85
- * - data is a Vec whose [0] is a Map { 'amount': I128 }
86
- * - data is U64 (fallback for amount fields encoded as u64) */
84
+ * - data is U64 (fallback for amount fields encoded as u64)
85
+ * - data is a Vec whose [0] (or [1] for SAC transfer) is I128 / U64
86
+ * - data is a Vec whose [0] is a Map { 'amount': I128 | U64 }
87
+ * - data is a Map whose entry with key "amount" is I128 / U64
88
+ *
89
+ * The Map shapes are the canonical data layout for SAC/SEP-41 `transfer`,
90
+ * `mint`, and `burn` events on Stellar mainnet, e.g. the USDC SAC and every
91
+ * Stellar Asset Contract. The key `amount` is the SEP-41-defined field name
92
+ * (verified empirically against txs 112d2392..., eb39f493..., 50d36f5c...
93
+ * where the data is `Map { amount: I128, to_muxed_id: ... }`). The other
94
+ * Map entries we observe in the wild (to_muxed_id, from_muxed_id) are
95
+ * muxed-address adjuncts that the recorder does not support as topic
96
+ * addresses; they are ignored here on purpose. A Map without an `amount`
97
+ * entry, or with an `amount` entry that is not I128/U64, returns null
98
+ * (fail-closed). */
87
99
  export function readAmount(data) {
88
100
  if (data.type === 'i128')
89
101
  return data.value;
90
102
  if (data.type === 'u64')
91
103
  return data.value;
104
+ if (data.type === 'map')
105
+ return readAmountFromMap(data.value);
92
106
  if (data.type !== 'vec')
93
107
  return null;
94
- const v = data.value;
95
- // SAC transfer event data shape (some clients): Vec<Address, I128>
96
- for (const item of v) {
108
+ // Vec shape: walk the elements and accept the first I128/U64 OR a Map whose
109
+ // 'amount' entry is I128/U64. SAC transfer event data is sometimes
110
+ // Vec<Address, I128> and sometimes Vec<Map{amount: I128}, ...>; both reach
111
+ // the same code path below.
112
+ for (const item of data.value) {
97
113
  if (item.type === 'i128')
98
114
  return item.value;
99
115
  if (item.type === 'u64')
100
116
  return item.value;
117
+ if (item.type === 'map') {
118
+ const fromMap = readAmountFromMap(item.value);
119
+ if (fromMap !== null)
120
+ return fromMap;
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ /** Try to extract an I128/U64 amount from a Map's `amount` entry. Returns
126
+ * null when the Map lacks an `amount` key or the entry is not a numeric
127
+ * ScVal kind this recorder understands. */
128
+ function readAmountFromMap(entries) {
129
+ for (const entry of entries) {
130
+ if (entry.key !== 'amount')
131
+ continue;
132
+ if (entry.val.type === 'i128')
133
+ return entry.val.value;
134
+ if (entry.val.type === 'u64')
135
+ return entry.val.value;
136
+ return null;
101
137
  }
102
- // Some flavours wrap in a Map. We expose Map entries as other; bail.
103
138
  return null;
104
139
  }
105
140
  /** Extract a single address from an event data ScVal at the given vec index. */
@@ -0,0 +1,43 @@
1
+ import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
2
+ import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
+ export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
+ export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
5
+ export type RunRecordTransactionInput = RecordTransactionInput;
6
+ export type RunSynthesizePolicyInput = SynthesizePolicyInput;
7
+ /** `record_transaction` body - wraps `recordTransaction`. The tool input
8
+ * matches the core RecordInput minus the injected `fetcher` (the transport
9
+ * layer does not own the RPC). Returns the core ToolResponse unchanged.
10
+ *
11
+ * Item 6: a try/catch envelope wraps the core call. The core's own
12
+ * `recordTransaction` already returns `{ok:false, error}` for Zod + decoder
13
+ * failures, but a throw from the SDK (e.g. a malformed hash handed to
14
+ * `@stellar/stellar-sdk`'s StrKey decoder) would otherwise surface as
15
+ * "[object Object]" in the MCP transport. The envelope converts any
16
+ * non-ToolResponse throw into a structured `RECORDING_FAILED` ToolError so
17
+ * the agent always sees a machine-readable error code. */
18
+ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse<RecordedTransaction>>;
19
+ /** `synthesize_policy` body - discriminated union on `source`:
20
+ * - `mandate` -> synthesizeFromMandate
21
+ * - `recording` -> synthesizeFromRecording
22
+ * Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
23
+ * letting the agent pick the deterministic or the inferred path. The CLI
24
+ * routes the same way.
25
+ *
26
+ * Item 6: same try/catch envelope as `runRecordTransaction`. The core
27
+ * `synthesizeFromRecording` already converts ToolError-shaped throws to
28
+ * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
29
+ * XDR decode error in the adapter) would otherwise surface as
30
+ * "[object Object]" in the MCP transport. */
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;
@@ -0,0 +1,222 @@
1
+ // packages/policy-synth/src/run/index.ts
2
+ //
3
+ // Tool-body adapters for the two core front-ends. Reachable as
4
+ // `@crediolabs/policy-synth/run` from both the MCP server (which re-exports
5
+ // for tool-registration glue) and the CLI (which calls them directly). Each
6
+ // body is a THIN adapter over the pure core:
7
+ //
8
+ // 1. Re-validate the parsed input via Zod. This is a defence-in-depth check -
9
+ // the SDK has already parsed it through the registered schema, but we never
10
+ // want a tool body to throw on garbage (the SDK treats uncaught throws as
11
+ // transport errors and the agent loses the machine-readable ToolError).
12
+ // 2. Dispatch to the matching core entry point with the minimum required
13
+ // inputs.
14
+ // 3. Return the core's ToolResponse<T> unchanged. The server layer maps it
15
+ // to the MCP envelope; nothing here knows about MCP.
16
+ //
17
+ // No business logic. No retries. No session state. The same call shape can
18
+ // drive the CLI (which calls into the same core directly without MCP).
19
+ import { placeholderOzConfig, recordTransaction, synthesizeFromMandate, synthesizeFromRecording, } from "../index.js";
20
+ import { RecordTransactionInputSchema, SynthesizePolicyInputSchema, } from "./schemas.js";
21
+ // Re-export the underlying Zod schemas so the MCP package (and any other
22
+ // downstream consumer) can import the canonical input shapes from the same
23
+ // module that owns the tool-body glue. The strict schemas are the source of
24
+ // truth - MCP tool shapes are derived from them.
25
+ export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from "./schemas.js";
26
+ /** `record_transaction` body - wraps `recordTransaction`. The tool input
27
+ * matches the core RecordInput minus the injected `fetcher` (the transport
28
+ * layer does not own the RPC). Returns the core ToolResponse unchanged.
29
+ *
30
+ * Item 6: a try/catch envelope wraps the core call. The core's own
31
+ * `recordTransaction` already returns `{ok:false, error}` for Zod + decoder
32
+ * failures, but a throw from the SDK (e.g. a malformed hash handed to
33
+ * `@stellar/stellar-sdk`'s StrKey decoder) would otherwise surface as
34
+ * "[object Object]" in the MCP transport. The envelope converts any
35
+ * non-ToolResponse throw into a structured `RECORDING_FAILED` ToolError so
36
+ * the agent always sees a machine-readable error code. */
37
+ export async function runRecordTransaction(raw) {
38
+ const parsed = RecordTransactionInputSchema.safeParse(raw);
39
+ if (!parsed.success) {
40
+ return {
41
+ ok: false,
42
+ error: validationError('record_transaction', parsed.error.issues),
43
+ };
44
+ }
45
+ const input = parsed.data;
46
+ // Strip the wire-only `confidenceOverride` and pass the rest straight through.
47
+ const coreInput = {
48
+ network: input.network,
49
+ ...(input.hash !== undefined ? { hash: input.hash } : {}),
50
+ ...(input.xdr !== undefined ? { xdr: input.xdr } : {}),
51
+ ...(input.confidenceOverride !== undefined
52
+ ? { confidenceOverride: input.confidenceOverride }
53
+ : {}),
54
+ };
55
+ try {
56
+ return await recordTransaction(coreInput);
57
+ }
58
+ catch (e) {
59
+ return {
60
+ ok: false,
61
+ error: caughtError('record_transaction', 'RECORDING_FAILED', e),
62
+ };
63
+ }
64
+ }
65
+ /** `synthesize_policy` body - discriminated union on `source`:
66
+ * - `mandate` -> synthesizeFromMandate
67
+ * - `recording` -> synthesizeFromRecording
68
+ * Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
69
+ * letting the agent pick the deterministic or the inferred path. The CLI
70
+ * routes the same way.
71
+ *
72
+ * Item 6: same try/catch envelope as `runRecordTransaction`. The core
73
+ * `synthesizeFromRecording` already converts ToolError-shaped throws to
74
+ * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
75
+ * XDR decode error in the adapter) would otherwise surface as
76
+ * "[object Object]" in the MCP transport. */
77
+ export async function runSynthesizePolicy(raw) {
78
+ const parsed = SynthesizePolicyInputSchema.safeParse(raw);
79
+ if (!parsed.success) {
80
+ return {
81
+ ok: false,
82
+ error: validationError('synthesize_policy', parsed.error.issues),
83
+ };
84
+ }
85
+ const input = parsed.data;
86
+ const ozConfig = resolveOzConfig(input);
87
+ try {
88
+ if (input.source === 'mandate') {
89
+ // Zod's optional fields widen to `T | undefined`, which the core's
90
+ // exact-optional MandateSpec rejects; the schema already validated the
91
+ // shape, so assert it (same pattern as the recordedTx cast below).
92
+ return await synthesizeFromMandate(input.mandate, ozConfig);
93
+ }
94
+ // recording source
95
+ const recorded = input.recordedTx;
96
+ return await synthesizeFromRecording(recorded, {
97
+ network: input.network,
98
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
99
+ ...(input.confidenceOverride !== undefined
100
+ ? { confidenceOverride: input.confidenceOverride }
101
+ : {}),
102
+ ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
103
+ }, ozConfig);
104
+ }
105
+ catch (e) {
106
+ return {
107
+ ok: false,
108
+ error: caughtError('synthesize_policy', 'SYNTHESIS_ERROR', e),
109
+ };
110
+ }
111
+ }
112
+ function resolveOzConfig(input) {
113
+ if (input.ozConfig)
114
+ return input.ozConfig;
115
+ // The mandate path is network-agnostic; fall back to mainnet so the
116
+ // placeholder OZ instance addresses are deterministic.
117
+ return placeholderOzConfig('mainnet');
118
+ }
119
+ /** Build a canonical ToolError for a Zod validation failure. The remediation
120
+ * hint points the agent back at the right tool with an empty arg bag - the
121
+ * tool name IS the machine-readable hint. */
122
+ function validationError(toolName, issues) {
123
+ const code = toolName === 'record_transaction' ? 'RECORDING_FAILED' : 'SYNTHESIS_ERROR';
124
+ return {
125
+ code,
126
+ message: `${toolName}: invalid input: ${issues
127
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
128
+ .join('; ')}`,
129
+ severity: 'error',
130
+ retryable: false,
131
+ remediation: {
132
+ toolCall: { name: toolName, args: {} },
133
+ },
134
+ };
135
+ }
136
+ /** Build a canonical ToolError for a thrown exception caught by the tool
137
+ * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
138
+ * default, so we extract a string-friendly message and tag the original
139
+ * error in `details` for the agent to inspect. The `code` is the tool's
140
+ * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
141
+ * for `synthesize_policy`) so the agent dispatches on the same code the
142
+ * structured ToolError would carry.
143
+ *
144
+ * Exported as `_caughtError` (the leading underscore signals the test-only
145
+ * seam) so the suite in run/index.test.ts can drive the envelope path
146
+ * without standing up a full recordTransaction pipeline. */
147
+ export function caughtError(toolName, code, e) {
148
+ const message = describeThrown(e, toolName);
149
+ const details = { thrown: safeStringify(e) };
150
+ return {
151
+ code,
152
+ message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
153
+ severity: 'error',
154
+ retryable: false,
155
+ remediation: {
156
+ toolCall: { name: toolName, args: {} },
157
+ },
158
+ details,
159
+ };
160
+ }
161
+ /** Build a human-readable message for an unknown caught value. Order matters:
162
+ * 1. `Error` instances use `.message` (or fallback to the class name).
163
+ * 2. Native strings pass through verbatim.
164
+ * 3. Objects with a string `message` field use that field (mirrors the
165
+ * shape thrown by some SDKs that package errors as plain objects).
166
+ * 4. Anything else falls back to a JSON-shaped summary, never to
167
+ * "[object Object]".
168
+ * The full payload is also captured in `details.thrown` via
169
+ * `safeStringify` so the agent can inspect the original value without
170
+ * risking an infinite loop on circular refs. */
171
+ function describeThrown(e, toolName) {
172
+ if (e instanceof Error) {
173
+ return e.message || `${toolName}: ${e.name || 'Error'}`;
174
+ }
175
+ if (typeof e === 'string') {
176
+ return e;
177
+ }
178
+ if (e !== null && typeof e === 'object') {
179
+ const obj = e;
180
+ const m = obj.message;
181
+ if (typeof m === 'string' && m.length > 0) {
182
+ return truncate(m);
183
+ }
184
+ return truncate(safeStringify(e));
185
+ }
186
+ return `${toolName}: caught non-Error throw of type ${typeof e}`;
187
+ }
188
+ /** Hard cap on the human-readable message embedded in the ToolError. The full
189
+ * payload is still preserved in `details.thrown` so the agent can inspect
190
+ * it - the truncation is only to keep the top-level message small enough to
191
+ * fit comfortably in transport logs. */
192
+ const MAX_MESSAGE_LEN = 512;
193
+ /** JSON.stringify that survives circular refs and very large payloads. The
194
+ * output is itself bounded by the same `MAX_DETAILS_LEN` so a thrown object
195
+ * with megabytes of buffer data cannot bloat the WHOLE envelope. */
196
+ const MAX_DETAILS_LEN = 4096;
197
+ function safeStringify(v) {
198
+ const seen = new WeakSet();
199
+ const json = JSON.stringify(v, (_k, value) => {
200
+ if (typeof value === 'bigint')
201
+ return value.toString();
202
+ if (typeof value === 'function')
203
+ return `[function ${value.name || 'anonymous'}]`;
204
+ if (value instanceof Error) {
205
+ return { name: value.name, message: value.message, stack: value.stack };
206
+ }
207
+ if (value !== null && typeof value === 'object') {
208
+ if (seen.has(value))
209
+ return '[Circular]';
210
+ seen.add(value);
211
+ }
212
+ return value;
213
+ }, 2);
214
+ if (json === undefined)
215
+ return '<unserializable>';
216
+ return truncate(json, MAX_DETAILS_LEN);
217
+ }
218
+ function truncate(s, cap = MAX_MESSAGE_LEN) {
219
+ if (s.length <= cap)
220
+ return s;
221
+ return `${s.slice(0, cap)}\n…[truncated ${s.length - cap} chars]`;
222
+ }