@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.
- package/dist/adapters/interpreter/adapter.d.ts +2 -2
- package/dist/adapters/interpreter/adapter.js +11 -3
- package/dist/errors.d.ts +6 -1
- package/dist/install/authority-overlap.d.ts +101 -0
- package/dist/install/authority-overlap.js +239 -0
- package/dist/install/build-add-context-rule.d.ts +1 -1
- package/dist/install/index.d.ts +1 -0
- package/dist/install/index.js +4 -0
- package/dist/install/read-account-rules.d.ts +79 -0
- package/dist/install/read-account-rules.js +241 -0
- package/dist/predicate/decode.js +22 -1
- package/dist/predicate/encode.js +52 -5
- package/dist/predicate/from-json.js +14 -1
- package/dist/record/index.d.ts +10 -0
- package/dist/record/index.js +32 -1
- package/dist/record/rpc.d.ts +4 -0
- package/dist/record/rpc.js +4 -1
- package/dist/registry/identify.d.ts +10 -1
- package/dist/registry/identify.js +4 -1
- package/dist/registry/on-chain-spec.d.ts +37 -0
- package/dist/registry/on-chain-spec.js +152 -0
- package/dist/review-card/builder.js +40 -0
- package/dist/review-card/cross-check.js +34 -0
- package/dist/review-card/render-leaf.d.ts +1 -1
- package/dist/review-card/render-leaf.js +7 -0
- package/dist/run/index.d.ts +25 -4
- package/dist/run/index.js +108 -4
- package/dist/run/schemas.d.ts +350 -6
- package/dist/run/schemas.js +95 -6
- package/dist/simulate/deny-cases.js +11 -0
- package/dist/simulate/evaluate.js +86 -5
- package/dist/synth/declare.d.ts +43 -0
- package/dist/synth/declare.js +127 -0
- package/dist/synth/index.d.ts +1 -0
- package/dist/synth/index.js +1 -0
- package/dist/synth/synthesize-from-recording.js +1 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.js +1 -1
- package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
- package/dist-cjs/adapters/interpreter/adapter.js +11 -3
- package/dist-cjs/errors.d.ts +6 -1
- package/dist-cjs/install/authority-overlap.d.ts +101 -0
- package/dist-cjs/install/authority-overlap.js +248 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
- package/dist-cjs/install/index.d.ts +1 -0
- package/dist-cjs/install/index.js +13 -2
- package/dist-cjs/install/read-account-rules.d.ts +79 -0
- package/dist-cjs/install/read-account-rules.js +252 -0
- package/dist-cjs/predicate/decode.js +22 -1
- package/dist-cjs/predicate/encode.js +52 -5
- package/dist-cjs/predicate/from-json.js +14 -1
- package/dist-cjs/record/index.d.ts +10 -0
- package/dist-cjs/record/index.js +31 -0
- package/dist-cjs/record/rpc.d.ts +4 -0
- package/dist-cjs/record/rpc.js +7 -3
- package/dist-cjs/registry/identify.d.ts +10 -1
- package/dist-cjs/registry/identify.js +4 -0
- package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
- package/dist-cjs/registry/on-chain-spec.js +159 -0
- package/dist-cjs/review-card/builder.js +40 -0
- package/dist-cjs/review-card/cross-check.js +34 -0
- package/dist-cjs/review-card/render-leaf.d.ts +1 -1
- package/dist-cjs/review-card/render-leaf.js +7 -0
- package/dist-cjs/run/index.d.ts +25 -4
- package/dist-cjs/run/index.js +108 -2
- package/dist-cjs/run/schemas.d.ts +350 -6
- package/dist-cjs/run/schemas.js +96 -7
- package/dist-cjs/simulate/deny-cases.js +11 -0
- package/dist-cjs/simulate/evaluate.js +86 -5
- package/dist-cjs/synth/declare.d.ts +43 -0
- package/dist-cjs/synth/declare.js +130 -0
- package/dist-cjs/synth/index.d.ts +1 -0
- package/dist-cjs/synth/index.js +3 -1
- package/dist-cjs/synth/synthesize-from-recording.js +1 -1
- package/dist-cjs/types.d.ts +21 -1
- package/dist-cjs/types.js +1 -1
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +13 -5
- package/src/errors.ts +5 -0
- package/src/install/authority-overlap.ts +323 -0
- package/src/install/index.ts +20 -0
- package/src/install/read-account-rules.ts +313 -0
- package/src/predicate/decode.ts +22 -1
- package/src/predicate/encode.ts +55 -5
- package/src/predicate/from-json.ts +14 -1
- package/src/record/index.ts +59 -2
- package/src/record/rpc.ts +4 -1
- package/src/registry/identify.ts +4 -1
- package/src/registry/on-chain-spec.ts +168 -0
- package/src/review-card/builder.ts +45 -2
- package/src/review-card/cross-check.ts +35 -1
- package/src/review-card/render-leaf.ts +8 -1
- package/src/run/index.ts +125 -2
- package/src/run/schemas.ts +100 -6
- package/src/simulate/deny-cases.ts +12 -1
- package/src/simulate/evaluate.ts +101 -9
- package/src/synth/declare.ts +206 -0
- package/src/synth/index.ts +5 -0
- package/src/synth/synthesize-from-recording.ts +1 -1
- 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 '
|
|
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 '
|
|
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
|
-
|
|
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>;
|
package/dist-cjs/record/index.js
CHANGED
|
@@ -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) {
|
package/dist-cjs/record/rpc.d.ts
CHANGED
|
@@ -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;
|
package/dist-cjs/record/rpc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|