@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
@@ -83,7 +83,8 @@ function computeStats(node) {
83
83
  }
84
84
  function walk(node, inCounts, counters) {
85
85
  switch (node.op) {
86
- case 'and': {
86
+ case 'and':
87
+ case 'or': {
87
88
  if (node.children.length === 0) {
88
89
  throw capError('MALFORMED_PREDICATE', `\`${node.op}\` with no children: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed`);
89
90
  }
@@ -98,7 +99,10 @@ function walk(node, inCounts, counters) {
98
99
  return { depth: maxChildDepth + 1, leaves: totalLeaves };
99
100
  }
100
101
  case 'eq':
101
- case 'lte': {
102
+ case 'lt':
103
+ case 'lte':
104
+ case 'gt':
105
+ case 'gte': {
102
106
  collectSelector(node.left, counters);
103
107
  collectSelector(node.right, counters);
104
108
  return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) };
@@ -146,14 +150,20 @@ function collectSelector(leaf, counters) {
146
150
  }
147
151
  function encodeNode(node) {
148
152
  switch (node.op) {
149
- case 'and': {
153
+ case 'and':
154
+ case 'or': {
150
155
  const encoded = node.children.map(encodeNode);
151
- // sort children by their canonical XDR bytes ascending.
156
+ // sort children by their canonical XDR bytes ascending. Both operators
157
+ // are commutative for the permit decision, so sorting costs no meaning
158
+ // and buys a stable hash for logically-identical predicates.
152
159
  const sorted = sortByCanonicalBytes(encoded);
153
160
  return stellar_sdk_1.xdr.ScVal.scvVec([symbol(node.op), stellar_sdk_1.xdr.ScVal.scvVec(sorted)]);
154
161
  }
155
162
  case 'eq':
156
- case 'lte': {
163
+ case 'lt':
164
+ case 'lte':
165
+ case 'gt':
166
+ case 'gte': {
157
167
  return stellar_sdk_1.xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)]);
158
168
  }
159
169
  case 'in': {
@@ -184,6 +194,15 @@ function encodeLeaf(leaf) {
184
194
  stellar_sdk_1.xdr.ScVal.scvU32(leaf.element),
185
195
  stellar_sdk_1.xdr.ScVal.scvSymbol(leaf.field),
186
196
  ]);
197
+ case 'call_arg_scaled':
198
+ // num/den are i128 on the wire. The Rust decoder type-checks both
199
+ // slots, so a u32 here would be refused rather than widened.
200
+ return stellar_sdk_1.xdr.ScVal.scvVec([
201
+ symbol('call_arg_scaled'),
202
+ stellar_sdk_1.xdr.ScVal.scvU32(leaf.index),
203
+ scvI128FromDecimal(leaf.num),
204
+ scvI128FromDecimal(leaf.den),
205
+ ]);
187
206
  case 'literal_address':
188
207
  return scvAddressFromStrkey(leaf.value);
189
208
  case 'literal_i128':
@@ -269,6 +288,30 @@ function validateLeafValues(node) {
269
288
  throw malformed(`call_arg_field.element out of u32 range at ${path}`);
270
289
  }
271
290
  return;
291
+ case 'call_arg_scaled': {
292
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
293
+ throw malformed(`call_arg_scaled.index out of u32 range at ${path}`);
294
+ }
295
+ // Mirror of the contract's install gate (214). Without it the TS
296
+ // self-verify would green-light a ratio the chain refuses, which is
297
+ // exactly the divergence this validator exists to prevent.
298
+ let num;
299
+ let den;
300
+ try {
301
+ num = BigInt(leaf.num);
302
+ den = BigInt(leaf.den);
303
+ }
304
+ catch {
305
+ throw malformed(`call_arg_scaled num/den must be i128 decimal strings at ${path}`);
306
+ }
307
+ if (den === 0n) {
308
+ throw malformed(`call_arg_scaled.den is zero at ${path}: the contract refuses it at install (INVALID_SCALED_RATIO)`);
309
+ }
310
+ if (num <= 0n || den < 0n) {
311
+ throw malformed(`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)`);
312
+ }
313
+ return;
314
+ }
272
315
  case 'call_contract':
273
316
  case 'call_fn':
274
317
  case 'literal_address':
@@ -279,12 +322,16 @@ function validateLeafValues(node) {
279
322
  function walkNode(n, path) {
280
323
  switch (n.op) {
281
324
  case 'and':
325
+ case 'or':
282
326
  n.children.forEach((c, i) => {
283
327
  walkNode(c, `${path}.children[${i}]`);
284
328
  });
285
329
  return;
286
330
  case 'eq':
331
+ case 'lt':
287
332
  case 'lte':
333
+ case 'gt':
334
+ case 'gte':
288
335
  walkLeaf(n.left, `${path}.left`);
289
336
  walkLeaf(n.right, `${path}.right`);
290
337
  return;
@@ -20,9 +20,13 @@ function jsonToAst(value) {
20
20
  const v = value;
21
21
  switch (v.op) {
22
22
  case 'and':
23
- return { op: 'and', children: arrayOf(v.children, jsonToAst) };
23
+ case 'or':
24
+ return { op: v.op, children: arrayOf(v.children, jsonToAst) };
24
25
  case 'eq':
26
+ case 'lt':
25
27
  case 'lte':
28
+ case 'gt':
29
+ case 'gte':
26
30
  return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) };
27
31
  case 'in':
28
32
  return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) };
@@ -47,6 +51,15 @@ function jsonToLeaf(value) {
47
51
  return { kind: 'call_arg', index: numberField(v, 'index') };
48
52
  case 'call_arg_len':
49
53
  return { kind: 'call_arg_len', index: numberField(v, 'index') };
54
+ case 'call_arg_scaled':
55
+ // num/den stay decimal STRINGS: an i128 ratio does not survive a JS
56
+ // number, and silently rounding one would change the floor.
57
+ return {
58
+ kind: 'call_arg_scaled',
59
+ index: numberField(v, 'index'),
60
+ num: stringField(v, 'num'),
61
+ den: stringField(v, 'den'),
62
+ };
50
63
  case 'literal_address':
51
64
  return { kind: 'literal_address', value: stringField(v, 'value') };
52
65
  case 'literal_i128':
@@ -1,4 +1,5 @@
1
1
  import type { ToolResponse } from '../errors.ts';
2
+ import { type SpecFetcher } from '../registry/on-chain-spec.ts';
2
3
  import type { Network, RecordedTransaction } from '../types.ts';
3
4
  import { type RpcFetcher } from './rpc.ts';
4
5
  /** Public input shape. The brief pins:
@@ -27,6 +28,15 @@ export interface RecordInput {
27
28
  * automatically; tests can pass a deterministic stub. */
28
29
  crossNetworkFetcher?: RpcFetcher;
29
30
  confidenceOverride?: number;
31
+ /** Read a contract's own interface off chain when the compiled-in registry
32
+ * does not recognise it. Default ON: the registry covers the protocols we
33
+ * pinned by hand, and refusing everything else reported `no-abi` for
34
+ * contracts that publish a full typed spec. Set false to record against
35
+ * the registry alone (no extra RPC). */
36
+ resolveContractSpecs?: boolean;
37
+ /** Test seam for the spec lookup. Unset in production, where it is built
38
+ * from the network's pinned RPC URL. */
39
+ specFetcher?: SpecFetcher;
30
40
  }
31
41
  export type RecordResult = ToolResponse<RecordedTransaction>;
32
42
  export declare function recordTransaction(input: RecordInput): Promise<RecordResult>;
@@ -19,11 +19,40 @@
19
19
  // defined in src/errors.ts.
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.recordTransaction = recordTransaction;
22
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
23
+ const on_chain_spec_ts_1 = require("../registry/on-chain-spec.js");
22
24
  const decode_ts_1 = require("./decode.js");
23
25
  const freshness_ts_1 = require("./freshness.js");
24
26
  const movements_ts_1 = require("./movements.js");
25
27
  const rpc_ts_1 = require("./rpc.js");
26
28
  const validate_ts_1 = require("./validate.js");
29
+ /** Second pass over the contracts the compiled-in registry did not recognise.
30
+ *
31
+ * Each candidate's own interface is read off chain and every call it received
32
+ * is checked against it; the ones that verify are fed back through the decoder
33
+ * as known. Re-decoding rather than patching the first result keeps ONE code
34
+ * path computing parseConfidence - a hand-adjusted count here would be a
35
+ * second implementation of the gate, free to drift from the real one.
36
+ *
37
+ * Only ever ADDS recognition. A missing spec, an unreachable RPC or a call the
38
+ * interface does not describe all leave the recording exactly as it was. */
39
+ async function resolveByOnChainSpec(input, decoded, redecode) {
40
+ if (input.resolveContractSpecs === false)
41
+ return decoded;
42
+ if (decoded.unknownContracts.length === 0)
43
+ return decoded;
44
+ const fetcher = input.specFetcher ??
45
+ (0, on_chain_spec_ts_1.specFetcherFromRpc)(rpc_ts_1.PUBLIC_RPC_URLS[input.network], input.network === 'mainnet' ? stellar_sdk_1.Networks.PUBLIC : stellar_sdk_1.Networks.TESTNET);
46
+ let resolved;
47
+ try {
48
+ resolved = await (0, on_chain_spec_ts_1.resolveContractsByOnChainSpec)(decoded.invocations, decoded.unknownContracts.map((u) => u.contract), fetcher);
49
+ }
50
+ catch {
51
+ // A lookup failure must not fail the recording that already succeeded.
52
+ return decoded;
53
+ }
54
+ return resolved.size === 0 ? decoded : redecode(resolved);
55
+ }
27
56
  async function recordTransaction(input) {
28
57
  if (!input.network) {
29
58
  return err('RECORDING_FAILED', 'network required', false);
@@ -79,6 +108,7 @@ async function recordTransaction(input) {
79
108
  return err('RECORDING_FAILED', e.message, false);
80
109
  throw e;
81
110
  }
111
+ decoded = await resolveByOnChainSpec(input, decoded, (known) => (0, decode_ts_1.decodeEnvelope)(fetched.envelopeXdr, events, [], fetched.ledger, known, input.network));
82
112
  return finish(input.network, decoded, input.confidenceOverride);
83
113
  }
84
114
  // XDR mode has no raw on-chain events, so the events-based cross-check is
@@ -96,6 +126,7 @@ async function recordTransaction(input) {
96
126
  return err('RECORDING_FAILED', e.message, false);
97
127
  return err('RECORDING_FAILED', `failed to decode base64 envelope XDR: ${e.message}`, false);
98
128
  }
129
+ decoded = await resolveByOnChainSpec(input, decoded, (known) => (0, decode_ts_1.decodeEnvelopeXdr)(xdrStr, [], [], 0, known, input.network));
99
130
  return finish(input.network, decoded, input.confidenceOverride);
100
131
  }
101
132
  function combineEvents(raw) {
@@ -14,6 +14,10 @@ export interface SorobanTxResponse {
14
14
  events: rpc.Api.TransactionEvents;
15
15
  }
16
16
  export type RpcFetcher = (hash: string) => Promise<SorobanTxResponse | null>;
17
+ /** Exported so the on-chain spec lookup reads from the SAME endpoint the
18
+ * recorder fetched the transaction from. Two different endpoints could
19
+ * disagree about what a contract is. */
20
+ export declare const PUBLIC_RPC_URLS: Record<Network, string>;
17
21
  /** Build a fetcher backed by the public Soroban RPC for the given network.
18
22
  * Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
19
23
  export declare function createRpcServer(network: Network): RpcFetcher;
@@ -5,10 +5,14 @@
5
5
  // Unit tests inject a fetcher so they never hit the network; the integration test
6
6
  // uses the real Soroban RPC through `createRpcServer`.
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.PUBLIC_RPC_URLS = void 0;
8
9
  exports.createRpcServer = createRpcServer;
9
10
  exports.probeNetwork = probeNetwork;
10
11
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
11
- const PUBLIC_RPC_URLS = {
12
+ /** Exported so the on-chain spec lookup reads from the SAME endpoint the
13
+ * recorder fetched the transaction from. Two different endpoints could
14
+ * disagree about what a contract is. */
15
+ exports.PUBLIC_RPC_URLS = {
12
16
  testnet: 'https://soroban-testnet.stellar.org',
13
17
  // The brief pins testnet; mainnet is left to the caller via injection. We keep
14
18
  // a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
@@ -17,7 +21,7 @@ const PUBLIC_RPC_URLS = {
17
21
  /** Build a fetcher backed by the public Soroban RPC for the given network.
18
22
  * Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
19
23
  function createRpcServer(network) {
20
- const server = new stellar_sdk_1.rpc.Server(PUBLIC_RPC_URLS[network], { allowHttp: false });
24
+ const server = new stellar_sdk_1.rpc.Server(exports.PUBLIC_RPC_URLS[network], { allowHttp: false });
21
25
  return async (hash) => {
22
26
  const resp = await server.getTransaction(hash);
23
27
  if (resp.status === stellar_sdk_1.rpc.Api.GetTransactionStatus.NOT_FOUND) {
@@ -50,7 +54,7 @@ function createRpcServer(network) {
50
54
  /** Lightweight reachability probe used by the integration test to decide whether
51
55
  * to run or self-skip. Sends a JSON-RPC `getHealth` to the public endpoint. */
52
56
  async function probeNetwork(network, timeoutMs = 3000) {
53
- const url = PUBLIC_RPC_URLS[network];
57
+ const url = exports.PUBLIC_RPC_URLS[network];
54
58
  const controller = new AbortController();
55
59
  const timer = setTimeout(() => controller.abort(), timeoutMs);
56
60
  try {
@@ -1,5 +1,5 @@
1
1
  import type { ScVal } from '../types.ts';
2
- import type { ProtocolId } from './protocols.ts';
2
+ import type { AbiArg, ProtocolId } from './protocols.ts';
3
3
  export interface IdentifiedProtocol {
4
4
  protocol: ProtocolId;
5
5
  fn: string;
@@ -9,3 +9,12 @@ export interface IdentifiedProtocol {
9
9
  * off, or unknown method on a pinned address). The caller MUST keep the
10
10
  * null path fail-closed. */
11
11
  export declare function identifyProtocol(contract: string, method: string, args: ScVal[], network?: 'mainnet' | 'testnet'): IdentifiedProtocol | null;
12
+ /** Compare the decoded args against the ABI signature. Returns true only when
13
+ * the arg count matches AND every arg's ScVal subset type matches the ABI's
14
+ * declared type. `other` is intentionally NOT a valid ABI match - it means
15
+ * the decoder couldn't classify the value, which is exactly the signal
16
+ * fail-closed should refuse. */
17
+ /** Exported so the on-chain-spec path checks a call the SAME way a pinned
18
+ * protocol does. Reimplementing it there would let the two drift, and a
19
+ * looser copy would be the fail-OPEN direction. */
20
+ export declare function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean;
@@ -31,6 +31,7 @@
31
31
  // ScVal subset type against the ABI signature; mismatch -> null.
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.identifyProtocol = identifyProtocol;
34
+ exports.argsMatchAbi = argsMatchAbi;
34
35
  const known_addresses_ts_1 = require("./known-addresses.js");
35
36
  const protocols_ts_1 = require("./protocols.js");
36
37
  /** Identify the protocol for a single (contract, method, args) invocation.
@@ -91,6 +92,9 @@ function identifyProtocol(contract, method, args, network) {
91
92
  * declared type. `other` is intentionally NOT a valid ABI match - it means
92
93
  * the decoder couldn't classify the value, which is exactly the signal
93
94
  * fail-closed should refuse. */
95
+ /** Exported so the on-chain-spec path checks a call the SAME way a pinned
96
+ * protocol does. Reimplementing it there would let the two drift, and a
97
+ * looser copy would be the fail-OPEN direction. */
94
98
  function argsMatchAbi(expected, actual) {
95
99
  if (expected.length !== actual.length)
96
100
  return false;
@@ -0,0 +1,37 @@
1
+ import { contract as sdkContract } from '@stellar/stellar-sdk';
2
+ import type { ContractInvocation } from '../types.ts';
3
+ import type { AbiArgType, ProtocolAbi } from './protocols.ts';
4
+ /** Map an XDR spec type to the ScVal subset vocabulary the matcher uses.
5
+ *
6
+ * Returns null for a type the recorder's ScVal subset cannot represent. That
7
+ * is deliberate: an argument we cannot type is an argument we cannot check,
8
+ * and claiming a match on it would be the fail-OPEN direction. A function
9
+ * with any such argument is dropped from the derived ABI, so a call to it
10
+ * stays unrecognised rather than being waved through. */
11
+ export declare function abiTypeFromSpecType(specTypeName: string): AbiArgType | null;
12
+ /** The RPC surface this module needs. Narrowed to one method so a test can
13
+ * supply a stub without standing up a server, and so the recorder's existing
14
+ * RPC client can be passed straight in. */
15
+ export interface SpecFetcher {
16
+ contractSpec(contractId: string): Promise<sdkContract.Spec | null>;
17
+ }
18
+ /** Build a `SpecFetcher` over the SDK's contract client. */
19
+ export declare function specFetcherFromRpc(rpcUrl: string, networkPassphrase: string): SpecFetcher;
20
+ /** Convert a fetched spec into the ABI shape `identifyProtocol` already
21
+ * matches against. Functions with an argument outside the ScVal subset are
22
+ * OMITTED rather than partially typed. */
23
+ export declare function abiFromSpec(spec: sdkContract.Spec): ProtocolAbi;
24
+ /** Fetch and convert in one step. Returns null when no usable interface was
25
+ * obtained, which the caller must treat as "unrecognised". */
26
+ export declare function fetchContractAbi(contractId: string, fetcher: SpecFetcher): Promise<ProtocolAbi | null>;
27
+ /** Contracts whose EVERY recorded call matches their own published interface.
28
+ *
29
+ * All-or-nothing per contract, deliberately. A contract where one call
30
+ * verifies and another does not is a contract we do not understand, and
31
+ * marking it recognised would raise confidence on the strength of the call
32
+ * we happened to check. The unverified call is the one that matters.
33
+ *
34
+ * A contract with no fetchable spec, or a call naming a function absent from
35
+ * it, simply stays unknown - this only ever ADDS recognition, so a failure
36
+ * here degrades to today's behaviour rather than to a wrong answer. */
37
+ export declare function resolveContractsByOnChainSpec(invocations: ReadonlyArray<ContractInvocation>, candidates: ReadonlyArray<string>, fetcher: SpecFetcher): Promise<Set<string>>;
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ // src/registry/on-chain-spec.ts - read a contract's own interface off chain.
3
+ //
4
+ // The compiled-in registry covers the protocols we pinned by hand. Everything
5
+ // else was reported as `no-abi`, which read as "this contract does not
6
+ // describe itself" when it almost always does: a Soroban contract embeds a
7
+ // typed spec in its wasm, and the network will hand it over. The recorder was
8
+ // refusing calls whose interface was one RPC round-trip away.
9
+ //
10
+ // What this buys is NARROW and deliberately so. A fetched spec says what the
11
+ // contract's arguments ARE, not what they MEAN. So a call verified against it
12
+ // is recognised - the decode is trustworthy, the confidence gate stops
13
+ // refusing it - but the arguments carry the contract's own parameter NAMES
14
+ // rather than a curated meaning, and nothing here infers which argument is a
15
+ // spend or a recipient. A pinned protocol still outranks a fetched spec for
16
+ // exactly that reason, and `identifyProtocol` is consulted first.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.abiTypeFromSpecType = abiTypeFromSpecType;
19
+ exports.specFetcherFromRpc = specFetcherFromRpc;
20
+ exports.abiFromSpec = abiFromSpec;
21
+ exports.fetchContractAbi = fetchContractAbi;
22
+ exports.resolveContractsByOnChainSpec = resolveContractsByOnChainSpec;
23
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
24
+ const identify_ts_1 = require("./identify.js");
25
+ /** Map an XDR spec type to the ScVal subset vocabulary the matcher uses.
26
+ *
27
+ * Returns null for a type the recorder's ScVal subset cannot represent. That
28
+ * is deliberate: an argument we cannot type is an argument we cannot check,
29
+ * and claiming a match on it would be the fail-OPEN direction. A function
30
+ * with any such argument is dropped from the derived ABI, so a call to it
31
+ * stays unrecognised rather than being waved through. */
32
+ function abiTypeFromSpecType(specTypeName) {
33
+ switch (specTypeName) {
34
+ case 'scSpecTypeAddress':
35
+ return 'address';
36
+ case 'scSpecTypeI128':
37
+ return 'i128';
38
+ case 'scSpecTypeU64':
39
+ case 'scSpecTypeI64':
40
+ return 'u64';
41
+ case 'scSpecTypeU32':
42
+ case 'scSpecTypeI32':
43
+ return 'u32';
44
+ case 'scSpecTypeSymbol':
45
+ return 'symbol';
46
+ case 'scSpecTypeVec':
47
+ return 'vec';
48
+ case 'scSpecTypeBytes':
49
+ case 'scSpecTypeBytesN':
50
+ return 'bytes';
51
+ case 'scSpecTypeMap':
52
+ return 'map';
53
+ default:
54
+ return null;
55
+ }
56
+ }
57
+ /** Build a `SpecFetcher` over the SDK's contract client. */
58
+ function specFetcherFromRpc(rpcUrl, networkPassphrase) {
59
+ return {
60
+ async contractSpec(contractId) {
61
+ try {
62
+ const client = await stellar_sdk_1.contract.Client.from({ contractId, networkPassphrase, rpcUrl });
63
+ return client.spec ?? null;
64
+ }
65
+ catch {
66
+ // A contract with no spec, an unreachable RPC and a bad address all
67
+ // land here and all mean the same thing to the caller: no interface
68
+ // was obtained, so the contract stays unrecognised. Fail closed.
69
+ return null;
70
+ }
71
+ },
72
+ };
73
+ }
74
+ /** Convert a fetched spec into the ABI shape `identifyProtocol` already
75
+ * matches against. Functions with an argument outside the ScVal subset are
76
+ * OMITTED rather than partially typed. */
77
+ function abiFromSpec(spec) {
78
+ const abi = {};
79
+ for (const fn of spec.funcs()) {
80
+ const name = fn.name().toString();
81
+ // The constructor is not callable after deployment, so a recorded
82
+ // invocation can never be one. Keeping it would only widen the surface.
83
+ if (name === '__constructor')
84
+ continue;
85
+ const args = [];
86
+ let usable = true;
87
+ for (const input of fn.inputs()) {
88
+ const type = abiTypeFromSpecType(input.type().switch().name);
89
+ if (type === null) {
90
+ usable = false;
91
+ break;
92
+ }
93
+ const argName = input.name().toString();
94
+ args.push({
95
+ name: argName,
96
+ type,
97
+ // The contract's own parameter name is the honest description. It is
98
+ // NOT a curated meaning - nothing here knows whether `value` is a
99
+ // spend - so downstream must not treat it as one.
100
+ meaning: `${argName} (from the contract's on-chain interface)`,
101
+ });
102
+ }
103
+ if (usable)
104
+ abi[name] = { args };
105
+ }
106
+ return abi;
107
+ }
108
+ /** Fetch and convert in one step. Returns null when no usable interface was
109
+ * obtained, which the caller must treat as "unrecognised". */
110
+ async function fetchContractAbi(contractId, fetcher) {
111
+ const spec = await fetcher.contractSpec(contractId);
112
+ if (spec === null)
113
+ return null;
114
+ const abi = abiFromSpec(spec);
115
+ return Object.keys(abi).length === 0 ? null : abi;
116
+ }
117
+ /** Every contract invoked anywhere in the tree, with the calls made on it. */
118
+ function callsByContract(invocations) {
119
+ const out = new Map();
120
+ const walk = (inv) => {
121
+ const list = out.get(inv.contract) ?? [];
122
+ list.push({ fn: inv.fn, args: inv.args });
123
+ out.set(inv.contract, list);
124
+ for (const sub of inv.subInvocations)
125
+ walk(sub);
126
+ };
127
+ for (const inv of invocations)
128
+ walk(inv);
129
+ return out;
130
+ }
131
+ /** Contracts whose EVERY recorded call matches their own published interface.
132
+ *
133
+ * All-or-nothing per contract, deliberately. A contract where one call
134
+ * verifies and another does not is a contract we do not understand, and
135
+ * marking it recognised would raise confidence on the strength of the call
136
+ * we happened to check. The unverified call is the one that matters.
137
+ *
138
+ * A contract with no fetchable spec, or a call naming a function absent from
139
+ * it, simply stays unknown - this only ever ADDS recognition, so a failure
140
+ * here degrades to today's behaviour rather than to a wrong answer. */
141
+ async function resolveContractsByOnChainSpec(invocations, candidates, fetcher) {
142
+ const resolved = new Set();
143
+ const calls = callsByContract(invocations);
144
+ for (const contract of new Set(candidates)) {
145
+ const made = calls.get(contract);
146
+ if (!made || made.length === 0)
147
+ continue;
148
+ const abi = await fetchContractAbi(contract, fetcher);
149
+ if (abi === null)
150
+ continue;
151
+ const everyCallVerifies = made.every((c) => {
152
+ const entry = abi[c.fn];
153
+ return entry !== undefined && (0, identify_ts_1.argsMatchAbi)(entry.args, c.args);
154
+ });
155
+ if (everyCallVerifies)
156
+ resolved.add(contract);
157
+ }
158
+ return resolved;
159
+ }
@@ -84,15 +84,31 @@ function walkPredicate(node, visit) {
84
84
  for (const child of node.children)
85
85
  walkPredicate(child, visit);
86
86
  return;
87
+ // NOT descended into. Every line the card emits reads as a requirement,
88
+ // and `and` is what makes that true. Listing an `or`'s branches as
89
+ // separate lines would state the opposite of what the policy means, so
90
+ // the whole disjunction is rendered as ONE line instead.
91
+ case 'or':
92
+ visit(node);
93
+ return;
87
94
  case 'in':
88
95
  visit(node);
89
96
  return;
90
97
  case 'eq':
98
+ case 'lt':
91
99
  case 'lte':
100
+ case 'gt':
101
+ case 'gte':
92
102
  visit(node);
93
103
  return;
94
104
  }
95
105
  }
106
+ /** Argument index of a `call_arg` leaf, for the scaled-comparison line. Any
107
+ * other leaf renders as its kind so the line stays readable rather than
108
+ * claiming an index that does not exist. */
109
+ function leftArgLabel(leaf) {
110
+ return leaf.kind === 'call_arg' ? String(leaf.index) : `<${leaf.kind}>`;
111
+ }
96
112
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
97
113
  * shape of the output is pinned by Task 7b so the test suite can assert
98
114
  * byte-for-byte equality. Returns `null` when the node is a structural
@@ -101,14 +117,38 @@ function renderConstraint(node) {
101
117
  switch (node.op) {
102
118
  case 'and':
103
119
  return null;
120
+ case 'or': {
121
+ // One line for the whole disjunction. If any branch is a shape the
122
+ // card cannot render, the entire line is withheld rather than shown
123
+ // with a branch missing - a disjunction with a branch dropped reads
124
+ // as STRICTER than it is, which is the dangerous direction.
125
+ const parts = node.children.map(renderConstraint);
126
+ if (parts.some((p) => p === null))
127
+ return null;
128
+ return `Either: ${parts.join(' OR ')}`;
129
+ }
104
130
  case 'eq':
131
+ case 'lt':
105
132
  case 'lte':
133
+ case 'gt':
134
+ case 'gte':
106
135
  return renderComparison(node);
107
136
  case 'in':
108
137
  return renderMembership(node);
109
138
  }
110
139
  }
111
140
  function renderComparison(node) {
141
+ // The slippage floor: OP(call_arg[out], call_arg_scaled(in, num, den)).
142
+ // Rendered explicitly because the human approving the signature has to see
143
+ // that the bound is a RATIO of another argument, not a fixed amount.
144
+ if (node.right.kind === 'call_arg_scaled') {
145
+ const s = node.right;
146
+ return `arg[${leftArgLabel(node.left)}] ${(0, render_leaf_ts_1.comparisonOpText)(node.op)} arg[${s.index}] * ${s.num}/${s.den}`;
147
+ }
148
+ if (node.left.kind === 'call_arg_scaled') {
149
+ const s = node.left;
150
+ return `arg[${s.index}] * ${s.num}/${s.den} ${(0, render_leaf_ts_1.comparisonOpText)(node.op)} arg[${leftArgLabel(node.right)}]`;
151
+ }
112
152
  const left = node.left;
113
153
  const right = node.right;
114
154
  // eq(call_contract, literal_address) -> Contract must be <addr>
@@ -42,8 +42,30 @@ function collect(node, out) {
42
42
  for (const child of node.children)
43
43
  collect(child, out);
44
44
  return;
45
+ // ONE line for the whole disjunction, mirroring the builder. Emitting a
46
+ // line per branch would claim every branch is required, which is the
47
+ // opposite of what `or` means. If any branch renders to nothing the whole
48
+ // line is withheld, again mirroring the builder - a disjunction missing a
49
+ // branch reads STRICTER than it is.
50
+ case 'or': {
51
+ const parts = [];
52
+ for (const child of node.children) {
53
+ const childOut = [];
54
+ collect(child, childOut);
55
+ if (childOut.length !== 1)
56
+ return;
57
+ parts.push(childOut[0]);
58
+ }
59
+ if (parts.length === 0)
60
+ return;
61
+ out.push(`Either: ${parts.join(' OR ')}`);
62
+ return;
63
+ }
45
64
  case 'eq':
65
+ case 'lt':
46
66
  case 'lte':
67
+ case 'gt':
68
+ case 'gte':
47
69
  pushComparison(node.left, node.right, node.op, out);
48
70
  return;
49
71
  case 'in':
@@ -52,6 +74,18 @@ function collect(node, out) {
52
74
  }
53
75
  }
54
76
  function pushComparison(left, right, op, out) {
77
+ // Slippage floor, mirroring the builder. The human has to see that the
78
+ // bound is a RATIO of another argument, not a fixed amount.
79
+ if (right.kind === 'call_arg_scaled') {
80
+ const label = left.kind === 'call_arg' ? String(left.index) : `<${left.kind}>`;
81
+ out.push(`arg[${label}] ${(0, render_leaf_ts_1.comparisonOpText)(op)} arg[${right.index}] * ${right.num}/${right.den}`);
82
+ return;
83
+ }
84
+ if (left.kind === 'call_arg_scaled') {
85
+ const label = right.kind === 'call_arg' ? String(right.index) : `<${right.kind}>`;
86
+ out.push(`arg[${left.index}] * ${left.num}/${left.den} ${(0, render_leaf_ts_1.comparisonOpText)(op)} arg[${label}]`);
87
+ return;
88
+ }
55
89
  if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
56
90
  out.push(`Contract must be ${right.value}`);
57
91
  return;
@@ -1,4 +1,4 @@
1
1
  import type { PredicateLeaf } from '../types.ts';
2
2
  export declare function renderVecElement(leaf: PredicateLeaf): string;
3
3
  export declare function renderHaystackElement(leaf: PredicateLeaf): string;
4
- export declare function comparisonOpText(op: 'eq' | 'lte'): string;
4
+ export declare function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string;
@@ -25,6 +25,7 @@ function renderVecElement(leaf) {
25
25
  case 'call_arg':
26
26
  case 'call_arg_len':
27
27
  case 'call_arg_field':
28
+ case 'call_arg_scaled':
28
29
  return `<${leaf.kind}>`;
29
30
  }
30
31
  }
@@ -44,8 +45,14 @@ function renderHaystackElement(leaf) {
44
45
  }
45
46
  function comparisonOpText(op) {
46
47
  switch (op) {
48
+ case 'lt':
49
+ return '<';
47
50
  case 'lte':
48
51
  return '<=';
52
+ case 'gt':
53
+ return '>';
54
+ case 'gte':
55
+ return '>=';
49
56
  case 'eq':
50
57
  return '==';
51
58
  }