@crediolabs/policy-synth 0.3.0 → 0.4.0

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 (152) hide show
  1. package/dist/install/authority-overlap.d.ts +101 -0
  2. package/dist/install/authority-overlap.js +227 -0
  3. package/dist/install/index.d.ts +1 -0
  4. package/dist/install/index.js +4 -0
  5. package/dist/record/freshness.d.ts +17 -11
  6. package/dist/record/freshness.js +28 -18
  7. package/dist/record/index.d.ts +10 -0
  8. package/dist/record/index.js +32 -1
  9. package/dist/record/rpc.d.ts +4 -0
  10. package/dist/record/rpc.js +4 -1
  11. package/dist/registry/identify.d.ts +10 -1
  12. package/dist/registry/identify.js +4 -1
  13. package/dist/registry/on-chain-spec.d.ts +37 -0
  14. package/dist/registry/on-chain-spec.js +152 -0
  15. package/dist/run/index.d.ts +25 -4
  16. package/dist/run/index.js +65 -4
  17. package/dist/run/schemas.d.ts +313 -0
  18. package/dist/run/schemas.js +52 -0
  19. package/dist/synth/declare.d.ts +30 -0
  20. package/dist/synth/declare.js +98 -0
  21. package/dist/synth/index.d.ts +1 -0
  22. package/dist/synth/index.js +1 -0
  23. package/dist-cjs/install/authority-overlap.d.ts +101 -0
  24. package/dist-cjs/install/authority-overlap.js +236 -0
  25. package/dist-cjs/install/index.d.ts +1 -0
  26. package/dist-cjs/install/index.js +13 -2
  27. package/dist-cjs/record/freshness.d.ts +17 -11
  28. package/dist-cjs/record/freshness.js +28 -18
  29. package/dist-cjs/record/index.d.ts +10 -0
  30. package/dist-cjs/record/index.js +31 -0
  31. package/dist-cjs/record/rpc.d.ts +4 -0
  32. package/dist-cjs/record/rpc.js +7 -3
  33. package/dist-cjs/registry/identify.d.ts +10 -1
  34. package/dist-cjs/registry/identify.js +4 -0
  35. package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
  36. package/dist-cjs/registry/on-chain-spec.js +159 -0
  37. package/dist-cjs/run/index.d.ts +25 -4
  38. package/dist-cjs/run/index.js +65 -2
  39. package/dist-cjs/run/schemas.d.ts +313 -0
  40. package/dist-cjs/run/schemas.js +53 -1
  41. package/dist-cjs/synth/declare.d.ts +30 -0
  42. package/dist-cjs/synth/declare.js +101 -0
  43. package/dist-cjs/synth/index.d.ts +1 -0
  44. package/dist-cjs/synth/index.js +3 -1
  45. package/package.json +2 -2
  46. package/src/install/authority-overlap.ts +312 -0
  47. package/src/install/index.ts +20 -0
  48. package/src/record/freshness.ts +29 -18
  49. package/src/record/index.ts +59 -2
  50. package/src/record/rpc.ts +4 -1
  51. package/src/registry/identify.ts +4 -1
  52. package/src/registry/on-chain-spec.ts +168 -0
  53. package/src/run/index.ts +79 -2
  54. package/src/run/schemas.ts +57 -0
  55. package/src/synth/declare.ts +157 -0
  56. package/src/synth/index.ts +5 -0
  57. package/dist/adapters/interpreter/index.d.ts +0 -1
  58. package/dist/adapters/interpreter/index.js +0 -2
  59. package/dist/adapters/oz/adapter.d.ts +0 -20
  60. package/dist/adapters/oz/adapter.js +0 -281
  61. package/dist/adapters/oz/index.d.ts +0 -1
  62. package/dist/adapters/oz/index.js +0 -2
  63. package/dist/codegen/compile-gate.d.ts +0 -33
  64. package/dist/codegen/compile-gate.js +0 -124
  65. package/dist/codegen/index.d.ts +0 -2
  66. package/dist/codegen/index.js +0 -8
  67. package/dist/codegen/template.d.ts +0 -18
  68. package/dist/codegen/template.js +0 -148
  69. package/dist/ir/index.d.ts +0 -1
  70. package/dist/ir/index.js +0 -2
  71. package/dist/ir/types.d.ts +0 -71
  72. package/dist/ir/types.js +0 -9
  73. package/dist/mandate/index.d.ts +0 -2
  74. package/dist/mandate/index.js +0 -2
  75. package/dist/mandate/to-ir.d.ts +0 -3
  76. package/dist/mandate/to-ir.js +0 -60
  77. package/dist/mandate/types.d.ts +0 -20
  78. package/dist/mandate/types.js +0 -8
  79. package/dist/seams/index.d.ts +0 -1
  80. package/dist/seams/index.js +0 -2
  81. package/dist/seams/types.d.ts +0 -64
  82. package/dist/seams/types.js +0 -11
  83. package/dist/synth/deny-cases.d.ts +0 -28
  84. package/dist/synth/deny-cases.js +0 -212
  85. package/dist/synth/evaluate.d.ts +0 -18
  86. package/dist/synth/evaluate.js +0 -271
  87. package/dist/synth/harness.d.ts +0 -28
  88. package/dist/synth/harness.js +0 -47
  89. package/dist/synth/minimize.d.ts +0 -4
  90. package/dist/synth/minimize.js +0 -38
  91. package/dist/synth/permit-context.d.ts +0 -8
  92. package/dist/synth/permit-context.js +0 -35
  93. package/dist/synth/predicate-literals.d.ts +0 -5
  94. package/dist/synth/predicate-literals.js +0 -25
  95. package/dist/synth/synthesize-from-mandate.d.ts +0 -20
  96. package/dist/synth/synthesize-from-mandate.js +0 -59
  97. package/dist/verify/envelope.d.ts +0 -15
  98. package/dist/verify/envelope.js +0 -22
  99. package/dist/verify/index.d.ts +0 -3
  100. package/dist/verify/index.js +0 -3
  101. package/dist/verify/simulate.d.ts +0 -18
  102. package/dist/verify/simulate.js +0 -172
  103. package/dist/verify/verify.d.ts +0 -15
  104. package/dist/verify/verify.js +0 -181
  105. package/dist-cjs/adapters/interpreter/index.d.ts +0 -1
  106. package/dist-cjs/adapters/interpreter/index.js +0 -7
  107. package/dist-cjs/adapters/oz/adapter.d.ts +0 -20
  108. package/dist-cjs/adapters/oz/adapter.js +0 -286
  109. package/dist-cjs/adapters/oz/index.d.ts +0 -1
  110. package/dist-cjs/adapters/oz/index.js +0 -8
  111. package/dist-cjs/codegen/compile-gate.d.ts +0 -33
  112. package/dist-cjs/codegen/compile-gate.js +0 -128
  113. package/dist-cjs/codegen/index.d.ts +0 -2
  114. package/dist-cjs/codegen/index.js +0 -14
  115. package/dist-cjs/codegen/template.d.ts +0 -18
  116. package/dist-cjs/codegen/template.js +0 -151
  117. package/dist-cjs/ir/index.d.ts +0 -1
  118. package/dist-cjs/ir/index.js +0 -3
  119. package/dist-cjs/ir/types.d.ts +0 -71
  120. package/dist-cjs/ir/types.js +0 -10
  121. package/dist-cjs/mandate/index.d.ts +0 -2
  122. package/dist-cjs/mandate/index.js +0 -6
  123. package/dist-cjs/mandate/to-ir.d.ts +0 -3
  124. package/dist-cjs/mandate/to-ir.js +0 -63
  125. package/dist-cjs/mandate/types.d.ts +0 -20
  126. package/dist-cjs/mandate/types.js +0 -9
  127. package/dist-cjs/seams/index.d.ts +0 -1
  128. package/dist-cjs/seams/index.js +0 -3
  129. package/dist-cjs/seams/types.d.ts +0 -64
  130. package/dist-cjs/seams/types.js +0 -12
  131. package/dist-cjs/synth/deny-cases.d.ts +0 -28
  132. package/dist-cjs/synth/deny-cases.js +0 -217
  133. package/dist-cjs/synth/evaluate.d.ts +0 -18
  134. package/dist-cjs/synth/evaluate.js +0 -274
  135. package/dist-cjs/synth/harness.d.ts +0 -28
  136. package/dist-cjs/synth/harness.js +0 -50
  137. package/dist-cjs/synth/minimize.d.ts +0 -4
  138. package/dist-cjs/synth/minimize.js +0 -41
  139. package/dist-cjs/synth/permit-context.d.ts +0 -8
  140. package/dist-cjs/synth/permit-context.js +0 -38
  141. package/dist-cjs/synth/predicate-literals.d.ts +0 -5
  142. package/dist-cjs/synth/predicate-literals.js +0 -28
  143. package/dist-cjs/synth/synthesize-from-mandate.d.ts +0 -20
  144. package/dist-cjs/synth/synthesize-from-mandate.js +0 -62
  145. package/dist-cjs/verify/envelope.d.ts +0 -15
  146. package/dist-cjs/verify/envelope.js +0 -23
  147. package/dist-cjs/verify/index.d.ts +0 -3
  148. package/dist-cjs/verify/index.js +0 -8
  149. package/dist-cjs/verify/simulate.d.ts +0 -18
  150. package/dist-cjs/verify/simulate.js +0 -175
  151. package/dist-cjs/verify/verify.d.ts +0 -15
  152. package/dist-cjs/verify/verify.js +0 -184
@@ -0,0 +1,168 @@
1
+ // src/registry/on-chain-spec.ts - read a contract's own interface off chain.
2
+ //
3
+ // The compiled-in registry covers the protocols we pinned by hand. Everything
4
+ // else was reported as `no-abi`, which read as "this contract does not
5
+ // describe itself" when it almost always does: a Soroban contract embeds a
6
+ // typed spec in its wasm, and the network will hand it over. The recorder was
7
+ // refusing calls whose interface was one RPC round-trip away.
8
+ //
9
+ // What this buys is NARROW and deliberately so. A fetched spec says what the
10
+ // contract's arguments ARE, not what they MEAN. So a call verified against it
11
+ // is recognised - the decode is trustworthy, the confidence gate stops
12
+ // refusing it - but the arguments carry the contract's own parameter NAMES
13
+ // rather than a curated meaning, and nothing here infers which argument is a
14
+ // spend or a recipient. A pinned protocol still outranks a fetched spec for
15
+ // exactly that reason, and `identifyProtocol` is consulted first.
16
+
17
+ import { contract as sdkContract } from '@stellar/stellar-sdk'
18
+ import type { ContractInvocation, ScVal } from '../types.ts'
19
+ import { argsMatchAbi } from './identify.ts'
20
+ import type { AbiArg, AbiArgType, ProtocolAbi } from './protocols.ts'
21
+
22
+ /** Map an XDR spec type to the ScVal subset vocabulary the matcher uses.
23
+ *
24
+ * Returns null for a type the recorder's ScVal subset cannot represent. That
25
+ * is deliberate: an argument we cannot type is an argument we cannot check,
26
+ * and claiming a match on it would be the fail-OPEN direction. A function
27
+ * with any such argument is dropped from the derived ABI, so a call to it
28
+ * stays unrecognised rather than being waved through. */
29
+ export function abiTypeFromSpecType(specTypeName: string): AbiArgType | null {
30
+ switch (specTypeName) {
31
+ case 'scSpecTypeAddress':
32
+ return 'address'
33
+ case 'scSpecTypeI128':
34
+ return 'i128'
35
+ case 'scSpecTypeU64':
36
+ case 'scSpecTypeI64':
37
+ return 'u64'
38
+ case 'scSpecTypeU32':
39
+ case 'scSpecTypeI32':
40
+ return 'u32'
41
+ case 'scSpecTypeSymbol':
42
+ return 'symbol'
43
+ case 'scSpecTypeVec':
44
+ return 'vec'
45
+ case 'scSpecTypeBytes':
46
+ case 'scSpecTypeBytesN':
47
+ return 'bytes'
48
+ case 'scSpecTypeMap':
49
+ return 'map'
50
+ default:
51
+ return null
52
+ }
53
+ }
54
+
55
+ /** The RPC surface this module needs. Narrowed to one method so a test can
56
+ * supply a stub without standing up a server, and so the recorder's existing
57
+ * RPC client can be passed straight in. */
58
+ export interface SpecFetcher {
59
+ contractSpec(contractId: string): Promise<sdkContract.Spec | null>
60
+ }
61
+
62
+ /** Build a `SpecFetcher` over the SDK's contract client. */
63
+ export function specFetcherFromRpc(rpcUrl: string, networkPassphrase: string): SpecFetcher {
64
+ return {
65
+ async contractSpec(contractId: string): Promise<sdkContract.Spec | null> {
66
+ try {
67
+ const client = await sdkContract.Client.from({ contractId, networkPassphrase, rpcUrl })
68
+ return (client as unknown as { spec: sdkContract.Spec }).spec ?? null
69
+ } catch {
70
+ // A contract with no spec, an unreachable RPC and a bad address all
71
+ // land here and all mean the same thing to the caller: no interface
72
+ // was obtained, so the contract stays unrecognised. Fail closed.
73
+ return null
74
+ }
75
+ },
76
+ }
77
+ }
78
+
79
+ /** Convert a fetched spec into the ABI shape `identifyProtocol` already
80
+ * matches against. Functions with an argument outside the ScVal subset are
81
+ * OMITTED rather than partially typed. */
82
+ export function abiFromSpec(spec: sdkContract.Spec): ProtocolAbi {
83
+ const abi: ProtocolAbi = {}
84
+ for (const fn of spec.funcs()) {
85
+ const name = fn.name().toString()
86
+ // The constructor is not callable after deployment, so a recorded
87
+ // invocation can never be one. Keeping it would only widen the surface.
88
+ if (name === '__constructor') continue
89
+ const args: AbiArg[] = []
90
+ let usable = true
91
+ for (const input of fn.inputs()) {
92
+ const type = abiTypeFromSpecType(input.type().switch().name)
93
+ if (type === null) {
94
+ usable = false
95
+ break
96
+ }
97
+ const argName = input.name().toString()
98
+ args.push({
99
+ name: argName,
100
+ type,
101
+ // The contract's own parameter name is the honest description. It is
102
+ // NOT a curated meaning - nothing here knows whether `value` is a
103
+ // spend - so downstream must not treat it as one.
104
+ meaning: `${argName} (from the contract's on-chain interface)`,
105
+ })
106
+ }
107
+ if (usable) abi[name] = { args }
108
+ }
109
+ return abi
110
+ }
111
+
112
+ /** Fetch and convert in one step. Returns null when no usable interface was
113
+ * obtained, which the caller must treat as "unrecognised". */
114
+ export async function fetchContractAbi(
115
+ contractId: string,
116
+ fetcher: SpecFetcher
117
+ ): Promise<ProtocolAbi | null> {
118
+ const spec = await fetcher.contractSpec(contractId)
119
+ if (spec === null) return null
120
+ const abi = abiFromSpec(spec)
121
+ return Object.keys(abi).length === 0 ? null : abi
122
+ }
123
+
124
+ /** Every contract invoked anywhere in the tree, with the calls made on it. */
125
+ function callsByContract(
126
+ invocations: ReadonlyArray<ContractInvocation>
127
+ ): Map<string, Array<{ fn: string; args: ScVal[] }>> {
128
+ const out = new Map<string, Array<{ fn: string; args: ScVal[] }>>()
129
+ const walk = (inv: ContractInvocation): void => {
130
+ const list = out.get(inv.contract) ?? []
131
+ list.push({ fn: inv.fn, args: inv.args })
132
+ out.set(inv.contract, list)
133
+ for (const sub of inv.subInvocations) walk(sub)
134
+ }
135
+ for (const inv of invocations) walk(inv)
136
+ return out
137
+ }
138
+
139
+ /** Contracts whose EVERY recorded call matches their own published interface.
140
+ *
141
+ * All-or-nothing per contract, deliberately. A contract where one call
142
+ * verifies and another does not is a contract we do not understand, and
143
+ * marking it recognised would raise confidence on the strength of the call
144
+ * we happened to check. The unverified call is the one that matters.
145
+ *
146
+ * A contract with no fetchable spec, or a call naming a function absent from
147
+ * it, simply stays unknown - this only ever ADDS recognition, so a failure
148
+ * here degrades to today's behaviour rather than to a wrong answer. */
149
+ export async function resolveContractsByOnChainSpec(
150
+ invocations: ReadonlyArray<ContractInvocation>,
151
+ candidates: ReadonlyArray<string>,
152
+ fetcher: SpecFetcher
153
+ ): Promise<Set<string>> {
154
+ const resolved = new Set<string>()
155
+ const calls = callsByContract(invocations)
156
+ for (const contract of new Set(candidates)) {
157
+ const made = calls.get(contract)
158
+ if (!made || made.length === 0) continue
159
+ const abi = await fetchContractAbi(contract, fetcher)
160
+ if (abi === null) continue
161
+ const everyCallVerifies = made.every((c) => {
162
+ const entry = abi[c.fn]
163
+ return entry !== undefined && argsMatchAbi(entry.args, c.args)
164
+ })
165
+ if (everyCallVerifies) resolved.add(contract)
166
+ }
167
+ return resolved
168
+ }
package/src/run/index.ts CHANGED
@@ -20,7 +20,9 @@
20
20
  import { createHash } from 'node:crypto'
21
21
  import { rpc } from '@stellar/stellar-sdk'
22
22
  import {
23
+ declarePredicate,
23
24
  type ErrorCode,
25
+ encodePredicate,
24
26
  type Network,
25
27
  type PredicateNode,
26
28
  type ProposedPolicy,
@@ -31,6 +33,11 @@ import {
31
33
  type ToolError,
32
34
  type ToolResponse,
33
35
  } from '../index.ts'
36
+ import {
37
+ type AuthorityOverlap,
38
+ findAuthorityOverlaps,
39
+ type ObservedRule,
40
+ } from '../install/authority-overlap.ts'
34
41
  import {
35
42
  type BuildInstallPolicyResult,
36
43
  type BuildRevokePolicyResult,
@@ -40,8 +47,10 @@ import {
40
47
  rpcClientFromServer,
41
48
  } from '../install/build-install-policy.ts'
42
49
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
50
+ import { decodePredicate } from '../predicate/decode.ts'
43
51
  import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
44
52
  import {
53
+ DeclarePolicyInputSchema,
45
54
  type GetInterpreterInfoInput,
46
55
  GetInterpreterInfoInputSchema,
47
56
  type InstallPolicyInput,
@@ -64,6 +73,7 @@ import {
64
73
  } from './schemas.ts'
65
74
 
66
75
  export type {
76
+ DeclarePolicyInput,
67
77
  GetInterpreterInfoInput,
68
78
  InstallPolicyInput,
69
79
  RecordTransactionInput,
@@ -78,6 +88,7 @@ export type {
78
88
  // truth - MCP tool shapes are derived from them.
79
89
  export {
80
90
  ComposeUserResponsesSchema,
91
+ DeclarePolicyInputSchema,
81
92
  GetInterpreterInfoInputSchema,
82
93
  InstallPolicyInputSchema,
83
94
  InterpreterOptionsSchema,
@@ -107,6 +118,7 @@ export type RunVerifyPolicyInput = VerifyPolicyInput
107
118
  type RunToolName =
108
119
  | 'record_transaction'
109
120
  | 'synthesize_policy'
121
+ | 'declare_policy'
110
122
  | 'simulate_policy'
111
123
  | 'verify_policy'
112
124
  | 'install_policy'
@@ -119,6 +131,7 @@ type RunToolName =
119
131
  const TOOL_ERROR_CODE: Record<RunToolName, ErrorCode> = {
120
132
  record_transaction: 'RECORDING_FAILED',
121
133
  synthesize_policy: 'SYNTHESIS_ERROR',
134
+ declare_policy: 'SYNTHESIS_ERROR',
122
135
  simulate_policy: 'SIMULATION_ERROR',
123
136
  verify_policy: 'VERIFICATION_FAILED',
124
137
  install_policy: 'INSTALL_BUILD_FAILED',
@@ -203,7 +216,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
203
216
 
204
217
  export async function runInstallPolicy(
205
218
  raw: unknown
206
- ): Promise<ToolResponse<BuildInstallPolicyResult>> {
219
+ ): Promise<ToolResponse<BuildInstallPolicyResult & { authorityScan: AuthorityOverlap[] | null }>> {
207
220
  const parsed = InstallPolicyInputSchema.safeParse(raw)
208
221
  if (!parsed.success) {
209
222
  return { ok: false, error: validationError('install_policy', parsed.error.issues) }
@@ -254,7 +267,28 @@ export async function runInstallPolicy(
254
267
  rpc: rpcClient,
255
268
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
256
269
  })
257
- return { ok: true, data: result }
270
+ // Cross-rule scan, when the caller supplied what else is on the account.
271
+ // ABSENT is reported as `null` rather than an empty list: "we did not
272
+ // look" and "we looked and found nothing" are different answers, and
273
+ // collapsing them would let a caller read silence as safety.
274
+ const authorityScan =
275
+ input.existingRules === undefined
276
+ ? null
277
+ : findAuthorityOverlaps({
278
+ intended: {
279
+ // `add_context_rule` gets its id FROM the account, so there is
280
+ // no existing rule this install replaces. A sentinel no real id
281
+ // can equal keeps every observed rule in scope.
282
+ ruleId: -1,
283
+ contextType: input.rule.contextRuleType,
284
+ signers: input.rule.signers,
285
+ predicate: decodePredicate(encodedPredicate),
286
+ },
287
+ // The schema types `predicate` loosely (it is the shared
288
+ // PredicateNodeSchema); the shape is already validated.
289
+ existing: input.existingRules as ObservedRule[],
290
+ })
291
+ return { ok: true, data: { ...result, authorityScan } }
258
292
  } catch (e) {
259
293
  return toolFailure('install_policy', e)
260
294
  }
@@ -384,6 +418,49 @@ export function runSimulatePolicy(raw: unknown): ToolResponse<{
384
418
  }
385
419
  }
386
420
 
421
+ /** `declare_policy` body - the DECLARATIVE front-end.
422
+ *
423
+ * `synthesize_policy` infers a predicate from a transaction that happened;
424
+ * this takes the constraint stated outright. No RPC, no decoding and no
425
+ * parseConfidence, so nothing here can be refused for a contract the registry
426
+ * does not recognise - which is most of the point of having it.
427
+ *
428
+ * The returned `warnings` are load-bearing, not decoration. An argument index
429
+ * the caller did not supply is DEFAULTED to the SEP-41 position, and a bound
430
+ * on the wrong argument constrains something the caller did not mean without
431
+ * ever announcing itself, so a caller that ignores warnings can install a
432
+ * predicate that reads correctly and binds nothing. */
433
+ export function runDeclarePolicy(raw: unknown): ToolResponse<{
434
+ predicate: PredicateNode
435
+ encodedPredicate: string
436
+ predicateHash: string
437
+ warnings: string[]
438
+ }> {
439
+ const parsed = DeclarePolicyInputSchema.safeParse(raw)
440
+ if (!parsed.success) {
441
+ return { ok: false, error: validationError('declare_policy', parsed.error.issues) }
442
+ }
443
+ try {
444
+ // Rebuilt field-by-field rather than passed through: the schema's
445
+ // optionals are `T | undefined` and `PolicyDeclaration`'s are absent-or-T,
446
+ // which `exactOptionalPropertyTypes` treats as different.
447
+ const d = parsed.data
448
+ const { predicate, warnings } = declarePredicate({
449
+ fn: d.fn,
450
+ ...(d.contract !== undefined ? { contract: d.contract } : {}),
451
+ ...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
452
+ ...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
453
+ ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
454
+ ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
455
+ ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
456
+ })
457
+ const { encodedPredicate, predicateHash } = encodePredicate(predicate)
458
+ return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } }
459
+ } catch (e) {
460
+ return toolFailure('declare_policy', e)
461
+ }
462
+ }
463
+
387
464
  /** `verify_policy` body - the permit case plus a generated deny case per
388
465
  * dimension.
389
466
  *
@@ -281,6 +281,29 @@ export type VerifyPolicyInput = z.infer<typeof VerifyPolicyInputSchema>
281
281
  const MAX_SIGNERS_PER_RULE = 15
282
282
  const MAX_POLICIES_PER_RULE = 5
283
283
 
284
+ const SignerDraftSchema = z.discriminatedUnion('kind', [
285
+ z.object({ kind: z.literal('delegated'), address: z.string() }),
286
+ z.object({ kind: z.literal('external'), verifier: z.string(), keyBytes: z.string() }),
287
+ ])
288
+
289
+ const ContextTypeSchema = z.discriminatedUnion('kind', [
290
+ z.object({ kind: z.literal('default') }),
291
+ z.object({ kind: z.literal('call_contract'), contract: z.string() }),
292
+ z.object({ kind: z.literal('create_contract'), wasmHash: z.string() }),
293
+ ])
294
+
295
+ /** A rule ALREADY on the account, as the caller observed it. Supplying these
296
+ * turns on the cross-rule authority scan: a signer belonging to several rules
297
+ * picks which one applies, so a predicate only constrains a key when the
298
+ * policed rule is the only rule that key is on. */
299
+ export const ObservedRuleSchema = z.object({
300
+ id: z.number().int().nonnegative(),
301
+ contextType: ContextTypeSchema,
302
+ signers: z.array(SignerDraftSchema),
303
+ policyAddresses: z.array(z.string()),
304
+ predicate: PredicateNodeSchema.optional(),
305
+ })
306
+
284
307
  const ContextRuleDraftSchema = z
285
308
  .object({
286
309
  contextRuleType: z.discriminatedUnion('kind', [
@@ -391,8 +414,42 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
391
414
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
392
415
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
393
416
 
417
+ // ===== declare_policy =====
418
+ //
419
+ // The declarative front-end: the constraint stated outright, with no
420
+ // transaction to decode. Deliberately NOT a revival of the removed
421
+ // `MandateSpec` - that carried a rolling `spendingLimit` the interpreter
422
+ // cannot evaluate and an `approvalThreshold` needing OZ primitives nobody
423
+ // deployed. Only fields grammar 3 can actually enforce appear here.
424
+ export const DeclarePolicyInputSchema = z
425
+ .object({
426
+ fn: z.string().min(1, 'fn must name the method to pin'),
427
+ contract: z
428
+ .string()
429
+ .regex(STELLAR_CONTRACT_ADDRESS, 'contract must be a Stellar contract address (C...)')
430
+ .optional(),
431
+ /** Smallest unit, unsigned decimal STRING - an i128 is wider than
432
+ * Number.MAX_SAFE_INTEGER, so a number here would silently round. */
433
+ maxAmount: z
434
+ .string()
435
+ .regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
436
+ .optional(),
437
+ amountArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
438
+ recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
439
+ recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
440
+ allowZeroCap: z.boolean().optional(),
441
+ })
442
+ .strict()
443
+ export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>
444
+
394
445
  export const InstallPolicyInputSchema = z
395
446
  .object({
447
+ /** Rules already on the account. Supplying them turns on the cross-rule
448
+ * authority scan, which reports every existing rule a signer of this
449
+ * install could name INSTEAD - including an unpoliced one, against which
450
+ * the predicate never runs. Absent means the scan is skipped, and the
451
+ * result says so rather than reporting "no overlaps found". */
452
+ existingRules: z.array(ObservedRuleSchema).optional(),
396
453
  /** The smart account contract address (C...) that will receive the rule. */
397
454
  smartAccount: z
398
455
  .string()
@@ -0,0 +1,157 @@
1
+ // src/synth/declare.ts - build a predicate from a DECLARED constraint.
2
+ //
3
+ // The second synthesis front-end, beside `synthesizeFromRecording`. That one
4
+ // infers a predicate from a transaction that happened; this one takes the
5
+ // constraint the user states outright. No transaction, no decoding, no
6
+ // inference, no RPC: the same declaration always lowers to a byte-identical
7
+ // predicate.
8
+ //
9
+ // This is the surviving half of the removed `MandateSpec`. Two of that type's
10
+ // five fields lowered to things that do not exist - `spendingLimit` became a
11
+ // `window_spent` compare the interpreter cannot evaluate (it is handed one
12
+ // call and keeps no state), and `approvalThreshold` needed OZ built-in policy
13
+ // contracts that were never deployed. Both are deliberately absent here, and
14
+ // the per-call `maxAmount` bound below is the honest replacement for the
15
+ // first: it constrains the amount in THIS call rather than implying a rolling
16
+ // total nothing tracks.
17
+ //
18
+ // What a declaration can say maps one-to-one onto grammar 3:
19
+ // fn -> eq(call_fn, literal_symbol)
20
+ // contract -> eq(call_contract, literal_address)
21
+ // maxAmount -> lte(call_arg(i), literal_i128)
22
+ // recipients -> in(call_arg(j), [literal_address, ...])
23
+
24
+ import type { ToolError } from '../errors.ts'
25
+ import type { PredicateLeaf, PredicateNode } from '../types.ts'
26
+ import { isStellarAddress } from './address.ts'
27
+
28
+ /** Argument positions of the SEP-41 `transfer(from, to, amount)` shape. A
29
+ * declaration that names a different method almost certainly has different
30
+ * positions, which is why using either default emits a warning naming the
31
+ * index it assumed - a bound on the wrong argument constrains something the
32
+ * user did not mean and fails silently. */
33
+ const SEP41_RECIPIENT_ARG = 1
34
+ const SEP41_AMOUNT_ARG = 2
35
+
36
+ export interface PolicyDeclaration {
37
+ /** Method to pin. Required: a predicate with no selector leaf constrains
38
+ * nothing and the contract refuses it at install. */
39
+ fn: string
40
+ /** Contract to pin, already resolved to a `C...` address. */
41
+ contract?: string
42
+ /** Upper bound on the call's amount argument, in the token's SMALLEST
43
+ * unit as an unsigned decimal string (25 XLM = "250000000"). */
44
+ maxAmount?: string
45
+ /** Which argument carries the amount. Defaults to the SEP-41 position. */
46
+ amountArgIndex?: number
47
+ /** Recipient allowlist. */
48
+ recipients?: string[]
49
+ /** Which argument carries the recipient. Defaults to the SEP-41 position. */
50
+ recipientArgIndex?: number
51
+ /** A cap of "0" denies every call, so it is refused unless asked for
52
+ * explicitly. A rule that permits nothing is a plausible thing to want and
53
+ * an implausible thing to want by accident. */
54
+ allowZeroCap?: boolean
55
+ }
56
+
57
+ export interface DeclaredPredicate {
58
+ predicate: PredicateNode
59
+ /** Assumptions the caller should check. Never empty when an argument index
60
+ * was defaulted rather than supplied. */
61
+ warnings: string[]
62
+ }
63
+
64
+ /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
65
+ * the same declaration always produces the same predicate. */
66
+ export function declarePredicate(d: PolicyDeclaration): DeclaredPredicate {
67
+ if (!d.fn || d.fn.trim() === '') {
68
+ throw declareError('SYNTHESIS_ERROR', 'a declaration needs `fn`: the method to pin')
69
+ }
70
+ const warnings: string[] = []
71
+ const children: PredicateNode[] = [
72
+ { op: 'eq', left: { kind: 'call_fn' }, right: { kind: 'literal_symbol', value: d.fn } },
73
+ ]
74
+
75
+ if (d.contract !== undefined) {
76
+ if (!isStellarAddress(d.contract) || !d.contract.startsWith('C')) {
77
+ throw declareError(
78
+ 'SYNTHESIS_ERROR',
79
+ `contract must be a Stellar contract address (C...), got ${d.contract}`
80
+ )
81
+ }
82
+ children.push({
83
+ op: 'eq',
84
+ left: { kind: 'call_contract' },
85
+ right: { kind: 'literal_address', value: d.contract },
86
+ })
87
+ }
88
+
89
+ if (d.recipients !== undefined) {
90
+ if (d.recipients.length === 0) {
91
+ throw declareError(
92
+ 'SYNTHESIS_ERROR',
93
+ 'recipients was supplied but empty; an empty `in` haystack is refused at decode. Omit it to leave recipients unconstrained.'
94
+ )
95
+ }
96
+ for (const r of d.recipients) {
97
+ if (!isStellarAddress(r)) {
98
+ throw declareError('SYNTHESIS_ERROR', `recipient is not a Stellar address: ${r}`)
99
+ }
100
+ }
101
+ const idx = d.recipientArgIndex ?? SEP41_RECIPIENT_ARG
102
+ if (d.recipientArgIndex === undefined) {
103
+ warnings.push(
104
+ `recipient allowlist bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the recipient elsewhere this constrains the wrong argument - pass recipientArgIndex.`
105
+ )
106
+ }
107
+ children.push({
108
+ op: 'in',
109
+ needle: { kind: 'call_arg', index: idx },
110
+ haystack: d.recipients.map((value): PredicateLeaf => ({ kind: 'literal_address', value })),
111
+ })
112
+ }
113
+
114
+ if (d.maxAmount !== undefined) {
115
+ if (!/^[0-9]+$/.test(d.maxAmount)) {
116
+ throw declareError(
117
+ 'SYNTHESIS_ERROR',
118
+ `maxAmount must be an unsigned integer in the token's smallest unit, got "${d.maxAmount}" (25 XLM = "250000000")`
119
+ )
120
+ }
121
+ if (d.maxAmount === '0' && d.allowZeroCap !== true) {
122
+ throw declareError(
123
+ 'SYNTHESIS_ERROR',
124
+ 'maxAmount "0" denies every call: no amount satisfies the bound. Set allowZeroCap to declare that deliberately.'
125
+ )
126
+ }
127
+ const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG
128
+ if (d.amountArgIndex === undefined) {
129
+ warnings.push(
130
+ `amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`
131
+ )
132
+ }
133
+ children.push({
134
+ op: 'lte',
135
+ left: { kind: 'call_arg', index: idx },
136
+ right: { kind: 'literal_i128', value: d.maxAmount },
137
+ })
138
+ }
139
+
140
+ // A single conjunct is emitted bare. `and` with one child encodes to
141
+ // different bytes than the child alone, and the extra node buys nothing.
142
+ const predicate: PredicateNode =
143
+ children.length === 1 ? (children[0] as PredicateNode) : { op: 'and', children }
144
+ return { predicate, warnings }
145
+ }
146
+
147
+ function declareError(code: ToolError['code'], message: string): ToolError {
148
+ const err = new Error(message) as Error & {
149
+ code: ToolError['code']
150
+ severity: string
151
+ retryable: boolean
152
+ }
153
+ err.code = code
154
+ err.severity = 'error'
155
+ err.retryable = false
156
+ throw err
157
+ }
@@ -9,6 +9,11 @@ export {
9
9
  type ComposeResult,
10
10
  composeFromRecording,
11
11
  } from './compose-from-recording.ts'
12
+ export {
13
+ type DeclaredPredicate,
14
+ declarePredicate,
15
+ type PolicyDeclaration,
16
+ } from './declare.ts'
12
17
  export { type IntentFacts, lower } from './lower.ts'
13
18
  export {
14
19
  type DecideScopeOptions,
@@ -1 +0,0 @@
1
- export { createInterpreterAdapter, type InterpreterAdapterConfig, PLACEHOLDER_INTERPRETER_ADDRESS, } from './adapter.ts';
@@ -1,2 +0,0 @@
1
- // src/adapters/interpreter/index.ts - re-export the interpreter-policy CustodyAdapter.
2
- export { createInterpreterAdapter, PLACEHOLDER_INTERPRETER_ADDRESS, } from "./adapter.js";
@@ -1,20 +0,0 @@
1
- import type { CustodyAdapter } from '../../seams/types.ts';
2
- import type { Network } from '../../types.ts';
3
- /** OZ built-in policy contract instance addresses, per primitive. */
4
- export interface OzPrimitiveInstances {
5
- spending_limit: string;
6
- simple_threshold: string;
7
- weighted_threshold: string;
8
- }
9
- /** Per-network config for the OZ adapter. */
10
- export interface OzAdapterConfig {
11
- network: Network;
12
- instances: OzPrimitiveInstances;
13
- }
14
- /** [VERIFY] NOT real deployed addresses. The OZ built-in policy instances are
15
- * per-network deploy artifacts we do not have yet; install is a later phase.
16
- * Injected so the adapter never invents a Stellar contract address. */
17
- export declare const PLACEHOLDER_OZ_INSTANCES: OzPrimitiveInstances;
18
- /** Week-1 OZ adapter config with placeholder instance addresses. */
19
- export declare function placeholderOzConfig(network: Network): OzAdapterConfig;
20
- export declare function createOzAdapter(config: OzAdapterConfig): CustodyAdapter;