@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
@@ -18,13 +18,14 @@
18
18
 
19
19
  import { Address, StrKey, xdr } from '@stellar/stellar-sdk'
20
20
  import { identifyProtocol } from '../registry/identify.ts'
21
- import type {
22
- ContractInvocation,
23
- Network,
24
- OnChainEvent,
25
- ParseConfidence,
26
- ScVal,
27
- TokenMovement,
21
+ import {
22
+ type ContractInvocation,
23
+ MAX_SCVAL_DEPTH,
24
+ type Network,
25
+ type OnChainEvent,
26
+ type ParseConfidence,
27
+ type ScVal,
28
+ type TokenMovement,
28
29
  } from '../types.ts'
29
30
 
30
31
  /** Result of decoding an envelope XDR plus the surrounding metadata from
@@ -334,11 +335,15 @@ function symbolValueFromBufferOrString(s: string | Buffer): string {
334
335
  /** Map an arbitrary ScVal into the normalised `ScVal` subset defined in
335
336
  * types.ts. Anything outside the subset is wrapped as `{type:'other', value}`
336
337
  * AND recorded in `opaqueScVals` so the freshness module can reduce
337
- * parseConfidence. */
338
+ * parseConfidence. Recursion is bounded by `MAX_SCVAL_DEPTH` so a
339
+ * hand-crafted nested-vec payload cannot RangeError the JS stack; the
340
+ * over-depth branch is collapsed to a single opaque `depth-exceeded` token
341
+ * (the recorder's parseConfidence gate then fails the recording closed). */
338
342
  export function scValToSubset(
339
343
  val: xdr.ScVal,
340
344
  path: string,
341
- opaqueScVals: ParseConfidence['opaqueScVals']
345
+ opaqueScVals: ParseConfidence['opaqueScVals'],
346
+ depth = 0
342
347
  ): ScVal {
343
348
  const kind = val.switch().name
344
349
  switch (kind) {
@@ -364,14 +369,40 @@ export function scValToSubset(
364
369
  return { type: 'symbol', value: val.sym().toString() }
365
370
  }
366
371
  case 'scvVec': {
372
+ if (depth >= MAX_SCVAL_DEPTH) {
373
+ // Over-depth branch: collapse to a single opaque terminal so the
374
+ // downstream parseConfidence gate sees the fail-closed freshness hit.
375
+ opaqueScVals.push({ path, type: 'depth-exceeded' })
376
+ return { type: 'other', value: 'depth-exceeded' }
377
+ }
367
378
  const arr = (val.vec() ?? []) as xdr.ScVal[]
368
379
  return {
369
380
  type: 'vec',
370
381
  value: arr.map((v: xdr.ScVal, i: number) =>
371
- scValToSubset(v, `${path}[${i}]`, opaqueScVals)
382
+ scValToSubset(v, `${path}[${i}]`, opaqueScVals, depth + 1)
372
383
  ),
373
384
  }
374
385
  }
386
+ case 'scvMap': {
387
+ if (depth >= MAX_SCVAL_DEPTH) {
388
+ opaqueScVals.push({ path, type: 'depth-exceeded' })
389
+ return { type: 'other', value: 'depth-exceeded' }
390
+ }
391
+ // Map<Symbol, ScVal> is the canonical data shape for SAC/SEP-41
392
+ // transfer/mint/burn events on Stellar mainnet (verified empirically
393
+ // against txs 112d2392..., eb39f493..., 50d36f5c...). The entry key is
394
+ // carried forward so readAmount can route to the conventional field
395
+ // name without re-decoding the full XDR. Non-symbol / non-string keys
396
+ // are stringified via the same key-to-string rule used for topics.
397
+ const entries = (val.map() ?? []) as xdr.ScMapEntry[]
398
+ return {
399
+ type: 'map',
400
+ value: entries.map((entry, i) => ({
401
+ key: mapKeyToString(entry.key(), `${path}.key[${i}]`, opaqueScVals),
402
+ val: scValToSubset(entry.val(), `${path}.val[${i}]`, opaqueScVals, depth + 1),
403
+ })),
404
+ }
405
+ }
375
406
  case 'scvBytes': {
376
407
  return { type: 'bytes', value: Buffer.from(val.bytes() as Uint8Array).toString('hex') }
377
408
  }
@@ -458,6 +489,28 @@ export function scValToTopicString(val: xdr.ScVal): string {
458
489
  return `<${kind}>`
459
490
  }
460
491
 
492
+ /** Convert a Map<Symbol, ...> key ScVal to the string used in the normalised
493
+ * ScVal map representation. Uses the same string-form rule as
494
+ * `scValToTopicString` so SAC/SEP-41 event Map keys ("amount", "to_muxed_id")
495
+ * resolve to the same string in both the topic path and the map-entry path.
496
+ * Non-symbol / non-string keys record an opaque diagnostic and return a
497
+ * placeholder so the Map entry is still carried forward. */
498
+ function mapKeyToString(
499
+ val: xdr.ScVal,
500
+ path: string,
501
+ opaqueScVals: ParseConfidence['opaqueScVals']
502
+ ): string {
503
+ const kind = val.switch().name
504
+ if (kind === 'scvSymbol') return val.sym().toString()
505
+ if (kind === 'scvString') return val.str().toString()
506
+ if (kind === 'scvU64') return u64PartsToBigInt(val.u64()).toString()
507
+ if (kind === 'scvU32') return val.u32().toString()
508
+ if (kind === 'scvI128') return i128PartsToBigInt(val.i128()).toString()
509
+ if (kind === 'scvBytes') return Buffer.from(val.bytes() as Uint8Array).toString('hex')
510
+ opaqueScVals.push({ path, type: `unsupported-map-key:${kind}` })
511
+ return `<${kind}>`
512
+ }
513
+
461
514
  /** Build the OnChainEvent[] view from raw contract events. Diagnostic-only:
462
515
  * captures the topics + data + emitter contract for the downstream
463
516
  * validator. */
@@ -49,8 +49,23 @@ export function isBelowThreshold(c: ParseConfidence): boolean {
49
49
  }
50
50
 
51
51
  /** Convenience: build the user-facing remediation question for an
52
- * under-confidence recording. */
52
+ * under-confidence recording.
53
+ *
54
+ * The remediation text branches on which diagnostic bucket is non-empty:
55
+ * - unknownContracts only -> user MUST supply an ABI (or re-capture
56
+ * against a known protocol version). The contract is real, the
57
+ * recorder just cannot decode it.
58
+ * - opaqueScVals only -> the recorder encountered a value shape it
59
+ * should support but did not decode. The user cannot supply an ABI
60
+ * to fix a decoder bug; the guidance explicitly says so and points at
61
+ * re-running after a tool upgrade / reporting the path.
62
+ * - both -> list both diagnostics AND chain the right remediation for
63
+ * each (the user supplies an ABI AND reports the decoder gap).
64
+ * - neither (the "denom === 0" path) -> unchanged from the pre-fix
65
+ * text; the user did not actually hit a code-level barrier. */
53
66
  export function buildLowConfidenceQuestion(c: ParseConfidence): string {
67
+ const hasUnknown = c.unknownContracts.length > 0
68
+ const hasOpaque = c.opaqueScVals.length > 0
54
69
  const reasons: string[] = []
55
70
  for (const u of c.unknownContracts) {
56
71
  reasons.push(`unknown contract ${u.contract} (${u.reason})`)
@@ -59,5 +74,22 @@ export function buildLowConfidenceQuestion(c: ParseConfidence): string {
59
74
  reasons.push(`opaque ScVal at ${o.path} (${o.type})`)
60
75
  }
61
76
  const why = reasons.length === 0 ? 'no diagnostic reason available' : reasons.join('; ')
62
- 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.`
77
+ const header = `Recording refused: parseConfidence ${c.overall.toFixed(3)} is below the threshold ${c.thresholdUsed.toFixed(3)}. Diagnostic: ${why}.`
78
+ // The two remediation branches are explicit so the user (or the agent)
79
+ // can dispatch on the verb. The "unknown contract" branch is the only
80
+ // one that asks for an ABI; the "opaque ScVal" branch asks the user to
81
+ // report the path and re-run after a tool upgrade.
82
+ if (hasUnknown && hasOpaque) {
83
+ return (
84
+ `${header} Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version; ` +
85
+ `also file the opaque ScVal path against the recorder (decoder limitation) and re-run record_transaction after a tool upgrade.`
86
+ )
87
+ }
88
+ if (hasUnknown) {
89
+ 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.`
90
+ }
91
+ if (hasOpaque) {
92
+ 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.`
93
+ }
94
+ return `${header} No actionable diagnostic was recorded; re-run record_transaction against a fresh fetch and inspect the events.`
63
95
  }
@@ -41,6 +41,9 @@ import { validateAgainstEvents } from './validate.ts'
41
41
  * - exactly one of `hash` / `xdr`
42
42
  * - `network` for hash mode (selects the public RPC)
43
43
  * - optional injected `fetcher` for tests + custom RPC endpoints
44
+ * - optional `crossNetworkFetcher` (item 2) for tests + offline use;
45
+ * production builds the cross-network probe from `createRpcServer` when
46
+ * this field is absent
44
47
  * - optional `confidenceOverride` to relax the gate (default 1.0)
45
48
  *
46
49
  * In xdr mode `network` is still required so the caller documents intent
@@ -51,6 +54,14 @@ export interface RecordInput {
51
54
  xdr?: string
52
55
  network: Network
53
56
  fetcher?: RpcFetcher
57
+ /** Test-only seam + explicit production override for the cross-network
58
+ * probe (item 2). When the primary fetcher returns null on hash mode,
59
+ * the recorder also tries this fetcher against the OTHER network. If it
60
+ * finds the hash there, the recorder returns a specific actionable
61
+ * error mentioning the actual network. When unset, the recorder builds
62
+ * this fetcher from `createRpcServer(otherNetwork)` so the help fires
63
+ * automatically; tests can pass a deterministic stub. */
64
+ crossNetworkFetcher?: RpcFetcher
54
65
  confidenceOverride?: number
55
66
  }
56
67
 
@@ -92,6 +103,25 @@ export async function recordTransaction(input: RecordInput): Promise<RecordResul
92
103
  if (!hash) return err('RECORDING_FAILED', 'hash required for on-chain mode', false)
93
104
  const fetched = await fetcher(hash)
94
105
  if (!fetched) {
106
+ // Item 2: cross-network sanity check. The default NOT_FOUND message
107
+ // ("transaction X not found on <network>") is not actionable when the
108
+ // user just used the wrong --network. Probe the OTHER network's
109
+ // fetcher before giving up: if the hash actually exists there, the
110
+ // user almost certainly passed the wrong network flag. Trade-off:
111
+ // one extra RPC round-trip ONLY on the NOT_FOUND path (the happy
112
+ // path is unchanged; NOT_FOUND is rare). The probe is auto-built
113
+ // from createRpcServer(otherNetwork) when the caller did not inject
114
+ // one; tests pass an explicit stub for offline determinism.
115
+ const otherNetwork: Network = input.network === 'mainnet' ? 'testnet' : 'mainnet'
116
+ const crossFetcher = input.crossNetworkFetcher ?? createRpcServer(otherNetwork)
117
+ const crossFetched = await crossFetcher(hash)
118
+ if (crossFetched) {
119
+ return err(
120
+ 'RECORDING_FAILED',
121
+ `transaction ${hash} not found on ${input.network}; it exists on ${otherNetwork}. Re-run with --network ${otherNetwork} (or the corresponding MCP / API field).`,
122
+ true
123
+ )
124
+ }
95
125
  return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}`, true)
96
126
  }
97
127
  const events = combineEvents(fetched.events)
@@ -184,6 +214,16 @@ function finish(
184
214
  unknownContracts: decoded.unknownContracts,
185
215
  opaqueScVals: decoded.opaqueScVals,
186
216
  })
217
+ // Item 1: surface the "zero contract invocations decoded" case explicitly.
218
+ // The math already pins overall = 1.0 via the `denom === 0` guard; the
219
+ // marker just makes the silence visible so a downstream synth can refuse
220
+ // the recording (a policy must scope to an authorized contract call)
221
+ // without inferring the situation from `invocations: []` next to a
222
+ // confidence of 1.0. Only set when zero invocations were actually decoded
223
+ // - the marker is a positive signal, not a default.
224
+ if (decoded.invocations.length === 0) {
225
+ confidence = { ...confidence, noInvocations: true }
226
+ }
187
227
  if (typeof confidenceOverride === 'number') {
188
228
  confidence = { ...confidence, thresholdUsed: confidenceOverride }
189
229
  }
@@ -87,20 +87,53 @@ function parseSingleEvent(evt: OnChainEvent): TokenMovement | null {
87
87
 
88
88
  /** Read an I128 amount from an event data ScVal. Supports:
89
89
  * - data is I128 directly
90
- * - data is a Vec whose [0] (or [1] for SAC transfer) is I128
91
- * - data is a Vec whose [0] is a Map { 'amount': I128 }
92
- * - data is U64 (fallback for amount fields encoded as u64) */
90
+ * - data is U64 (fallback for amount fields encoded as u64)
91
+ * - data is a Vec whose [0] (or [1] for SAC transfer) is I128 / U64
92
+ * - data is a Vec whose [0] is a Map { 'amount': I128 | U64 }
93
+ * - data is a Map whose entry with key "amount" is I128 / U64
94
+ *
95
+ * The Map shapes are the canonical data layout for SAC/SEP-41 `transfer`,
96
+ * `mint`, and `burn` events on Stellar mainnet, e.g. the USDC SAC and every
97
+ * Stellar Asset Contract. The key `amount` is the SEP-41-defined field name
98
+ * (verified empirically against txs 112d2392..., eb39f493..., 50d36f5c...
99
+ * where the data is `Map { amount: I128, to_muxed_id: ... }`). The other
100
+ * Map entries we observe in the wild (to_muxed_id, from_muxed_id) are
101
+ * muxed-address adjuncts that the recorder does not support as topic
102
+ * addresses; they are ignored here on purpose. A Map without an `amount`
103
+ * entry, or with an `amount` entry that is not I128/U64, returns null
104
+ * (fail-closed). */
93
105
  export function readAmount(data: OnChainEvent['data']): string | null {
94
106
  if (data.type === 'i128') return data.value
95
107
  if (data.type === 'u64') return data.value
108
+ if (data.type === 'map') return readAmountFromMap(data.value)
96
109
  if (data.type !== 'vec') return null
97
- const v = data.value
98
- // SAC transfer event data shape (some clients): Vec<Address, I128>
99
- for (const item of v) {
110
+ // Vec shape: walk the elements and accept the first I128/U64 OR a Map whose
111
+ // 'amount' entry is I128/U64. SAC transfer event data is sometimes
112
+ // Vec<Address, I128> and sometimes Vec<Map{amount: I128}, ...>; both reach
113
+ // the same code path below.
114
+ for (const item of data.value) {
100
115
  if (item.type === 'i128') return item.value
101
116
  if (item.type === 'u64') return item.value
117
+ if (item.type === 'map') {
118
+ const fromMap = readAmountFromMap(item.value)
119
+ if (fromMap !== null) return fromMap
120
+ }
121
+ }
122
+ return null
123
+ }
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(
129
+ entries: Array<{ key: string; val: OnChainEvent['data'] }>
130
+ ): string | null {
131
+ for (const entry of entries) {
132
+ if (entry.key !== 'amount') continue
133
+ if (entry.val.type === 'i128') return entry.val.value
134
+ if (entry.val.type === 'u64') return entry.val.value
135
+ return null
102
136
  }
103
- // Some flavours wrap in a Map. We expose Map entries as other; bail.
104
137
  return null
105
138
  }
106
139
 
@@ -0,0 +1,277 @@
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
+
20
+ import {
21
+ type ErrorCode,
22
+ type MandateSpec,
23
+ type OzAdapterConfig,
24
+ type ProposedPolicy,
25
+ placeholderOzConfig,
26
+ type RecordedTransaction,
27
+ recordTransaction,
28
+ type SynthesizeFromRecordingOptions,
29
+ synthesizeFromMandate,
30
+ synthesizeFromRecording,
31
+ type ToolError,
32
+ type ToolResponse,
33
+ } from '../index.ts'
34
+ import {
35
+ type RecordTransactionInput,
36
+ RecordTransactionInputSchema,
37
+ type SynthesizePolicyInput,
38
+ SynthesizePolicyInputSchema,
39
+ } from './schemas.ts'
40
+
41
+ export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts'
42
+ // Re-export the underlying Zod schemas so the MCP package (and any other
43
+ // downstream consumer) can import the canonical input shapes from the same
44
+ // module that owns the tool-body glue. The strict schemas are the source of
45
+ // truth - MCP tool shapes are derived from them.
46
+ export {
47
+ ComposeUserResponsesSchema,
48
+ InterpreterOptionsSchema,
49
+ MandateSpecSchema,
50
+ NetworkSchema,
51
+ OzAdapterConfigSchema,
52
+ RecordedTransactionSchema,
53
+ RecordTransactionInputSchema,
54
+ SynthesizePolicyInputSchema,
55
+ ToolErrorSchema,
56
+ } from './schemas.ts'
57
+
58
+ export type RunRecordTransactionInput = RecordTransactionInput
59
+
60
+ export type RunSynthesizePolicyInput = SynthesizePolicyInput
61
+
62
+ /** `record_transaction` body - wraps `recordTransaction`. The tool input
63
+ * matches the core RecordInput minus the injected `fetcher` (the transport
64
+ * layer does not own the RPC). Returns the core ToolResponse unchanged.
65
+ *
66
+ * Item 6: a try/catch envelope wraps the core call. The core's own
67
+ * `recordTransaction` already returns `{ok:false, error}` for Zod + decoder
68
+ * failures, but a throw from the SDK (e.g. a malformed hash handed to
69
+ * `@stellar/stellar-sdk`'s StrKey decoder) would otherwise surface as
70
+ * "[object Object]" in the MCP transport. The envelope converts any
71
+ * non-ToolResponse throw into a structured `RECORDING_FAILED` ToolError so
72
+ * the agent always sees a machine-readable error code. */
73
+ export async function runRecordTransaction(
74
+ raw: unknown
75
+ ): Promise<ToolResponse<RecordedTransaction>> {
76
+ const parsed = RecordTransactionInputSchema.safeParse(raw)
77
+ if (!parsed.success) {
78
+ return {
79
+ ok: false,
80
+ error: validationError('record_transaction', parsed.error.issues),
81
+ }
82
+ }
83
+ const input: RecordTransactionInput = parsed.data
84
+ // Strip the wire-only `confidenceOverride` and pass the rest straight through.
85
+ const coreInput = {
86
+ network: input.network,
87
+ ...(input.hash !== undefined ? { hash: input.hash } : {}),
88
+ ...(input.xdr !== undefined ? { xdr: input.xdr } : {}),
89
+ ...(input.confidenceOverride !== undefined
90
+ ? { confidenceOverride: input.confidenceOverride }
91
+ : {}),
92
+ }
93
+ try {
94
+ return await recordTransaction(coreInput)
95
+ } catch (e) {
96
+ return {
97
+ ok: false,
98
+ error: caughtError('record_transaction', 'RECORDING_FAILED', e),
99
+ }
100
+ }
101
+ }
102
+
103
+ /** `synthesize_policy` body - discriminated union on `source`:
104
+ * - `mandate` -> synthesizeFromMandate
105
+ * - `recording` -> synthesizeFromRecording
106
+ * Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
107
+ * letting the agent pick the deterministic or the inferred path. The CLI
108
+ * routes the same way.
109
+ *
110
+ * Item 6: same try/catch envelope as `runRecordTransaction`. The core
111
+ * `synthesizeFromRecording` already converts ToolError-shaped throws to
112
+ * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
113
+ * XDR decode error in the adapter) would otherwise surface as
114
+ * "[object Object]" in the MCP transport. */
115
+ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>> {
116
+ const parsed = SynthesizePolicyInputSchema.safeParse(raw)
117
+ if (!parsed.success) {
118
+ return {
119
+ ok: false,
120
+ error: validationError('synthesize_policy', parsed.error.issues),
121
+ }
122
+ }
123
+ const input = parsed.data
124
+ const ozConfig: OzAdapterConfig = resolveOzConfig(input)
125
+
126
+ try {
127
+ if (input.source === 'mandate') {
128
+ // Zod's optional fields widen to `T | undefined`, which the core's
129
+ // exact-optional MandateSpec rejects; the schema already validated the
130
+ // shape, so assert it (same pattern as the recordedTx cast below).
131
+ return await synthesizeFromMandate(input.mandate as MandateSpec, ozConfig)
132
+ }
133
+ // recording source
134
+ const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
135
+ return await synthesizeFromRecording(
136
+ recorded,
137
+ {
138
+ network: input.network,
139
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
140
+ ...(input.confidenceOverride !== undefined
141
+ ? { confidenceOverride: input.confidenceOverride }
142
+ : {}),
143
+ ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
144
+ } as SynthesizeFromRecordingOptions,
145
+ ozConfig
146
+ )
147
+ } catch (e) {
148
+ return {
149
+ ok: false,
150
+ error: caughtError('synthesize_policy', 'SYNTHESIS_ERROR', e),
151
+ }
152
+ }
153
+ }
154
+
155
+ function resolveOzConfig(input: SynthesizePolicyInput): OzAdapterConfig {
156
+ if (input.ozConfig) return input.ozConfig
157
+ // The mandate path is network-agnostic; fall back to mainnet so the
158
+ // placeholder OZ instance addresses are deterministic.
159
+ return placeholderOzConfig('mainnet')
160
+ }
161
+
162
+ /** Build a canonical ToolError for a Zod validation failure. The remediation
163
+ * hint points the agent back at the right tool with an empty arg bag - the
164
+ * tool name IS the machine-readable hint. */
165
+ function validationError(
166
+ toolName: 'record_transaction' | 'synthesize_policy',
167
+ issues: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>
168
+ ): ToolError {
169
+ const code: ErrorCode = toolName === 'record_transaction' ? 'RECORDING_FAILED' : 'SYNTHESIS_ERROR'
170
+ return {
171
+ code,
172
+ message: `${toolName}: invalid input: ${issues
173
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
174
+ .join('; ')}`,
175
+ severity: 'error',
176
+ retryable: false,
177
+ remediation: {
178
+ toolCall: { name: toolName, args: {} },
179
+ },
180
+ }
181
+ }
182
+
183
+ /** Build a canonical ToolError for a thrown exception caught by the tool
184
+ * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
185
+ * default, so we extract a string-friendly message and tag the original
186
+ * error in `details` for the agent to inspect. The `code` is the tool's
187
+ * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
188
+ * for `synthesize_policy`) so the agent dispatches on the same code the
189
+ * structured ToolError would carry.
190
+ *
191
+ * Exported as `_caughtError` (the leading underscore signals the test-only
192
+ * seam) so the suite in run/index.test.ts can drive the envelope path
193
+ * without standing up a full recordTransaction pipeline. */
194
+ export function caughtError(
195
+ toolName: 'record_transaction' | 'synthesize_policy',
196
+ code: ErrorCode,
197
+ e: unknown
198
+ ): ToolError {
199
+ const message = describeThrown(e, toolName)
200
+ const details = { thrown: safeStringify(e) }
201
+ return {
202
+ code,
203
+ message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
204
+ severity: 'error',
205
+ retryable: false,
206
+ remediation: {
207
+ toolCall: { name: toolName, args: {} },
208
+ },
209
+ details,
210
+ }
211
+ }
212
+
213
+ /** Build a human-readable message for an unknown caught value. Order matters:
214
+ * 1. `Error` instances use `.message` (or fallback to the class name).
215
+ * 2. Native strings pass through verbatim.
216
+ * 3. Objects with a string `message` field use that field (mirrors the
217
+ * shape thrown by some SDKs that package errors as plain objects).
218
+ * 4. Anything else falls back to a JSON-shaped summary, never to
219
+ * "[object Object]".
220
+ * The full payload is also captured in `details.thrown` via
221
+ * `safeStringify` so the agent can inspect the original value without
222
+ * risking an infinite loop on circular refs. */
223
+ function describeThrown(e: unknown, toolName: string): string {
224
+ if (e instanceof Error) {
225
+ return e.message || `${toolName}: ${e.name || 'Error'}`
226
+ }
227
+ if (typeof e === 'string') {
228
+ return e
229
+ }
230
+ if (e !== null && typeof e === 'object') {
231
+ const obj = e as Record<string, unknown>
232
+ const m = obj.message
233
+ if (typeof m === 'string' && m.length > 0) {
234
+ return truncate(m)
235
+ }
236
+ return truncate(safeStringify(e))
237
+ }
238
+ return `${toolName}: caught non-Error throw of type ${typeof e}`
239
+ }
240
+
241
+ /** Hard cap on the human-readable message embedded in the ToolError. The full
242
+ * payload is still preserved in `details.thrown` so the agent can inspect
243
+ * it - the truncation is only to keep the top-level message small enough to
244
+ * fit comfortably in transport logs. */
245
+ const MAX_MESSAGE_LEN = 512
246
+
247
+ /** JSON.stringify that survives circular refs and very large payloads. The
248
+ * output is itself bounded by the same `MAX_DETAILS_LEN` so a thrown object
249
+ * with megabytes of buffer data cannot bloat the WHOLE envelope. */
250
+ const MAX_DETAILS_LEN = 4096
251
+
252
+ function safeStringify(v: unknown): string {
253
+ const seen = new WeakSet<object>()
254
+ const json = JSON.stringify(
255
+ v,
256
+ (_k, value) => {
257
+ if (typeof value === 'bigint') return value.toString()
258
+ if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`
259
+ if (value instanceof Error) {
260
+ return { name: value.name, message: value.message, stack: value.stack }
261
+ }
262
+ if (value !== null && typeof value === 'object') {
263
+ if (seen.has(value)) return '[Circular]'
264
+ seen.add(value)
265
+ }
266
+ return value
267
+ },
268
+ 2
269
+ )
270
+ if (json === undefined) return '<unserializable>'
271
+ return truncate(json, MAX_DETAILS_LEN)
272
+ }
273
+
274
+ function truncate(s: string, cap = MAX_MESSAGE_LEN): string {
275
+ if (s.length <= cap) return s
276
+ return `${s.slice(0, cap)}\n…[truncated ${s.length - cap} chars]`
277
+ }