@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
package/src/predicate/decode.ts
CHANGED
|
@@ -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,
|
package/src/predicate/encode.ts
CHANGED
|
@@ -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 '
|
|
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 '
|
|
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
|
-
|
|
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':
|
package/src/record/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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").
|
package/src/registry/identify.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
}
|