@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
@@ -268,6 +268,26 @@ export function scValToSubset(val, path, opaqueScVals, depth = 0) {
268
268
  value: arr.map((v, i) => scValToSubset(v, `${path}[${i}]`, opaqueScVals, depth + 1)),
269
269
  };
270
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
+ })),
289
+ };
290
+ }
271
291
  case 'scvBytes': {
272
292
  return { type: 'bytes', value: Buffer.from(val.bytes()).toString('hex') };
273
293
  }
@@ -345,6 +365,29 @@ export function scValToTopicString(val) {
345
365
  return Buffer.from(val.bytes()).toString('hex');
346
366
  return `<${kind}>`;
347
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
+ }
348
391
  /** Build the OnChainEvent[] view from raw contract events. Diagnostic-only:
349
392
  * captures the topics + data + emitter contract for the downstream
350
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. */
@@ -1,4 +1,4 @@
1
- import { type ProposedPolicy, type RecordedTransaction, type ToolResponse } from '../index.ts';
1
+ import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
2
2
  import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
3
  export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
4
  export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
@@ -29,3 +29,15 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
29
29
  * XDR decode error in the adapter) would otherwise surface as
30
30
  * "[object Object]" in the MCP transport. */
31
31
  export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;
32
+ /** Build a canonical ToolError for a thrown exception caught by the tool
33
+ * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
34
+ * default, so we extract a string-friendly message and tag the original
35
+ * error in `details` for the agent to inspect. The `code` is the tool's
36
+ * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
37
+ * for `synthesize_policy`) so the agent dispatches on the same code the
38
+ * structured ToolError would carry.
39
+ *
40
+ * Exported as `_caughtError` (the leading underscore signals the test-only
41
+ * seam) so the suite in run/index.test.ts can drive the envelope path
42
+ * without standing up a full recordTransaction pipeline. */
43
+ export declare function caughtError(toolName: 'record_transaction' | 'synthesize_policy', code: ErrorCode, e: unknown): ToolError;
package/dist/run/index.js CHANGED
@@ -134,23 +134,19 @@ function validationError(toolName, issues) {
134
134
  };
135
135
  }
136
136
  /** Build a canonical ToolError for a thrown exception caught by the tool
137
- * envelope. We refuse to surface the raw exception's text (the MCP SDK
138
- * stringifies thrown objects as "[object Object]"), so we extract a
139
- * string-friendly message and tag the original error in `details` for the
140
- * agent to inspect. The `code` is the tool's domain code (RECORDING_FAILED
141
- * for `record_transaction`, SYNTHESIS_ERROR for `synthesize_policy`) so the
142
- * agent dispatches on the same code the structured ToolError would carry. */
143
- function caughtError(toolName, code, e) {
144
- let message;
145
- if (e instanceof Error) {
146
- message = e.message || `${toolName}: unknown error`;
147
- }
148
- else if (typeof e === 'string') {
149
- message = e;
150
- }
151
- else {
152
- message = `${toolName}: caught non-Error throw of type ${typeof e}`;
153
- }
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) };
154
150
  return {
155
151
  code,
156
152
  message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
@@ -159,6 +155,68 @@ function caughtError(toolName, code, e) {
159
155
  remediation: {
160
156
  toolCall: { name: toolName, args: {} },
161
157
  },
162
- details: { thrown: String(e) },
158
+ details,
163
159
  };
164
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
+ }
@@ -446,16 +446,19 @@ export declare const ComposeUserResponsesSchema: z.ZodObject<{
446
446
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
447
447
  limitAmount: z.ZodOptional<z.ZodString>;
448
448
  invocationLimit: z.ZodOptional<z.ZodNumber>;
449
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
449
450
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
450
451
  windowSeconds: z.ZodOptional<z.ZodNumber>;
451
452
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
452
453
  limitAmount: z.ZodOptional<z.ZodString>;
453
454
  invocationLimit: z.ZodOptional<z.ZodNumber>;
455
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
454
456
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
455
457
  windowSeconds: z.ZodOptional<z.ZodNumber>;
456
458
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
457
459
  limitAmount: z.ZodOptional<z.ZodString>;
458
460
  invocationLimit: z.ZodOptional<z.ZodNumber>;
461
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
459
462
  }, z.ZodTypeAny, "passthrough">>;
460
463
  /** OzAdapterConfig - the per-network OZ built-in instance addresses. */
461
464
  export declare const OzAdapterConfigSchema: z.ZodObject<{
@@ -1003,16 +1006,19 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1003
1006
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1004
1007
  limitAmount: z.ZodOptional<z.ZodString>;
1005
1008
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1009
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1006
1010
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
1007
1011
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1008
1012
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1009
1013
  limitAmount: z.ZodOptional<z.ZodString>;
1010
1014
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1015
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1011
1016
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
1012
1017
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1013
1018
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1014
1019
  limitAmount: z.ZodOptional<z.ZodString>;
1015
1020
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1021
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1016
1022
  }, z.ZodTypeAny, "passthrough">>>;
1017
1023
  confidenceOverride: z.ZodOptional<z.ZodObject<{
1018
1024
  threshold: z.ZodNumber;
@@ -1130,6 +1136,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1130
1136
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1131
1137
  limitAmount: z.ZodOptional<z.ZodString>;
1132
1138
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1139
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1133
1140
  }, z.ZodTypeAny, "passthrough"> | undefined;
1134
1141
  confidenceOverride?: {
1135
1142
  threshold: number;
@@ -1193,6 +1200,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1193
1200
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1194
1201
  limitAmount: z.ZodOptional<z.ZodString>;
1195
1202
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1203
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1196
1204
  }, z.ZodTypeAny, "passthrough"> | undefined;
1197
1205
  confidenceOverride?: {
1198
1206
  threshold: number;
@@ -1649,16 +1657,19 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1649
1657
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1650
1658
  limitAmount: z.ZodOptional<z.ZodString>;
1651
1659
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1660
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1652
1661
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
1653
1662
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1654
1663
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1655
1664
  limitAmount: z.ZodOptional<z.ZodString>;
1656
1665
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1666
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1657
1667
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
1658
1668
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1659
1669
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1660
1670
  limitAmount: z.ZodOptional<z.ZodString>;
1661
1671
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1672
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1662
1673
  }, z.ZodTypeAny, "passthrough">>>;
1663
1674
  confidenceOverride: z.ZodOptional<z.ZodObject<{
1664
1675
  threshold: z.ZodNumber;
@@ -1776,6 +1787,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1776
1787
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1777
1788
  limitAmount: z.ZodOptional<z.ZodString>;
1778
1789
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1790
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1779
1791
  }, z.ZodTypeAny, "passthrough"> | undefined;
1780
1792
  confidenceOverride?: {
1781
1793
  threshold: number;
@@ -1839,6 +1851,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1839
1851
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1840
1852
  limitAmount: z.ZodOptional<z.ZodString>;
1841
1853
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1854
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1842
1855
  }, z.ZodTypeAny, "passthrough"> | undefined;
1843
1856
  confidenceOverride?: {
1844
1857
  threshold: number;
@@ -15,6 +15,7 @@
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
16
16
  // them here so it can build the same args envelope the MCP transport builds.
17
17
  import { z } from 'zod';
18
+ import { isStellarAddress } from "../synth/address.js";
18
19
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
19
20
  * installed on-chain, so reject it at the boundary (fail-closed). */
20
21
  const U32_MAX = 4294967295;
@@ -35,6 +36,13 @@ export const ScValSchema = z.lazy(() => z.union([
35
36
  z.object({ type: z.literal('u32'), value: z.string().regex(/^[0-9]+$/) }),
36
37
  z.object({ type: z.literal('symbol'), value: z.string() }),
37
38
  z.object({ type: z.literal('vec'), value: z.array(ScValSchema) }),
39
+ // Map mirrors the core ScVal. Real Blend `submit` calls carry a vec of maps
40
+ // as the request argument, so omitting it here made the synthesizer reject a
41
+ // recording its own recorder had just produced at full confidence.
42
+ z.object({
43
+ type: z.literal('map'),
44
+ value: z.array(z.object({ key: z.string(), val: ScValSchema })),
45
+ }),
38
46
  z.object({ type: z.literal('bytes'), value: z.string() }),
39
47
  z.object({ type: z.literal('other'), value: z.string() }),
40
48
  ]));
@@ -126,6 +134,13 @@ export const ComposeUserResponsesSchema = z
126
134
  .regex(/^[0-9]+$/)
127
135
  .optional(),
128
136
  invocationLimit: z.number().int().positive().optional(),
137
+ // Swap recipient allowlist (SoroSwap call_arg[3]). Each entry must be a
138
+ // Stellar address (G... wallet or C... contract); supplying it REPLACES the
139
+ // default pin to the recorded recipient. Validated with the shared StrKey
140
+ // helper (no hand-rolled regex).
141
+ swapRecipientAllowlist: z
142
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
143
+ .optional(),
129
144
  })
130
145
  .passthrough();
131
146
  /** OzAdapterConfig - the per-network OZ built-in instance addresses. */
@@ -0,0 +1,7 @@
1
+ /** True when `s` is a valid Stellar address strkey - either an Ed25519 public
2
+ * key (`G...`) or a contract address (`C...`). Backed by the SDK's `StrKey`
3
+ * decoder (the same one the recorder uses in `record/decode.ts`); no
4
+ * hand-rolled regex. Used to validate a caller-supplied swap recipient
5
+ * allowlist, whose entries may be either a wallet (`G...`) or a contract
6
+ * (`C...`). */
7
+ export declare function isStellarAddress(s: string): boolean;
@@ -0,0 +1,12 @@
1
+ // src/synth/address.ts - Stellar address validation shared by the CLI + the
2
+ // run-layer Zod schema.
3
+ import { StrKey } from '@stellar/stellar-sdk';
4
+ /** True when `s` is a valid Stellar address strkey - either an Ed25519 public
5
+ * key (`G...`) or a contract address (`C...`). Backed by the SDK's `StrKey`
6
+ * decoder (the same one the recorder uses in `record/decode.ts`); no
7
+ * hand-rolled regex. Used to validate a caller-supplied swap recipient
8
+ * allowlist, whose entries may be either a wallet (`G...`) or a contract
9
+ * (`C...`). */
10
+ export function isStellarAddress(s) {
11
+ return StrKey.isValidEd25519PublicKey(s) || StrKey.isValidContract(s);
12
+ }
@@ -28,9 +28,10 @@ export interface ComposeUserResponses {
28
28
  * entries on the same asset emit multiple leaves. */
29
29
  oraclePriceBound?: OraclePriceBound[];
30
30
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
31
- * swap_exact_tokens_for_tokens). Absent -> RECIPIENT_ALLOWLIST_EMPTY
32
- * ambiguity (the caller is prompted; proceeding without an allowlist leaves
33
- * the recipient unconstrained). */
31
+ * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
32
+ * pin. Absent -> the recipient is pinned to the recorded value (mirroring
33
+ * SEP-41) and RECIPIENT_ALLOWLIST_EMPTY is surfaced as informational, never
34
+ * a silent free pass. */
34
35
  swapRecipientAllowlist?: string[];
35
36
  }
36
37
  /** Composition options. */
@@ -308,11 +308,16 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
308
308
  }
309
309
  // Swap recipient (call_arg[3]): when the caller supplies
310
310
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
311
- // arg. When absent, surface RECIPIENT_ALLOWLIST_EMPTY so the caller is
312
- // prompted; proceeding without an allowlist leaves the recipient
313
- // unconstrained in the predicate. The ambiguity is only surfaced when
314
- // the interpreter is enabled - if it isn't, the swap recipient is just
315
- // ignored (today's behaviour).
311
+ // arg. When absent, PIN the recipient to the recorded value - mirroring the
312
+ // SEP-41 `to` arg above: the recorded flow went to exactly one recipient, so
313
+ // pinning it is the minimal policy that permits exactly that flow. Leaving
314
+ // it unconstrained would permit an arbitrary recipient (an evil twin with
315
+ // call_arg[3] = attacker_wallet). RECIPIENT_ALLOWLIST_EMPTY is still
316
+ // surfaced, but as INFORMATIONAL (the recipient was pinned; here is how to
317
+ // widen it), never as a silent free pass. The recipient is only enforceable
318
+ // via the interpreter predicate, so the pin (and the ambiguity) apply only
319
+ // when the interpreter is enabled - otherwise the swap recipient is ignored
320
+ // (today's behaviour, matching the other SoroSwap constraints).
316
321
  const recipientArg = topLevel.args[3];
317
322
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
318
323
  const cond = {
@@ -328,9 +333,14 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
328
333
  }
329
334
  }
330
335
  else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
336
+ interpreterConstraints.push({
337
+ op: 'in',
338
+ selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
339
+ values: [recipientArg.value],
340
+ });
331
341
  ambiguities.push({
332
342
  code: 'RECIPIENT_ALLOWLIST_EMPTY',
333
- question: `Recording shows the swap sending to ${recipientArg.value}. What recipient allowlist should the interpreter predicate bind? (omit to leave recipient unconstrained)`,
343
+ 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.`,
334
344
  });
335
345
  }
336
346
  }