@crediolabs/policy-synth 0.4.0 → 0.5.1
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.js +12 -0
- package/dist/install/build-add-context-rule.d.ts +1 -1
- 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 +21 -1
- 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 +2 -2
- package/dist/run/index.js +52 -9
- package/dist/run/schemas.d.ts +64 -14
- package/dist/run/schemas.js +70 -6
- package/dist/simulate/deny-cases.js +11 -0
- package/dist/simulate/evaluate.js +86 -5
- package/dist/synth/declare.d.ts +13 -0
- package/dist/synth/declare.js +34 -5
- 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.js +12 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
- 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 +21 -1
- 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 +2 -2
- package/dist-cjs/run/index.js +54 -9
- package/dist-cjs/run/schemas.d.ts +64 -14
- package/dist-cjs/run/schemas.js +71 -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 +13 -0
- package/dist-cjs/synth/declare.js +34 -5
- 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 +11 -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 +21 -1
- 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 +57 -8
- package/src/run/schemas.ts +83 -6
- package/src/simulate/deny-cases.ts +12 -1
- package/src/simulate/evaluate.ts +101 -9
- package/src/synth/declare.ts +54 -5
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +16 -1
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
// first: it constrains the amount in THIS call rather than implying a rolling
|
|
17
17
|
// total nothing tracks.
|
|
18
18
|
//
|
|
19
|
-
// What a declaration can say maps one-to-one onto grammar
|
|
20
|
-
// fn
|
|
21
|
-
// contract
|
|
22
|
-
// maxAmount
|
|
23
|
-
// recipients
|
|
19
|
+
// What a declaration can say maps one-to-one onto grammar 4:
|
|
20
|
+
// fn -> eq(call_fn, literal_symbol)
|
|
21
|
+
// contract -> eq(call_contract, literal_address)
|
|
22
|
+
// maxAmount -> lte(call_arg(i), literal_i128)
|
|
23
|
+
// recipients -> in(call_arg(j), [literal_address, ...])
|
|
24
|
+
// minOutputRatio-> gte(call_arg(out), call_arg_scaled(in, num, den))
|
|
24
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
26
|
exports.declarePredicate = declarePredicate;
|
|
26
27
|
const address_ts_1 = require("./address.js");
|
|
@@ -87,6 +88,34 @@ function declarePredicate(d) {
|
|
|
87
88
|
right: { kind: 'literal_i128', value: d.maxAmount },
|
|
88
89
|
});
|
|
89
90
|
}
|
|
91
|
+
if (d.minOutputRatio !== undefined) {
|
|
92
|
+
const { num, den, inputArgIndex, outputArgIndex } = d.minOutputRatio;
|
|
93
|
+
if (!/^[0-9]+$/.test(num) || !/^[0-9]+$/.test(den)) {
|
|
94
|
+
throw declareError('SYNTHESIS_ERROR', `minOutputRatio num/den must be unsigned integers, got "${num}"/"${den}" (a 1% slippage tolerance is num "99", den "100")`);
|
|
95
|
+
}
|
|
96
|
+
// Both are refused on chain at install (INVALID_SCALED_RATIO). Refusing
|
|
97
|
+
// here too means the caller learns before a transaction is built.
|
|
98
|
+
if (den === '0') {
|
|
99
|
+
throw declareError('SYNTHESIS_ERROR', 'minOutputRatio.den is zero: the ratio has no value');
|
|
100
|
+
}
|
|
101
|
+
if (num === '0') {
|
|
102
|
+
throw declareError('SYNTHESIS_ERROR', 'minOutputRatio.num is zero: the floor would be zero, which constrains nothing. Omit it instead.');
|
|
103
|
+
}
|
|
104
|
+
if (inputArgIndex === outputArgIndex) {
|
|
105
|
+
throw declareError('SYNTHESIS_ERROR', `minOutputRatio bounds arg[${inputArgIndex}] against itself, which is true for any ratio at or below 1 and false above it - never a slippage floor. Pass the distinct input and output positions.`);
|
|
106
|
+
}
|
|
107
|
+
if (BigInt(num) > BigInt(den)) {
|
|
108
|
+
// Demanding MORE out than went in is not slippage protection; it is a
|
|
109
|
+
// rule that denies every honest trade. Loud beats a policy that never
|
|
110
|
+
// permits.
|
|
111
|
+
warnings.push(`minOutputRatio ${num}/${den} is above 1: it requires the output to EXCEED the input, which no ordinary swap satisfies. Check the ratio is not inverted.`);
|
|
112
|
+
}
|
|
113
|
+
children.push({
|
|
114
|
+
op: 'gte',
|
|
115
|
+
left: { kind: 'call_arg', index: outputArgIndex },
|
|
116
|
+
right: { kind: 'call_arg_scaled', index: inputArgIndex, num, den },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
90
119
|
// A single conjunct is emitted bare. `and` with one child encodes to
|
|
91
120
|
// different bytes than the child alone, and the extra node buys nothing.
|
|
92
121
|
const predicate = children.length === 1 ? children[0] : { op: 'and', children };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// src/synth/synthesize-from-recording.ts - recording-path orchestrator.
|
|
3
3
|
//
|
|
4
4
|
// `synthesizeFromRecording` INFERS a bounded policy from a `RecordedTransaction`
|
|
5
|
-
// via the
|
|
5
|
+
// via the composer + interpreter adapter pair.
|
|
6
6
|
// Flow: validate -> parseConfidence gate -> lower -> decideScope -> composeFromRecording
|
|
7
7
|
// -> interpreter compile + self-verify.
|
|
8
8
|
// Same (tx, opts) -> byte-identical ProposedPolicy (no randomness, clock, globals).
|
package/dist-cjs/types.d.ts
CHANGED
|
@@ -124,7 +124,7 @@ export type PolicyRef = {
|
|
|
124
124
|
* `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
|
|
125
125
|
* the wire derives from here; `grammar-version-parity.test.ts` pins it to the
|
|
126
126
|
* contract so a skew cannot pass a green test run. */
|
|
127
|
-
export declare const GRAMMAR_VERSION:
|
|
127
|
+
export declare const GRAMMAR_VERSION: 4;
|
|
128
128
|
/** A serialised predicate document stored against a (smart_account, rule_id) pair. */
|
|
129
129
|
export interface PolicyDocument {
|
|
130
130
|
/** Grammar version baked into the interpreter wasm. Fail-closed strict match at install
|
|
@@ -146,14 +146,29 @@ export interface PolicyDocument {
|
|
|
146
146
|
export type PredicateNode = {
|
|
147
147
|
op: 'and';
|
|
148
148
|
children: PredicateNode[];
|
|
149
|
+
} | {
|
|
150
|
+
op: 'or';
|
|
151
|
+
children: PredicateNode[];
|
|
149
152
|
} | {
|
|
150
153
|
op: 'eq';
|
|
151
154
|
left: PredicateLeaf;
|
|
152
155
|
right: PredicateLeaf;
|
|
156
|
+
} | {
|
|
157
|
+
op: 'lt';
|
|
158
|
+
left: PredicateLeaf;
|
|
159
|
+
right: PredicateLeaf;
|
|
153
160
|
} | {
|
|
154
161
|
op: 'lte';
|
|
155
162
|
left: PredicateLeaf;
|
|
156
163
|
right: PredicateLeaf;
|
|
164
|
+
} | {
|
|
165
|
+
op: 'gt';
|
|
166
|
+
left: PredicateLeaf;
|
|
167
|
+
right: PredicateLeaf;
|
|
168
|
+
} | {
|
|
169
|
+
op: 'gte';
|
|
170
|
+
left: PredicateLeaf;
|
|
171
|
+
right: PredicateLeaf;
|
|
157
172
|
} | {
|
|
158
173
|
op: 'in';
|
|
159
174
|
needle: PredicateLeaf;
|
|
@@ -174,6 +189,11 @@ export type PredicateLeaf = {
|
|
|
174
189
|
index: number;
|
|
175
190
|
element: number;
|
|
176
191
|
field: string;
|
|
192
|
+
} | {
|
|
193
|
+
kind: 'call_arg_scaled';
|
|
194
|
+
index: number;
|
|
195
|
+
num: string;
|
|
196
|
+
den: string;
|
|
177
197
|
} | {
|
|
178
198
|
kind: 'literal_address';
|
|
179
199
|
value: string;
|
package/dist-cjs/types.js
CHANGED
|
@@ -55,4 +55,4 @@ exports.PREDICATE_CAPS = {
|
|
|
55
55
|
* `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
|
|
56
56
|
* the wire derives from here; `grammar-version-parity.test.ts` pins it to the
|
|
57
57
|
* contract so a skew cannot pass a green test run. */
|
|
58
|
-
exports.GRAMMAR_VERSION =
|
|
58
|
+
exports.GRAMMAR_VERSION = 4;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-synth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
|
|
6
6
|
"type": "module",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// src/adapters/interpreter/adapter.ts - the interpreter-policy
|
|
1
|
+
// src/adapters/interpreter/adapter.ts - the interpreter-policy adapter.
|
|
2
2
|
//
|
|
3
|
-
// Compiles a
|
|
3
|
+
// Compiles a `ComposedRule` to a single interpreter `PolicyDocument` + `PolicyRef`
|
|
4
4
|
// carrying the canonical predicate encoding from `predicate/encode.ts`. This is
|
|
5
5
|
// the only backend; the compose step lowers every constraint to it.
|
|
6
6
|
//
|
|
@@ -41,9 +41,9 @@ export interface InterpreterAdapterConfig {
|
|
|
41
41
|
smartAccountAddress: string
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** The result of compiling a
|
|
44
|
+
/** The result of compiling a `ComposedRule` for this backend. */
|
|
45
45
|
export interface CompileResult {
|
|
46
|
-
/** false => some
|
|
46
|
+
/** false => some constraint this backend cannot express (see `uncovered`). */
|
|
47
47
|
covered: boolean
|
|
48
48
|
/** Human-readable list of unsupported constructs. */
|
|
49
49
|
uncovered: string[]
|
|
@@ -195,14 +195,22 @@ function assertNoSelfCall(node: PredicateNode, config: InterpreterAdapterConfig)
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
switch (node.op) {
|
|
198
|
+
// Both boolean nodes recurse. `or` is listed explicitly rather than left
|
|
199
|
+
// to a default branch: this is a security check, and a new node kind must
|
|
200
|
+
// fail to compile here rather than silently skip it.
|
|
198
201
|
case 'and':
|
|
202
|
+
case 'or':
|
|
199
203
|
for (const c of node.children) assertNoSelfCall(c, config)
|
|
200
204
|
return
|
|
201
205
|
case 'in':
|
|
202
206
|
// The needle is a selector; only the haystack carries literals.
|
|
203
207
|
for (const h of node.haystack) checkLeaf(h)
|
|
204
208
|
return
|
|
205
|
-
|
|
209
|
+
case 'eq':
|
|
210
|
+
case 'lt':
|
|
211
|
+
case 'lte':
|
|
212
|
+
case 'gt':
|
|
213
|
+
case 'gte':
|
|
206
214
|
checkLeaf(node.left)
|
|
207
215
|
checkLeaf(node.right)
|
|
208
216
|
}
|
package/src/errors.ts
CHANGED
|
@@ -43,6 +43,11 @@ export type ErrorCode =
|
|
|
43
43
|
| 'UNSUPPORTED_NODE'
|
|
44
44
|
| 'STATEFUL_BOUND'
|
|
45
45
|
| 'NOT_IN_ALLOWLIST'
|
|
46
|
+
/** A comparison against a `call_arg_scaled` operand failed (contract 107). */
|
|
47
|
+
| 'SLIPPAGE_FLOOR'
|
|
48
|
+
/** A `call_arg_scaled` ratio is zero or non-positive. Refused at install
|
|
49
|
+
* (contract 214) and mirrored at encode. */
|
|
50
|
+
| 'INVALID_SCALED_RATIO'
|
|
46
51
|
// --- escape-hatch compile gate ---
|
|
47
52
|
| 'COMPILE_OK'
|
|
48
53
|
| 'COMPILE_GATE_FAILED'
|
|
@@ -200,6 +200,17 @@ export function permittedSelectors(node: PredicateNode): Selector[] {
|
|
|
200
200
|
for (const child of node.children) acc = intersectSelectors(acc, permittedSelectors(child))
|
|
201
201
|
return acc
|
|
202
202
|
}
|
|
203
|
+
case 'or': {
|
|
204
|
+
// Any branch may hold, so the permitted set is the UNION. A union of
|
|
205
|
+
// over-approximations is still an over-approximation, so this keeps the
|
|
206
|
+
// fail-safe direction while staying tighter than the wildcard the
|
|
207
|
+
// default branch would give. Precision matters here: `or` is how a
|
|
208
|
+
// policy says "pair A or pair B", and widening that to the wildcard
|
|
209
|
+
// would report an overlap against every rule on the account.
|
|
210
|
+
const acc: Selector[] = []
|
|
211
|
+
for (const child of node.children) acc.push(...permittedSelectors(child))
|
|
212
|
+
return dedupe(acc)
|
|
213
|
+
}
|
|
203
214
|
case 'eq': {
|
|
204
215
|
const sel = selectorFromEq(node.left, node.right)
|
|
205
216
|
return sel === null ? [WILDCARD] : [sel]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
//! Reading an OpenZeppelin smart account's context rules back off chain.
|
|
2
|
+
//!
|
|
3
|
+
//! `authority-overlap.ts` needs to know what a signer can already do before a
|
|
4
|
+
//! new policy is installed. That means every rule on the account: its context
|
|
5
|
+
//! type, its signers, its attached policies, and - for rules our interpreter
|
|
6
|
+
//! polices - the predicate itself.
|
|
7
|
+
//!
|
|
8
|
+
//! Without this, the overlap scan can only report on rules the CALLER supplied,
|
|
9
|
+
//! which means it answers "what did you tell me about" rather than "what is on
|
|
10
|
+
//! the account". Those are different questions, and only the second one is
|
|
11
|
+
//! worth anything to someone deciding whether to sign.
|
|
12
|
+
//!
|
|
13
|
+
//! The predicate is NOT reachable through a contract call. The interpreter
|
|
14
|
+
//! publishes only `grammar_version`, `install`, `enforce`, `uninstall` and
|
|
15
|
+
//! `rotate_master_signer_set`, so the stored document is read as a ledger entry
|
|
16
|
+
//! instead. That keeps this a purely client-side capability: adding a getter
|
|
17
|
+
//! would change a deployed contract's ABI and force a redeploy plus re-audit to
|
|
18
|
+
//! obtain data the ledger already exposes.
|
|
19
|
+
//!
|
|
20
|
+
//! The decoders are pure so they can be tested without a network; the caller
|
|
21
|
+
//! supplies raw `ScVal`s.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Account,
|
|
25
|
+
Address,
|
|
26
|
+
BASE_FEE,
|
|
27
|
+
Contract,
|
|
28
|
+
Keypair,
|
|
29
|
+
rpc,
|
|
30
|
+
TransactionBuilder,
|
|
31
|
+
xdr,
|
|
32
|
+
} from '@stellar/stellar-sdk'
|
|
33
|
+
import { decodePredicate } from '../predicate/decode.ts'
|
|
34
|
+
import type { SignerDraft } from '../types.ts'
|
|
35
|
+
import type { ContextType, ObservedRule } from './authority-overlap.ts'
|
|
36
|
+
|
|
37
|
+
/** `storage.rs` - the third element of the persistent doc key tuple. */
|
|
38
|
+
export const K_DOC = 1
|
|
39
|
+
|
|
40
|
+
/** Persistent-storage key for a rule's stored document:
|
|
41
|
+
* `(account, rule_id, K_DOC)`. */
|
|
42
|
+
export function docKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal {
|
|
43
|
+
return xdr.ScVal.scvVec([
|
|
44
|
+
new Address(smartAccount).toScVal(),
|
|
45
|
+
xdr.ScVal.scvU32(ruleId),
|
|
46
|
+
xdr.ScVal.scvU32(K_DOC),
|
|
47
|
+
])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Ledger key for the interpreter's persistent entry holding that document. */
|
|
51
|
+
export function docLedgerKey(
|
|
52
|
+
interpreter: string,
|
|
53
|
+
smartAccount: string,
|
|
54
|
+
ruleId: number
|
|
55
|
+
): xdr.LedgerKey {
|
|
56
|
+
return xdr.LedgerKey.contractData(
|
|
57
|
+
new xdr.LedgerKeyContractData({
|
|
58
|
+
contract: new Address(interpreter).toScAddress(),
|
|
59
|
+
key: docKeyScVal(smartAccount, ruleId),
|
|
60
|
+
durability: xdr.ContractDataDurability.persistent(),
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- ScVal helpers -----
|
|
66
|
+
|
|
67
|
+
/** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
|
|
68
|
+
* by field-name symbol. Returns undefined when the field is absent so a
|
|
69
|
+
* caller can distinguish "not there" from "there and empty". */
|
|
70
|
+
function mapField(v: xdr.ScVal, name: string): xdr.ScVal | undefined {
|
|
71
|
+
if (v.switch() !== xdr.ScValType.scvMap()) return undefined
|
|
72
|
+
for (const entry of v.map() ?? []) {
|
|
73
|
+
const key = entry.key()
|
|
74
|
+
if (key.switch() === xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
|
|
75
|
+
return entry.val()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function u32Of(v: xdr.ScVal | undefined): number | undefined {
|
|
82
|
+
return v?.switch() === xdr.ScValType.scvU32() ? v.u32() : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function addressOf(v: xdr.ScVal | undefined): string | undefined {
|
|
86
|
+
if (!v || v.switch() !== xdr.ScValType.scvAddress()) return undefined
|
|
87
|
+
return Address.fromScAddress(v.address()).toString()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
|
|
91
|
+
function enumVariant(v: xdr.ScVal | undefined): { tag: string; args: xdr.ScVal[] } | undefined {
|
|
92
|
+
if (!v || v.switch() !== xdr.ScValType.scvVec()) return undefined
|
|
93
|
+
const items = v.vec() ?? []
|
|
94
|
+
const head = items[0]
|
|
95
|
+
if (!head || head.switch() !== xdr.ScValType.scvSymbol()) return undefined
|
|
96
|
+
return { tag: head.sym().toString(), args: items.slice(1) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- decoders -----
|
|
100
|
+
|
|
101
|
+
/** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
|
|
102
|
+
* is the widest reading and therefore the safe one: it makes the rule look
|
|
103
|
+
* like it could serve any call, so overlap is over-reported, never missed. */
|
|
104
|
+
export function decodeContextType(v: xdr.ScVal | undefined): ContextType {
|
|
105
|
+
const variant = enumVariant(v)
|
|
106
|
+
if (!variant) return { kind: 'default' }
|
|
107
|
+
if (variant.tag === 'CallContract') {
|
|
108
|
+
const addr = addressOf(variant.args[0])
|
|
109
|
+
return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' }
|
|
110
|
+
}
|
|
111
|
+
if (variant.tag === 'CreateContract') {
|
|
112
|
+
const arg = variant.args[0]
|
|
113
|
+
const hash = arg?.switch() === xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : ''
|
|
114
|
+
return { kind: 'create_contract', wasmHash: hash }
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'default' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
|
|
120
|
+
export function decodeSigner(v: xdr.ScVal): SignerDraft | undefined {
|
|
121
|
+
const variant = enumVariant(v)
|
|
122
|
+
if (!variant) return undefined
|
|
123
|
+
if (variant.tag === 'Delegated') {
|
|
124
|
+
const addr = addressOf(variant.args[0])
|
|
125
|
+
return addr ? { kind: 'delegated', address: addr } : undefined
|
|
126
|
+
}
|
|
127
|
+
if (variant.tag === 'External') {
|
|
128
|
+
const verifier = addressOf(variant.args[0])
|
|
129
|
+
const keyArg = variant.args[1]
|
|
130
|
+
const keyBytes =
|
|
131
|
+
keyArg?.switch() === xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : ''
|
|
132
|
+
return verifier ? { kind: 'external', verifier, keyBytes } : undefined
|
|
133
|
+
}
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
|
|
138
|
+
* `predicate` is filled in separately from the ledger entry. */
|
|
139
|
+
export function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined {
|
|
140
|
+
const id = u32Of(mapField(v, 'id'))
|
|
141
|
+
if (id === undefined) return undefined
|
|
142
|
+
|
|
143
|
+
const signersVal = mapField(v, 'signers')
|
|
144
|
+
const signers: SignerDraft[] = []
|
|
145
|
+
if (signersVal?.switch() === xdr.ScValType.scvVec()) {
|
|
146
|
+
for (const s of signersVal.vec() ?? []) {
|
|
147
|
+
const decoded = decodeSigner(s)
|
|
148
|
+
if (decoded) signers.push(decoded)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const policiesVal = mapField(v, 'policies')
|
|
153
|
+
const policyAddresses: string[] = []
|
|
154
|
+
if (policiesVal?.switch() === xdr.ScValType.scvVec()) {
|
|
155
|
+
for (const p of policiesVal.vec() ?? []) {
|
|
156
|
+
const addr = addressOf(p)
|
|
157
|
+
if (addr) policyAddresses.push(addr)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
contextType: decodeContextType(mapField(v, 'context_type')),
|
|
164
|
+
signers,
|
|
165
|
+
policyAddresses,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The interpreter's `StoredDoc { predicate_bytes }`. */
|
|
170
|
+
export function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined {
|
|
171
|
+
const field = mapField(v, 'predicate_bytes')
|
|
172
|
+
if (!field || field.switch() !== xdr.ScValType.scvBytes()) return undefined
|
|
173
|
+
return field.bytes()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- collection -----
|
|
177
|
+
|
|
178
|
+
/** The three reads the scan needs. Kept as an interface so the collection
|
|
179
|
+
* below is testable without a network. */
|
|
180
|
+
export interface AccountRuleReader {
|
|
181
|
+
/** OZ `get_context_rules_count()`. */
|
|
182
|
+
getContextRuleCount(smartAccount: string): Promise<number>
|
|
183
|
+
/** OZ `get_context_rule(id)`. Undefined when the id is absent. */
|
|
184
|
+
getContextRule(smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>
|
|
185
|
+
/** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
|
|
186
|
+
* Undefined when no document is stored for that rule. */
|
|
187
|
+
getStoredDoc(
|
|
188
|
+
interpreter: string,
|
|
189
|
+
smartAccount: string,
|
|
190
|
+
ruleId: number
|
|
191
|
+
): Promise<xdr.ScVal | undefined>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** How far the id scan will probe before giving up. OZ imposes no per-account
|
|
195
|
+
* rule cap, so there is no exact bound to derive; this one is far above any
|
|
196
|
+
* realistic account and keeps a malformed `Count` from spinning forever. */
|
|
197
|
+
export const MAX_RULE_ID_SCAN = 512
|
|
198
|
+
|
|
199
|
+
export interface CollectedRules {
|
|
200
|
+
rules: ObservedRule[]
|
|
201
|
+
/** Rule ids whose stored predicate could not be read even though the
|
|
202
|
+
* interpreter is attached. Such a rule is reported without a predicate,
|
|
203
|
+
* which classifies it as opaque rather than as safely narrow. */
|
|
204
|
+
unreadablePredicateRuleIds: number[]
|
|
205
|
+
/** True when the scan stopped before accounting for every live rule. The
|
|
206
|
+
* result is then a SUBSET of the account's rules, so an empty overlap list
|
|
207
|
+
* proves nothing and the caller must not present it as safety. */
|
|
208
|
+
incomplete: boolean
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Every context rule on the account, with predicates filled in for the rules
|
|
213
|
+
* our interpreter polices.
|
|
214
|
+
*
|
|
215
|
+
* Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
|
|
216
|
+
* decrements `Count` on removal without ever reusing an id, so after any
|
|
217
|
+
* removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
|
|
218
|
+
* would silently skip live rules at higher ids, and a skipped rule is a missed
|
|
219
|
+
* overlap - the one error that reports safety which does not exist. Instead the
|
|
220
|
+
* scan walks ids upward until it has accounted for `Count` live rules.
|
|
221
|
+
*
|
|
222
|
+
* A rule whose predicate cannot be read is deliberately left without one. That
|
|
223
|
+
* demotes it to the `foreign` class, so the scan reports it as opaque instead
|
|
224
|
+
* of assuming it is narrow.
|
|
225
|
+
*/
|
|
226
|
+
export async function collectObservedRules(args: {
|
|
227
|
+
reader: AccountRuleReader
|
|
228
|
+
smartAccount: string
|
|
229
|
+
interpreterAddress: string
|
|
230
|
+
maxRuleIdScan?: number
|
|
231
|
+
}): Promise<CollectedRules> {
|
|
232
|
+
const count = await args.reader.getContextRuleCount(args.smartAccount)
|
|
233
|
+
const limit = args.maxRuleIdScan ?? MAX_RULE_ID_SCAN
|
|
234
|
+
const rules: ObservedRule[] = []
|
|
235
|
+
const unreadablePredicateRuleIds: number[] = []
|
|
236
|
+
|
|
237
|
+
let id = 0
|
|
238
|
+
while (rules.length < count && id < limit) {
|
|
239
|
+
const raw = await args.reader.getContextRule(args.smartAccount, id)
|
|
240
|
+
id++
|
|
241
|
+
if (!raw) continue
|
|
242
|
+
const rule = decodeContextRule(raw)
|
|
243
|
+
if (!rule) continue
|
|
244
|
+
|
|
245
|
+
if (rule.policyAddresses.includes(args.interpreterAddress)) {
|
|
246
|
+
const doc = await args.reader.getStoredDoc(
|
|
247
|
+
args.interpreterAddress,
|
|
248
|
+
args.smartAccount,
|
|
249
|
+
rule.id
|
|
250
|
+
)
|
|
251
|
+
const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined
|
|
252
|
+
if (bytes) {
|
|
253
|
+
try {
|
|
254
|
+
rule.predicate = decodePredicate(bytes)
|
|
255
|
+
} catch {
|
|
256
|
+
unreadablePredicateRuleIds.push(rule.id)
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
unreadablePredicateRuleIds.push(rule.id)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
rules.push(rule)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An `AccountRuleReader` over a live RPC server.
|
|
270
|
+
*
|
|
271
|
+
* The two OZ getters are read-only simulations: the source account is
|
|
272
|
+
* constructed locally because a simulation never checks its sequence number,
|
|
273
|
+
* and asking the network for a random key would 404.
|
|
274
|
+
*
|
|
275
|
+
* The stored document is fetched as a ledger entry rather than a contract
|
|
276
|
+
* call, because the interpreter publishes no getter for it.
|
|
277
|
+
*/
|
|
278
|
+
export function accountRuleReaderFromServer(
|
|
279
|
+
server: rpc.Server,
|
|
280
|
+
networkPassphrase: string
|
|
281
|
+
): AccountRuleReader {
|
|
282
|
+
async function simulateCall(
|
|
283
|
+
contract: string,
|
|
284
|
+
method: string,
|
|
285
|
+
...args: xdr.ScVal[]
|
|
286
|
+
): Promise<xdr.ScVal | undefined> {
|
|
287
|
+
const account = new Account(Keypair.random().publicKey(), '0')
|
|
288
|
+
const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
|
|
289
|
+
.addOperation(new Contract(contract).call(method, ...args))
|
|
290
|
+
.setTimeout(30)
|
|
291
|
+
.build()
|
|
292
|
+
const sim = await server.simulateTransaction(tx)
|
|
293
|
+
if (rpc.Api.isSimulationError(sim)) return undefined
|
|
294
|
+
return sim.result?.retval
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
async getContextRuleCount(smartAccount) {
|
|
299
|
+
const val = await simulateCall(smartAccount, 'get_context_rules_count')
|
|
300
|
+
return u32Of(val) ?? 0
|
|
301
|
+
},
|
|
302
|
+
async getContextRule(smartAccount, ruleId) {
|
|
303
|
+
return simulateCall(smartAccount, 'get_context_rule', xdr.ScVal.scvU32(ruleId))
|
|
304
|
+
},
|
|
305
|
+
async getStoredDoc(interpreter, smartAccount, ruleId) {
|
|
306
|
+
const key = docLedgerKey(interpreter, smartAccount, ruleId)
|
|
307
|
+
const res = await server.getLedgerEntries(key)
|
|
308
|
+
const entry = res.entries?.[0]?.val
|
|
309
|
+
if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
|
|
310
|
+
return entry.contractData().val()
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|
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
|