@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
@@ -383,6 +383,26 @@ export function scValToSubset(
383
383
  ),
384
384
  }
385
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
+ }
386
406
  case 'scvBytes': {
387
407
  return { type: 'bytes', value: Buffer.from(val.bytes() as Uint8Array).toString('hex') }
388
408
  }
@@ -469,6 +489,28 @@ export function scValToTopicString(val: xdr.ScVal): string {
469
489
  return `<${kind}>`
470
490
  }
471
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
+
472
514
  /** Build the OnChainEvent[] view from raw contract events. Diagnostic-only:
473
515
  * captures the topics + data + emitter contract for the downstream
474
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
 
package/src/run/index.ts CHANGED
@@ -181,25 +181,23 @@ function validationError(
181
181
  }
182
182
 
183
183
  /** Build a canonical ToolError for a thrown exception caught by the tool
184
- * envelope. We refuse to surface the raw exception's text (the MCP SDK
185
- * stringifies thrown objects as "[object Object]"), so we extract a
186
- * string-friendly message and tag the original error in `details` for the
187
- * agent to inspect. The `code` is the tool's domain code (RECORDING_FAILED
188
- * for `record_transaction`, SYNTHESIS_ERROR for `synthesize_policy`) so the
189
- * agent dispatches on the same code the structured ToolError would carry. */
190
- function caughtError(
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(
191
195
  toolName: 'record_transaction' | 'synthesize_policy',
192
196
  code: ErrorCode,
193
197
  e: unknown
194
198
  ): ToolError {
195
- let message: string
196
- if (e instanceof Error) {
197
- message = e.message || `${toolName}: unknown error`
198
- } else if (typeof e === 'string') {
199
- message = e
200
- } else {
201
- message = `${toolName}: caught non-Error throw of type ${typeof e}`
202
- }
199
+ const message = describeThrown(e, toolName)
200
+ const details = { thrown: safeStringify(e) }
203
201
  return {
204
202
  code,
205
203
  message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
@@ -208,6 +206,72 @@ function caughtError(
208
206
  remediation: {
209
207
  toolCall: { name: toolName, args: {} },
210
208
  },
211
- details: { thrown: String(e) },
209
+ details,
212
210
  }
213
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
+ }
@@ -16,6 +16,7 @@
16
16
  // them here so it can build the same args envelope the MCP transport builds.
17
17
 
18
18
  import { z } from 'zod'
19
+ import { isStellarAddress } from '../synth/address.ts'
19
20
 
20
21
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
21
22
  * installed on-chain, so reject it at the boundary (fail-closed). */
@@ -41,6 +42,13 @@ export const ScValSchema: z.ZodType<unknown> = z.lazy(() =>
41
42
  z.object({ type: z.literal('u32'), value: z.string().regex(/^[0-9]+$/) }),
42
43
  z.object({ type: z.literal('symbol'), value: z.string() }),
43
44
  z.object({ type: z.literal('vec'), value: z.array(ScValSchema) }),
45
+ // Map mirrors the core ScVal. Real Blend `submit` calls carry a vec of maps
46
+ // as the request argument, so omitting it here made the synthesizer reject a
47
+ // recording its own recorder had just produced at full confidence.
48
+ z.object({
49
+ type: z.literal('map'),
50
+ value: z.array(z.object({ key: z.string(), val: ScValSchema })),
51
+ }),
44
52
  z.object({ type: z.literal('bytes'), value: z.string() }),
45
53
  z.object({ type: z.literal('other'), value: z.string() }),
46
54
  ])
@@ -142,6 +150,13 @@ export const ComposeUserResponsesSchema = z
142
150
  .regex(/^[0-9]+$/)
143
151
  .optional(),
144
152
  invocationLimit: z.number().int().positive().optional(),
153
+ // Swap recipient allowlist (SoroSwap call_arg[3]). Each entry must be a
154
+ // Stellar address (G... wallet or C... contract); supplying it REPLACES the
155
+ // default pin to the recorded recipient. Validated with the shared StrKey
156
+ // helper (no hand-rolled regex).
157
+ swapRecipientAllowlist: z
158
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
159
+ .optional(),
145
160
  })
146
161
  .passthrough()
147
162
 
@@ -0,0 +1,14 @@
1
+ // src/synth/address.ts - Stellar address validation shared by the CLI + the
2
+ // run-layer Zod schema.
3
+
4
+ import { StrKey } from '@stellar/stellar-sdk'
5
+
6
+ /** True when `s` is a valid Stellar address strkey - either an Ed25519 public
7
+ * key (`G...`) or a contract address (`C...`). Backed by the SDK's `StrKey`
8
+ * decoder (the same one the recorder uses in `record/decode.ts`); no
9
+ * hand-rolled regex. Used to validate a caller-supplied swap recipient
10
+ * allowlist, whose entries may be either a wallet (`G...`) or a contract
11
+ * (`C...`). */
12
+ export function isStellarAddress(s: string): boolean {
13
+ return StrKey.isValidEd25519PublicKey(s) || StrKey.isValidContract(s)
14
+ }
@@ -81,9 +81,10 @@ export interface ComposeUserResponses {
81
81
  * entries on the same asset emit multiple leaves. */
82
82
  oraclePriceBound?: OraclePriceBound[]
83
83
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
84
- * swap_exact_tokens_for_tokens). Absent -> RECIPIENT_ALLOWLIST_EMPTY
85
- * ambiguity (the caller is prompted; proceeding without an allowlist leaves
86
- * the recipient unconstrained). */
84
+ * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
85
+ * pin. Absent -> the recipient is pinned to the recorded value (mirroring
86
+ * SEP-41) and RECIPIENT_ALLOWLIST_EMPTY is surfaced as informational, never
87
+ * a silent free pass. */
87
88
  swapRecipientAllowlist?: string[]
88
89
  }
89
90
 
@@ -416,11 +417,16 @@ function appendProtocolSpecificConstraints(
416
417
 
417
418
  // Swap recipient (call_arg[3]): when the caller supplies
418
419
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
419
- // arg. When absent, surface RECIPIENT_ALLOWLIST_EMPTY so the caller is
420
- // prompted; proceeding without an allowlist leaves the recipient
421
- // unconstrained in the predicate. The ambiguity is only surfaced when
422
- // the interpreter is enabled - if it isn't, the swap recipient is just
423
- // ignored (today's behaviour).
420
+ // arg. When absent, PIN the recipient to the recorded value - mirroring the
421
+ // SEP-41 `to` arg above: the recorded flow went to exactly one recipient, so
422
+ // pinning it is the minimal policy that permits exactly that flow. Leaving
423
+ // it unconstrained would permit an arbitrary recipient (an evil twin with
424
+ // call_arg[3] = attacker_wallet). RECIPIENT_ALLOWLIST_EMPTY is still
425
+ // surfaced, but as INFORMATIONAL (the recipient was pinned; here is how to
426
+ // widen it), never as a silent free pass. The recipient is only enforceable
427
+ // via the interpreter predicate, so the pin (and the ambiguity) apply only
428
+ // when the interpreter is enabled - otherwise the swap recipient is ignored
429
+ // (today's behaviour, matching the other SoroSwap constraints).
424
430
  const recipientArg = topLevel.args[3]
425
431
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
426
432
  const cond: IRCondition = {
@@ -434,9 +440,14 @@ function appendProtocolSpecificConstraints(
434
440
  ozConstraints.push(cond)
435
441
  }
436
442
  } else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
443
+ interpreterConstraints.push({
444
+ op: 'in',
445
+ selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
446
+ values: [recipientArg.value],
447
+ })
437
448
  ambiguities.push({
438
449
  code: 'RECIPIENT_ALLOWLIST_EMPTY',
439
- question: `Recording shows the swap sending to ${recipientArg.value}. What recipient allowlist should the interpreter predicate bind? (omit to leave recipient unconstrained)`,
450
+ question: `No recipient allowlist supplied; the swap recipient (call_arg[3]) was pinned to the recorded value ${recipientArg.value}. To permit additional recipients, supply an allowlist (CLI: --recipient <C...|G...>, repeatable; MCP: userResponses.swapRecipientAllowlist) - it REPLACES this default pin.`,
440
451
  })
441
452
  }
442
453
  }
@@ -45,8 +45,45 @@ const ADJACENT_ASSETS = [
45
45
  'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
46
46
  ] as const
47
47
 
48
- /** Build deterministic model-evaluated alternatives without mutating the intended call. */
49
- export function generateCases(predicate: PredicateNode, permitCtx: EvalContext): GeneratedCases {
48
+ // Phase 1 property-harness mutation dimensions excluded from the synth pipeline's
49
+ // self-verify call so existing fixtures still emit policies. The harness tests
50
+ // them as FINDINGS against the already-emitted policy.
51
+ const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
52
+
53
+ // The 15 dimensions the synth pipeline uses for self-verify and minimise.
54
+ const ORIGINAL_DIMENSIONS: string[] = [
55
+ 'amount',
56
+ 'asset',
57
+ 'contract',
58
+ 'function',
59
+ 'timing',
60
+ 'time_window',
61
+ 'invocation_count',
62
+ 'arg_amount_bound',
63
+ 'arg_bound',
64
+ 'scope_contract_fn_arg',
65
+ 'oracle_stale',
66
+ 'oracle_missing',
67
+ 'oracle_deviation_exceeded',
68
+ 'oracle_paused',
69
+ 'soroswap_allowed_path',
70
+ ]
71
+
72
+ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS }
73
+
74
+ /** Build deterministic model-evaluated alternatives without mutating the intended call.
75
+ *
76
+ * @param predicate - the synthesized predicate
77
+ * @param permitCtx - EvalContext for the intended (permitted) call
78
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
79
+ * all known dimensions (including over-permissiveness mutations)
80
+ * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
81
+ * existing fixtures do not regress. */
82
+ export function generateCases(
83
+ predicate: PredicateNode,
84
+ permitCtx: EvalContext,
85
+ dimensions?: string[]
86
+ ): GeneratedCases {
50
87
  const facts = inspectPredicate(predicate)
51
88
  const denies: DenyCase[] = []
52
89
 
@@ -210,6 +247,33 @@ export function generateCases(predicate: PredicateNode, permitCtx: EvalContext):
210
247
  denies.push({ dimension: 'soroswap_allowed_path', ctx })
211
248
  }
212
249
 
250
+ // --- argument_reorder: swap first two address args ---
251
+ // Skipped when dimensions filter is active so the synth pipeline can emit a policy;
252
+ // the over-permissiveness harness then tests this mutation as a FINDING.
253
+ const constrainedArgIndices = new Set<number>()
254
+ for (const comparison of facts.comparisons) {
255
+ if (comparison.left.kind === 'call_arg') constrainedArgIndices.add(comparison.left.index)
256
+ }
257
+ for (const membership of facts.memberships) {
258
+ if (membership.needle.kind === 'call_arg') constrainedArgIndices.add(membership.needle.index)
259
+ }
260
+ const hasConstrainedArg = constrainedArgIndices.size > 0
261
+ if (
262
+ (!dimensions || dimensions.includes('argument_reorder')) &&
263
+ hasConstrainedArg &&
264
+ permitCtx.args.length >= 2 &&
265
+ permitCtx.args[0]?.type === 'address' &&
266
+ permitCtx.args[1]?.type === 'address'
267
+ ) {
268
+ const ctx = cloneContext(permitCtx)
269
+ // Guard above guarantees args[0] and args[1] exist and are address-typed.
270
+ const a0 = ctx.args[0] as ScVal
271
+ const a1 = ctx.args[1] as ScVal
272
+ ctx.args[0] = a1
273
+ ctx.args[1] = a0
274
+ denies.push({ dimension: 'argument_reorder', ctx })
275
+ }
276
+
213
277
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
214
278
  return { permit: cloneContext(permitCtx), denies }
215
279
  }
@@ -1,5 +1,9 @@
1
1
  // src/synth/index.ts - re-export the synthesizer front-ends.
2
2
 
3
+ // Exported because `swapRecipientAllowlist` is part of the public synthesis
4
+ // input: a caller assembling one needs the same validator the schema applies,
5
+ // and re-deriving it elsewhere means a second address check that can drift.
6
+ export { isStellarAddress } from './address.ts'
3
7
  export {
4
8
  type ComposeOptions,
5
9
  type ComposeResult,
@@ -4,7 +4,11 @@ import type { EvalContext } from './evaluate.ts'
4
4
  import { runHarness } from './harness.ts'
5
5
 
6
6
  /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
7
- export function minimize(predicate: PredicateNode, permitCtx: EvalContext): PredicateNode {
7
+ export function minimize(
8
+ predicate: PredicateNode,
9
+ permitCtx: EvalContext,
10
+ dimensions?: string[]
11
+ ): PredicateNode {
8
12
  if (predicate.op !== 'and') return predicate
9
13
 
10
14
  let children = [...predicate.children]
@@ -12,10 +16,10 @@ export function minimize(predicate: PredicateNode, permitCtx: EvalContext): Pred
12
16
 
13
17
  while (index < children.length) {
14
18
  const current: PredicateNode = { op: 'and', children }
15
- const currentCases = generateCases(current, permitCtx)
19
+ const currentCases = generateCases(current, permitCtx, dimensions)
16
20
  const candidateChildren = children.filter((_, childIndex) => childIndex !== index)
17
21
  const candidate: PredicateNode = { op: 'and', children: candidateChildren }
18
- const candidateCases = generateCases(candidate, permitCtx)
22
+ const candidateCases = generateCases(candidate, permitCtx, dimensions)
19
23
  const verificationCases = {
20
24
  permit: candidateCases.permit,
21
25
  denies: mergeDenyCases(currentCases.denies, candidateCases.denies),