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