@crediolabs/policy-synth 0.3.1 → 0.5.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 (100) hide show
  1. package/dist/adapters/interpreter/adapter.d.ts +2 -2
  2. package/dist/adapters/interpreter/adapter.js +11 -3
  3. package/dist/errors.d.ts +6 -1
  4. package/dist/install/authority-overlap.d.ts +101 -0
  5. package/dist/install/authority-overlap.js +239 -0
  6. package/dist/install/build-add-context-rule.d.ts +1 -1
  7. package/dist/install/index.d.ts +1 -0
  8. package/dist/install/index.js +4 -0
  9. package/dist/install/read-account-rules.d.ts +79 -0
  10. package/dist/install/read-account-rules.js +241 -0
  11. package/dist/predicate/decode.js +22 -1
  12. package/dist/predicate/encode.js +52 -5
  13. package/dist/predicate/from-json.js +14 -1
  14. package/dist/record/index.d.ts +10 -0
  15. package/dist/record/index.js +32 -1
  16. package/dist/record/rpc.d.ts +4 -0
  17. package/dist/record/rpc.js +4 -1
  18. package/dist/registry/identify.d.ts +10 -1
  19. package/dist/registry/identify.js +4 -1
  20. package/dist/registry/on-chain-spec.d.ts +37 -0
  21. package/dist/registry/on-chain-spec.js +152 -0
  22. package/dist/review-card/builder.js +40 -0
  23. package/dist/review-card/cross-check.js +34 -0
  24. package/dist/review-card/render-leaf.d.ts +1 -1
  25. package/dist/review-card/render-leaf.js +7 -0
  26. package/dist/run/index.d.ts +25 -4
  27. package/dist/run/index.js +108 -4
  28. package/dist/run/schemas.d.ts +350 -6
  29. package/dist/run/schemas.js +95 -6
  30. package/dist/simulate/deny-cases.js +11 -0
  31. package/dist/simulate/evaluate.js +86 -5
  32. package/dist/synth/declare.d.ts +43 -0
  33. package/dist/synth/declare.js +127 -0
  34. package/dist/synth/index.d.ts +1 -0
  35. package/dist/synth/index.js +1 -0
  36. package/dist/synth/synthesize-from-recording.js +1 -1
  37. package/dist/types.d.ts +21 -1
  38. package/dist/types.js +1 -1
  39. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  40. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  41. package/dist-cjs/errors.d.ts +6 -1
  42. package/dist-cjs/install/authority-overlap.d.ts +101 -0
  43. package/dist-cjs/install/authority-overlap.js +248 -0
  44. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  45. package/dist-cjs/install/index.d.ts +1 -0
  46. package/dist-cjs/install/index.js +13 -2
  47. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  48. package/dist-cjs/install/read-account-rules.js +252 -0
  49. package/dist-cjs/predicate/decode.js +22 -1
  50. package/dist-cjs/predicate/encode.js +52 -5
  51. package/dist-cjs/predicate/from-json.js +14 -1
  52. package/dist-cjs/record/index.d.ts +10 -0
  53. package/dist-cjs/record/index.js +31 -0
  54. package/dist-cjs/record/rpc.d.ts +4 -0
  55. package/dist-cjs/record/rpc.js +7 -3
  56. package/dist-cjs/registry/identify.d.ts +10 -1
  57. package/dist-cjs/registry/identify.js +4 -0
  58. package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
  59. package/dist-cjs/registry/on-chain-spec.js +159 -0
  60. package/dist-cjs/review-card/builder.js +40 -0
  61. package/dist-cjs/review-card/cross-check.js +34 -0
  62. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  63. package/dist-cjs/review-card/render-leaf.js +7 -0
  64. package/dist-cjs/run/index.d.ts +25 -4
  65. package/dist-cjs/run/index.js +108 -2
  66. package/dist-cjs/run/schemas.d.ts +350 -6
  67. package/dist-cjs/run/schemas.js +96 -7
  68. package/dist-cjs/simulate/deny-cases.js +11 -0
  69. package/dist-cjs/simulate/evaluate.js +86 -5
  70. package/dist-cjs/synth/declare.d.ts +43 -0
  71. package/dist-cjs/synth/declare.js +130 -0
  72. package/dist-cjs/synth/index.d.ts +1 -0
  73. package/dist-cjs/synth/index.js +3 -1
  74. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  75. package/dist-cjs/types.d.ts +21 -1
  76. package/dist-cjs/types.js +1 -1
  77. package/package.json +1 -1
  78. package/src/adapters/interpreter/adapter.ts +13 -5
  79. package/src/errors.ts +5 -0
  80. package/src/install/authority-overlap.ts +323 -0
  81. package/src/install/index.ts +20 -0
  82. package/src/install/read-account-rules.ts +313 -0
  83. package/src/predicate/decode.ts +22 -1
  84. package/src/predicate/encode.ts +55 -5
  85. package/src/predicate/from-json.ts +14 -1
  86. package/src/record/index.ts +59 -2
  87. package/src/record/rpc.ts +4 -1
  88. package/src/registry/identify.ts +4 -1
  89. package/src/registry/on-chain-spec.ts +168 -0
  90. package/src/review-card/builder.ts +45 -2
  91. package/src/review-card/cross-check.ts +35 -1
  92. package/src/review-card/render-leaf.ts +8 -1
  93. package/src/run/index.ts +125 -2
  94. package/src/run/schemas.ts +100 -6
  95. package/src/simulate/deny-cases.ts +12 -1
  96. package/src/simulate/evaluate.ts +101 -9
  97. package/src/synth/declare.ts +206 -0
  98. package/src/synth/index.ts +5 -0
  99. package/src/synth/synthesize-from-recording.ts +1 -1
  100. package/src/types.ts +16 -1
@@ -64,6 +64,15 @@ function expectSymbol(v: xdr.ScVal | undefined, what: string): string {
64
64
  return v.sym().toString()
65
65
  }
66
66
 
67
+ /** Strict i128, returned as a decimal string. Strict on purpose: the Rust
68
+ * decoder refuses a u32 in an i128 slot rather than widening it, so
69
+ * accepting one here would let a predicate decode off chain and be refused
70
+ * on chain. */
71
+ function expectI128(v: xdr.ScVal | undefined, what: string): string {
72
+ if (!v || v.switch() !== xdr.ScValType.scvI128()) throw malformed(`${what} is not an i128`)
73
+ return scValToBigInt(v).toString()
74
+ }
75
+
67
76
  /** Arity check with the same intent as the Rust `check_arity`: a selector with
68
77
  * the wrong element count is malformed, not silently truncated. */
69
78
  function arity(items: xdr.ScVal[], n: number, selector: string): void {
@@ -94,6 +103,14 @@ function decodeSelectorLeaf(items: xdr.ScVal[], sym: string): PredicateLeaf {
94
103
  element: expectU32(items[2], 'call_arg_field element'),
95
104
  field: expectSymbol(items[3], 'call_arg_field field'),
96
105
  }
106
+ case 'call_arg_scaled':
107
+ arity(items, 4, sym)
108
+ return {
109
+ kind: 'call_arg_scaled',
110
+ index: expectU32(items[1], 'call_arg_scaled index'),
111
+ num: expectI128(items[2], 'call_arg_scaled num'),
112
+ den: expectI128(items[3], 'call_arg_scaled den'),
113
+ }
97
114
  default:
98
115
  // Deliberately NOT a literal_vec fallback - see the header note.
99
116
  throw malformed(`unknown selector symbol '${sym}'`)
@@ -129,14 +146,18 @@ export function decodeNode(v: xdr.ScVal): PredicateNode {
129
146
  const op = selectorSymbol(items)
130
147
  if (op === null) throw malformed('node does not start with an operator symbol')
131
148
  switch (op) {
132
- case 'and': {
149
+ case 'and':
150
+ case 'or': {
133
151
  arity(items, 2, op)
134
152
  const children = expectVec(items[1] as xdr.ScVal, `${op} children`).map(decodeNode)
135
153
  if (children.length === 0) throw malformed(`${op} has no children`)
136
154
  return { op, children }
137
155
  }
138
156
  case 'eq':
157
+ case 'lt':
139
158
  case 'lte':
159
+ case 'gt':
160
+ case 'gte':
140
161
  arity(items, 3, op)
141
162
  return {
142
163
  op,
@@ -124,7 +124,8 @@ function walk(
124
124
  counters: { selectorLeaves: number }
125
125
  ): { depth: number; leaves: number } {
126
126
  switch (node.op) {
127
- case 'and': {
127
+ case 'and':
128
+ case 'or': {
128
129
  if (node.children.length === 0) {
129
130
  throw capError(
130
131
  'MALFORMED_PREDICATE',
@@ -141,7 +142,10 @@ function walk(
141
142
  return { depth: maxChildDepth + 1, leaves: totalLeaves }
142
143
  }
143
144
  case 'eq':
144
- case 'lte': {
145
+ case 'lt':
146
+ case 'lte':
147
+ case 'gt':
148
+ case 'gte': {
145
149
  collectSelector(node.left, counters)
146
150
  collectSelector(node.right, counters)
147
151
  return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) }
@@ -192,14 +196,20 @@ function collectSelector(leaf: PredicateLeaf, counters: { selectorLeaves: number
192
196
 
193
197
  function encodeNode(node: PredicateNode): xdr.ScVal {
194
198
  switch (node.op) {
195
- case 'and': {
199
+ case 'and':
200
+ case 'or': {
196
201
  const encoded = node.children.map(encodeNode)
197
- // sort children by their canonical XDR bytes ascending.
202
+ // sort children by their canonical XDR bytes ascending. Both operators
203
+ // are commutative for the permit decision, so sorting costs no meaning
204
+ // and buys a stable hash for logically-identical predicates.
198
205
  const sorted = sortByCanonicalBytes(encoded)
199
206
  return xdr.ScVal.scvVec([symbol(node.op), xdr.ScVal.scvVec(sorted)])
200
207
  }
201
208
  case 'eq':
202
- case 'lte': {
209
+ case 'lt':
210
+ case 'lte':
211
+ case 'gt':
212
+ case 'gte': {
203
213
  return xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)])
204
214
  }
205
215
  case 'in': {
@@ -231,6 +241,15 @@ function encodeLeaf(leaf: PredicateLeaf): xdr.ScVal {
231
241
  xdr.ScVal.scvU32(leaf.element),
232
242
  xdr.ScVal.scvSymbol(leaf.field),
233
243
  ])
244
+ case 'call_arg_scaled':
245
+ // num/den are i128 on the wire. The Rust decoder type-checks both
246
+ // slots, so a u32 here would be refused rather than widened.
247
+ return xdr.ScVal.scvVec([
248
+ symbol('call_arg_scaled'),
249
+ xdr.ScVal.scvU32(leaf.index),
250
+ scvI128FromDecimal(leaf.num),
251
+ scvI128FromDecimal(leaf.den),
252
+ ])
234
253
  case 'literal_address':
235
254
  return scvAddressFromStrkey(leaf.value)
236
255
  case 'literal_i128':
@@ -332,6 +351,33 @@ function validateLeafValues(node: PredicateNode): void {
332
351
  throw malformed(`call_arg_field.element out of u32 range at ${path}`)
333
352
  }
334
353
  return
354
+ case 'call_arg_scaled': {
355
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
356
+ throw malformed(`call_arg_scaled.index out of u32 range at ${path}`)
357
+ }
358
+ // Mirror of the contract's install gate (214). Without it the TS
359
+ // self-verify would green-light a ratio the chain refuses, which is
360
+ // exactly the divergence this validator exists to prevent.
361
+ let num: bigint
362
+ let den: bigint
363
+ try {
364
+ num = BigInt(leaf.num)
365
+ den = BigInt(leaf.den)
366
+ } catch {
367
+ throw malformed(`call_arg_scaled num/den must be i128 decimal strings at ${path}`)
368
+ }
369
+ if (den === 0n) {
370
+ throw malformed(
371
+ `call_arg_scaled.den is zero at ${path}: the contract refuses it at install (INVALID_SCALED_RATIO)`
372
+ )
373
+ }
374
+ if (num <= 0n || den < 0n) {
375
+ throw malformed(
376
+ `call_arg_scaled ratio ${leaf.num}/${leaf.den} at ${path} is not positive: a negative ratio inverts the comparison, so the floor would permit what it was written to refuse. The contract refuses it at install (INVALID_SCALED_RATIO)`
377
+ )
378
+ }
379
+ return
380
+ }
335
381
  case 'call_contract':
336
382
  case 'call_fn':
337
383
  case 'literal_address':
@@ -342,12 +388,16 @@ function validateLeafValues(node: PredicateNode): void {
342
388
  function walkNode(n: PredicateNode, path: string): void {
343
389
  switch (n.op) {
344
390
  case 'and':
391
+ case 'or':
345
392
  n.children.forEach((c, i) => {
346
393
  walkNode(c, `${path}.children[${i}]`)
347
394
  })
348
395
  return
349
396
  case 'eq':
397
+ case 'lt':
350
398
  case 'lte':
399
+ case 'gt':
400
+ case 'gte':
351
401
  walkLeaf(n.left, `${path}.left`)
352
402
  walkLeaf(n.right, `${path}.right`)
353
403
  return
@@ -26,9 +26,13 @@ export function jsonToAst(value: unknown): PredicateNode {
26
26
  }
27
27
  switch (v.op) {
28
28
  case 'and':
29
- return { op: 'and', children: arrayOf(v.children, jsonToAst) }
29
+ case 'or':
30
+ return { op: v.op, children: arrayOf(v.children, jsonToAst) }
30
31
  case 'eq':
32
+ case 'lt':
31
33
  case 'lte':
34
+ case 'gt':
35
+ case 'gte':
32
36
  return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) }
33
37
  case 'in':
34
38
  return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) }
@@ -53,6 +57,15 @@ function jsonToLeaf(value: unknown): PredicateLeaf {
53
57
  return { kind: 'call_arg', index: numberField(v, 'index') }
54
58
  case 'call_arg_len':
55
59
  return { kind: 'call_arg_len', index: numberField(v, 'index') }
60
+ case 'call_arg_scaled':
61
+ // num/den stay decimal STRINGS: an i128 ratio does not survive a JS
62
+ // number, and silently rounding one would change the floor.
63
+ return {
64
+ kind: 'call_arg_scaled',
65
+ index: numberField(v, 'index'),
66
+ num: stringField(v, 'num'),
67
+ den: stringField(v, 'den'),
68
+ }
56
69
  case 'literal_address':
57
70
  return { kind: 'literal_address', value: stringField(v, 'value') }
58
71
  case 'literal_i128':
@@ -17,8 +17,13 @@
17
17
  // Returns ToolResponse<RecordedTransaction> per the canonical envelope
18
18
  // defined in src/errors.ts.
19
19
 
20
- import type { xdr } from '@stellar/stellar-sdk'
20
+ import { Networks, type xdr } from '@stellar/stellar-sdk'
21
21
  import type { ToolError, ToolResponse } from '../errors.ts'
22
+ import {
23
+ resolveContractsByOnChainSpec,
24
+ type SpecFetcher,
25
+ specFetcherFromRpc,
26
+ } from '../registry/on-chain-spec.ts'
22
27
  import type { Network, ParseConfidence, RecordedTransaction } from '../types.ts'
23
28
  import type { DecodedTransaction } from './decode.ts'
24
29
  import {
@@ -34,7 +39,7 @@ import {
34
39
  isBelowThreshold,
35
40
  } from './freshness.ts'
36
41
  import { extractTokenMovements } from './movements.ts'
37
- import { createRpcServer, type RpcFetcher } from './rpc.ts'
42
+ import { createRpcServer, PUBLIC_RPC_URLS, type RpcFetcher } from './rpc.ts'
38
43
  import { validateAgainstEvents } from './validate.ts'
39
44
 
40
45
  /** Public input shape. The brief pins:
@@ -63,10 +68,56 @@ export interface RecordInput {
63
68
  * automatically; tests can pass a deterministic stub. */
64
69
  crossNetworkFetcher?: RpcFetcher
65
70
  confidenceOverride?: number
71
+ /** Read a contract's own interface off chain when the compiled-in registry
72
+ * does not recognise it. Default ON: the registry covers the protocols we
73
+ * pinned by hand, and refusing everything else reported `no-abi` for
74
+ * contracts that publish a full typed spec. Set false to record against
75
+ * the registry alone (no extra RPC). */
76
+ resolveContractSpecs?: boolean
77
+ /** Test seam for the spec lookup. Unset in production, where it is built
78
+ * from the network's pinned RPC URL. */
79
+ specFetcher?: SpecFetcher
66
80
  }
67
81
 
68
82
  export type RecordResult = ToolResponse<RecordedTransaction>
69
83
 
84
+ /** Second pass over the contracts the compiled-in registry did not recognise.
85
+ *
86
+ * Each candidate's own interface is read off chain and every call it received
87
+ * is checked against it; the ones that verify are fed back through the decoder
88
+ * as known. Re-decoding rather than patching the first result keeps ONE code
89
+ * path computing parseConfidence - a hand-adjusted count here would be a
90
+ * second implementation of the gate, free to drift from the real one.
91
+ *
92
+ * Only ever ADDS recognition. A missing spec, an unreachable RPC or a call the
93
+ * interface does not describe all leave the recording exactly as it was. */
94
+ async function resolveByOnChainSpec(
95
+ input: RecordInput,
96
+ decoded: DecodedTransaction,
97
+ redecode: (known: ReadonlySet<string>) => DecodedTransaction
98
+ ): Promise<DecodedTransaction> {
99
+ if (input.resolveContractSpecs === false) return decoded
100
+ if (decoded.unknownContracts.length === 0) return decoded
101
+ const fetcher =
102
+ input.specFetcher ??
103
+ specFetcherFromRpc(
104
+ PUBLIC_RPC_URLS[input.network],
105
+ input.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET
106
+ )
107
+ let resolved: ReadonlySet<string>
108
+ try {
109
+ resolved = await resolveContractsByOnChainSpec(
110
+ decoded.invocations,
111
+ decoded.unknownContracts.map((u) => u.contract),
112
+ fetcher
113
+ )
114
+ } catch {
115
+ // A lookup failure must not fail the recording that already succeeded.
116
+ return decoded
117
+ }
118
+ return resolved.size === 0 ? decoded : redecode(resolved)
119
+ }
120
+
70
121
  export async function recordTransaction(input: RecordInput): Promise<RecordResult> {
71
122
  if (!input.network) {
72
123
  return err('RECORDING_FAILED', 'network required', false)
@@ -138,6 +189,9 @@ export async function recordTransaction(input: RecordInput): Promise<RecordResul
138
189
  if (e instanceof DecodeError) return err('RECORDING_FAILED', e.message, false)
139
190
  throw e
140
191
  }
192
+ decoded = await resolveByOnChainSpec(input, decoded, (known) =>
193
+ decodeEnvelope(fetched.envelopeXdr, events, [], fetched.ledger, known, input.network)
194
+ )
141
195
  return finish(input.network, decoded, input.confidenceOverride)
142
196
  }
143
197
 
@@ -157,6 +211,9 @@ export async function recordTransaction(input: RecordInput): Promise<RecordResul
157
211
  false
158
212
  )
159
213
  }
214
+ decoded = await resolveByOnChainSpec(input, decoded, (known) =>
215
+ decodeEnvelopeXdr(xdrStr, [], [], 0, known, input.network)
216
+ )
160
217
  return finish(input.network, decoded, input.confidenceOverride)
161
218
  }
162
219
 
package/src/record/rpc.ts CHANGED
@@ -23,7 +23,10 @@ export interface SorobanTxResponse {
23
23
 
24
24
  export type RpcFetcher = (hash: string) => Promise<SorobanTxResponse | null>
25
25
 
26
- const PUBLIC_RPC_URLS: Record<Network, string> = {
26
+ /** Exported so the on-chain spec lookup reads from the SAME endpoint the
27
+ * recorder fetched the transaction from. Two different endpoints could
28
+ * disagree about what a contract is. */
29
+ export const PUBLIC_RPC_URLS: Record<Network, string> = {
27
30
  testnet: 'https://soroban-testnet.stellar.org',
28
31
  // The brief pins testnet; mainnet is left to the caller via injection. We keep
29
32
  // a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
@@ -107,7 +107,10 @@ export function identifyProtocol(
107
107
  * declared type. `other` is intentionally NOT a valid ABI match - it means
108
108
  * the decoder couldn't classify the value, which is exactly the signal
109
109
  * fail-closed should refuse. */
110
- function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean {
110
+ /** Exported so the on-chain-spec path checks a call the SAME way a pinned
111
+ * protocol does. Reimplementing it there would let the two drift, and a
112
+ * looser copy would be the fail-OPEN direction. */
113
+ export function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean {
111
114
  if (expected.length !== actual.length) return false
112
115
  for (let i = 0; i < expected.length; i += 1) {
113
116
  const want = expected[i]
@@ -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
+ }
@@ -20,7 +20,7 @@
20
20
  // hash. There is no clock; the hash never includes a timestamp.
21
21
 
22
22
  import { createHash } from 'node:crypto'
23
- import type { ContextRuleDraft, PredicateNode } from '../types.ts'
23
+ import type { ContextRuleDraft, PredicateLeaf, PredicateNode } from '../types.ts'
24
24
  import { comparisonOpText, renderHaystackElement, renderVecElement } from './render-leaf.ts'
25
25
 
26
26
  export interface ReviewCardSummary {
@@ -105,16 +105,33 @@ function walkPredicate(node: PredicateNode, visit: (node: PredicateNode) => void
105
105
  case 'and':
106
106
  for (const child of node.children) walkPredicate(child, visit)
107
107
  return
108
+ // NOT descended into. Every line the card emits reads as a requirement,
109
+ // and `and` is what makes that true. Listing an `or`'s branches as
110
+ // separate lines would state the opposite of what the policy means, so
111
+ // the whole disjunction is rendered as ONE line instead.
112
+ case 'or':
113
+ visit(node)
114
+ return
108
115
  case 'in':
109
116
  visit(node)
110
117
  return
111
118
  case 'eq':
119
+ case 'lt':
112
120
  case 'lte':
121
+ case 'gt':
122
+ case 'gte':
113
123
  visit(node)
114
124
  return
115
125
  }
116
126
  }
117
127
 
128
+ /** Argument index of a `call_arg` leaf, for the scaled-comparison line. Any
129
+ * other leaf renders as its kind so the line stays readable rather than
130
+ * claiming an index that does not exist. */
131
+ function leftArgLabel(leaf: PredicateLeaf): string {
132
+ return leaf.kind === 'call_arg' ? String(leaf.index) : `<${leaf.kind}>`
133
+ }
134
+
118
135
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
119
136
  * shape of the output is pinned by Task 7b so the test suite can assert
120
137
  * byte-for-byte equality. Returns `null` when the node is a structural
@@ -123,15 +140,41 @@ function renderConstraint(node: PredicateNode): string | null {
123
140
  switch (node.op) {
124
141
  case 'and':
125
142
  return null
143
+ case 'or': {
144
+ // One line for the whole disjunction. If any branch is a shape the
145
+ // card cannot render, the entire line is withheld rather than shown
146
+ // with a branch missing - a disjunction with a branch dropped reads
147
+ // as STRICTER than it is, which is the dangerous direction.
148
+ const parts = node.children.map(renderConstraint)
149
+ if (parts.some((p) => p === null)) return null
150
+ return `Either: ${parts.join(' OR ')}`
151
+ }
126
152
  case 'eq':
153
+ case 'lt':
127
154
  case 'lte':
155
+ case 'gt':
156
+ case 'gte':
128
157
  return renderComparison(node)
129
158
  case 'in':
130
159
  return renderMembership(node)
131
160
  }
132
161
  }
133
162
 
134
- function renderComparison(node: Extract<PredicateNode, { op: 'eq' | 'lte' }>): string | null {
163
+ function renderComparison(
164
+ node: Extract<PredicateNode, { op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte' }>
165
+ ): string | null {
166
+ // The slippage floor: OP(call_arg[out], call_arg_scaled(in, num, den)).
167
+ // Rendered explicitly because the human approving the signature has to see
168
+ // that the bound is a RATIO of another argument, not a fixed amount.
169
+ if (node.right.kind === 'call_arg_scaled') {
170
+ const s = node.right
171
+ return `arg[${leftArgLabel(node.left)}] ${comparisonOpText(node.op)} arg[${s.index}] * ${s.num}/${s.den}`
172
+ }
173
+ if (node.left.kind === 'call_arg_scaled') {
174
+ const s = node.left
175
+ return `arg[${s.index}] * ${s.num}/${s.den} ${comparisonOpText(node.op)} arg[${leftArgLabel(node.right)}]`
176
+ }
177
+
135
178
  const left = node.left
136
179
  const right = node.right
137
180
 
@@ -46,8 +46,28 @@ function collect(node: PredicateNode, out: string[]): void {
46
46
  case 'and':
47
47
  for (const child of node.children) collect(child, out)
48
48
  return
49
+ // ONE line for the whole disjunction, mirroring the builder. Emitting a
50
+ // line per branch would claim every branch is required, which is the
51
+ // opposite of what `or` means. If any branch renders to nothing the whole
52
+ // line is withheld, again mirroring the builder - a disjunction missing a
53
+ // branch reads STRICTER than it is.
54
+ case 'or': {
55
+ const parts: string[] = []
56
+ for (const child of node.children) {
57
+ const childOut: string[] = []
58
+ collect(child, childOut)
59
+ if (childOut.length !== 1) return
60
+ parts.push(childOut[0] as string)
61
+ }
62
+ if (parts.length === 0) return
63
+ out.push(`Either: ${parts.join(' OR ')}`)
64
+ return
65
+ }
49
66
  case 'eq':
67
+ case 'lt':
50
68
  case 'lte':
69
+ case 'gt':
70
+ case 'gte':
51
71
  pushComparison(node.left, node.right, node.op, out)
52
72
  return
53
73
  case 'in':
@@ -59,9 +79,23 @@ function collect(node: PredicateNode, out: string[]): void {
59
79
  function pushComparison(
60
80
  left: PredicateLeaf,
61
81
  right: PredicateLeaf,
62
- op: 'eq' | 'lte',
82
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
63
83
  out: string[]
64
84
  ): void {
85
+ // Slippage floor, mirroring the builder. The human has to see that the
86
+ // bound is a RATIO of another argument, not a fixed amount.
87
+ if (right.kind === 'call_arg_scaled') {
88
+ const label = left.kind === 'call_arg' ? String(left.index) : `<${left.kind}>`
89
+ out.push(
90
+ `arg[${label}] ${comparisonOpText(op)} arg[${right.index}] * ${right.num}/${right.den}`
91
+ )
92
+ return
93
+ }
94
+ if (left.kind === 'call_arg_scaled') {
95
+ const label = right.kind === 'call_arg' ? String(right.index) : `<${right.kind}>`
96
+ out.push(`arg[${left.index}] * ${left.num}/${left.den} ${comparisonOpText(op)} arg[${label}]`)
97
+ return
98
+ }
65
99
  if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
66
100
  out.push(`Contract must be ${right.value}`)
67
101
  return
@@ -23,6 +23,7 @@ export function renderVecElement(leaf: PredicateLeaf): string {
23
23
  case 'call_arg':
24
24
  case 'call_arg_len':
25
25
  case 'call_arg_field':
26
+ case 'call_arg_scaled':
26
27
  return `<${leaf.kind}>`
27
28
  }
28
29
  }
@@ -38,10 +39,16 @@ export function renderHaystackElement(leaf: PredicateLeaf): string {
38
39
  return `<${leaf.kind}>`
39
40
  }
40
41
 
41
- export function comparisonOpText(op: 'eq' | 'lte'): string {
42
+ export function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string {
42
43
  switch (op) {
44
+ case 'lt':
45
+ return '<'
43
46
  case 'lte':
44
47
  return '<='
48
+ case 'gt':
49
+ return '>'
50
+ case 'gte':
51
+ return '>='
45
52
  case 'eq':
46
53
  return '=='
47
54
  }