@crediolabs/policy-synth 0.4.0 → 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.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 +14 -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.js +51 -8
- package/dist/run/schemas.d.ts +45 -14
- package/dist/run/schemas.js +43 -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 +14 -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.js +51 -8
- package/dist-cjs/run/schemas.d.ts +45 -14
- package/dist-cjs/run/schemas.js +43 -6
- 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 +14 -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 +54 -8
- package/src/run/schemas.ts +43 -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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
//! Reading an OpenZeppelin smart account's context rules back off chain.
|
|
3
|
+
//!
|
|
4
|
+
//! `authority-overlap.ts` needs to know what a signer can already do before a
|
|
5
|
+
//! new policy is installed. That means every rule on the account: its context
|
|
6
|
+
//! type, its signers, its attached policies, and - for rules our interpreter
|
|
7
|
+
//! polices - the predicate itself.
|
|
8
|
+
//!
|
|
9
|
+
//! Without this, the overlap scan can only report on rules the CALLER supplied,
|
|
10
|
+
//! which means it answers "what did you tell me about" rather than "what is on
|
|
11
|
+
//! the account". Those are different questions, and only the second one is
|
|
12
|
+
//! worth anything to someone deciding whether to sign.
|
|
13
|
+
//!
|
|
14
|
+
//! The predicate is NOT reachable through a contract call. The interpreter
|
|
15
|
+
//! publishes only `grammar_version`, `install`, `enforce`, `uninstall` and
|
|
16
|
+
//! `rotate_master_signer_set`, so the stored document is read as a ledger entry
|
|
17
|
+
//! instead. That keeps this a purely client-side capability: adding a getter
|
|
18
|
+
//! would change a deployed contract's ABI and force a redeploy plus re-audit to
|
|
19
|
+
//! obtain data the ledger already exposes.
|
|
20
|
+
//!
|
|
21
|
+
//! The decoders are pure so they can be tested without a network; the caller
|
|
22
|
+
//! supplies raw `ScVal`s.
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.MAX_RULE_ID_SCAN = exports.K_DOC = void 0;
|
|
25
|
+
exports.docKeyScVal = docKeyScVal;
|
|
26
|
+
exports.docLedgerKey = docLedgerKey;
|
|
27
|
+
exports.decodeContextType = decodeContextType;
|
|
28
|
+
exports.decodeSigner = decodeSigner;
|
|
29
|
+
exports.decodeContextRule = decodeContextRule;
|
|
30
|
+
exports.decodeStoredPredicateBytes = decodeStoredPredicateBytes;
|
|
31
|
+
exports.collectObservedRules = collectObservedRules;
|
|
32
|
+
exports.accountRuleReaderFromServer = accountRuleReaderFromServer;
|
|
33
|
+
const stellar_sdk_1 = require("@stellar/stellar-sdk");
|
|
34
|
+
const decode_ts_1 = require("../predicate/decode.js");
|
|
35
|
+
/** `storage.rs` - the third element of the persistent doc key tuple. */
|
|
36
|
+
exports.K_DOC = 1;
|
|
37
|
+
/** Persistent-storage key for a rule's stored document:
|
|
38
|
+
* `(account, rule_id, K_DOC)`. */
|
|
39
|
+
function docKeyScVal(smartAccount, ruleId) {
|
|
40
|
+
return stellar_sdk_1.xdr.ScVal.scvVec([
|
|
41
|
+
new stellar_sdk_1.Address(smartAccount).toScVal(),
|
|
42
|
+
stellar_sdk_1.xdr.ScVal.scvU32(ruleId),
|
|
43
|
+
stellar_sdk_1.xdr.ScVal.scvU32(exports.K_DOC),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
/** Ledger key for the interpreter's persistent entry holding that document. */
|
|
47
|
+
function docLedgerKey(interpreter, smartAccount, ruleId) {
|
|
48
|
+
return stellar_sdk_1.xdr.LedgerKey.contractData(new stellar_sdk_1.xdr.LedgerKeyContractData({
|
|
49
|
+
contract: new stellar_sdk_1.Address(interpreter).toScAddress(),
|
|
50
|
+
key: docKeyScVal(smartAccount, ruleId),
|
|
51
|
+
durability: stellar_sdk_1.xdr.ContractDataDurability.persistent(),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
// ---- ScVal helpers -----
|
|
55
|
+
/** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
|
|
56
|
+
* by field-name symbol. Returns undefined when the field is absent so a
|
|
57
|
+
* caller can distinguish "not there" from "there and empty". */
|
|
58
|
+
function mapField(v, name) {
|
|
59
|
+
if (v.switch() !== stellar_sdk_1.xdr.ScValType.scvMap())
|
|
60
|
+
return undefined;
|
|
61
|
+
for (const entry of v.map() ?? []) {
|
|
62
|
+
const key = entry.key();
|
|
63
|
+
if (key.switch() === stellar_sdk_1.xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
|
|
64
|
+
return entry.val();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
function u32Of(v) {
|
|
70
|
+
return v?.switch() === stellar_sdk_1.xdr.ScValType.scvU32() ? v.u32() : undefined;
|
|
71
|
+
}
|
|
72
|
+
function addressOf(v) {
|
|
73
|
+
if (!v || v.switch() !== stellar_sdk_1.xdr.ScValType.scvAddress())
|
|
74
|
+
return undefined;
|
|
75
|
+
return stellar_sdk_1.Address.fromScAddress(v.address()).toString();
|
|
76
|
+
}
|
|
77
|
+
/** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
|
|
78
|
+
function enumVariant(v) {
|
|
79
|
+
if (!v || v.switch() !== stellar_sdk_1.xdr.ScValType.scvVec())
|
|
80
|
+
return undefined;
|
|
81
|
+
const items = v.vec() ?? [];
|
|
82
|
+
const head = items[0];
|
|
83
|
+
if (!head || head.switch() !== stellar_sdk_1.xdr.ScValType.scvSymbol())
|
|
84
|
+
return undefined;
|
|
85
|
+
return { tag: head.sym().toString(), args: items.slice(1) };
|
|
86
|
+
}
|
|
87
|
+
// ---- decoders -----
|
|
88
|
+
/** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
|
|
89
|
+
* is the widest reading and therefore the safe one: it makes the rule look
|
|
90
|
+
* like it could serve any call, so overlap is over-reported, never missed. */
|
|
91
|
+
function decodeContextType(v) {
|
|
92
|
+
const variant = enumVariant(v);
|
|
93
|
+
if (!variant)
|
|
94
|
+
return { kind: 'default' };
|
|
95
|
+
if (variant.tag === 'CallContract') {
|
|
96
|
+
const addr = addressOf(variant.args[0]);
|
|
97
|
+
return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' };
|
|
98
|
+
}
|
|
99
|
+
if (variant.tag === 'CreateContract') {
|
|
100
|
+
const arg = variant.args[0];
|
|
101
|
+
const hash = arg?.switch() === stellar_sdk_1.xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : '';
|
|
102
|
+
return { kind: 'create_contract', wasmHash: hash };
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'default' };
|
|
105
|
+
}
|
|
106
|
+
/** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
|
|
107
|
+
function decodeSigner(v) {
|
|
108
|
+
const variant = enumVariant(v);
|
|
109
|
+
if (!variant)
|
|
110
|
+
return undefined;
|
|
111
|
+
if (variant.tag === 'Delegated') {
|
|
112
|
+
const addr = addressOf(variant.args[0]);
|
|
113
|
+
return addr ? { kind: 'delegated', address: addr } : undefined;
|
|
114
|
+
}
|
|
115
|
+
if (variant.tag === 'External') {
|
|
116
|
+
const verifier = addressOf(variant.args[0]);
|
|
117
|
+
const keyArg = variant.args[1];
|
|
118
|
+
const keyBytes = keyArg?.switch() === stellar_sdk_1.xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : '';
|
|
119
|
+
return verifier ? { kind: 'external', verifier, keyBytes } : undefined;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
|
|
124
|
+
* `predicate` is filled in separately from the ledger entry. */
|
|
125
|
+
function decodeContextRule(v) {
|
|
126
|
+
const id = u32Of(mapField(v, 'id'));
|
|
127
|
+
if (id === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
const signersVal = mapField(v, 'signers');
|
|
130
|
+
const signers = [];
|
|
131
|
+
if (signersVal?.switch() === stellar_sdk_1.xdr.ScValType.scvVec()) {
|
|
132
|
+
for (const s of signersVal.vec() ?? []) {
|
|
133
|
+
const decoded = decodeSigner(s);
|
|
134
|
+
if (decoded)
|
|
135
|
+
signers.push(decoded);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const policiesVal = mapField(v, 'policies');
|
|
139
|
+
const policyAddresses = [];
|
|
140
|
+
if (policiesVal?.switch() === stellar_sdk_1.xdr.ScValType.scvVec()) {
|
|
141
|
+
for (const p of policiesVal.vec() ?? []) {
|
|
142
|
+
const addr = addressOf(p);
|
|
143
|
+
if (addr)
|
|
144
|
+
policyAddresses.push(addr);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
contextType: decodeContextType(mapField(v, 'context_type')),
|
|
150
|
+
signers,
|
|
151
|
+
policyAddresses,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** The interpreter's `StoredDoc { predicate_bytes }`. */
|
|
155
|
+
function decodeStoredPredicateBytes(v) {
|
|
156
|
+
const field = mapField(v, 'predicate_bytes');
|
|
157
|
+
if (!field || field.switch() !== stellar_sdk_1.xdr.ScValType.scvBytes())
|
|
158
|
+
return undefined;
|
|
159
|
+
return field.bytes();
|
|
160
|
+
}
|
|
161
|
+
/** How far the id scan will probe before giving up. OZ imposes no per-account
|
|
162
|
+
* rule cap, so there is no exact bound to derive; this one is far above any
|
|
163
|
+
* realistic account and keeps a malformed `Count` from spinning forever. */
|
|
164
|
+
exports.MAX_RULE_ID_SCAN = 512;
|
|
165
|
+
/**
|
|
166
|
+
* Every context rule on the account, with predicates filled in for the rules
|
|
167
|
+
* our interpreter polices.
|
|
168
|
+
*
|
|
169
|
+
* Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
|
|
170
|
+
* decrements `Count` on removal without ever reusing an id, so after any
|
|
171
|
+
* removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
|
|
172
|
+
* would silently skip live rules at higher ids, and a skipped rule is a missed
|
|
173
|
+
* overlap - the one error that reports safety which does not exist. Instead the
|
|
174
|
+
* scan walks ids upward until it has accounted for `Count` live rules.
|
|
175
|
+
*
|
|
176
|
+
* A rule whose predicate cannot be read is deliberately left without one. That
|
|
177
|
+
* demotes it to the `foreign` class, so the scan reports it as opaque instead
|
|
178
|
+
* of assuming it is narrow.
|
|
179
|
+
*/
|
|
180
|
+
async function collectObservedRules(args) {
|
|
181
|
+
const count = await args.reader.getContextRuleCount(args.smartAccount);
|
|
182
|
+
const limit = args.maxRuleIdScan ?? exports.MAX_RULE_ID_SCAN;
|
|
183
|
+
const rules = [];
|
|
184
|
+
const unreadablePredicateRuleIds = [];
|
|
185
|
+
let id = 0;
|
|
186
|
+
while (rules.length < count && id < limit) {
|
|
187
|
+
const raw = await args.reader.getContextRule(args.smartAccount, id);
|
|
188
|
+
id++;
|
|
189
|
+
if (!raw)
|
|
190
|
+
continue;
|
|
191
|
+
const rule = decodeContextRule(raw);
|
|
192
|
+
if (!rule)
|
|
193
|
+
continue;
|
|
194
|
+
if (rule.policyAddresses.includes(args.interpreterAddress)) {
|
|
195
|
+
const doc = await args.reader.getStoredDoc(args.interpreterAddress, args.smartAccount, rule.id);
|
|
196
|
+
const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined;
|
|
197
|
+
if (bytes) {
|
|
198
|
+
try {
|
|
199
|
+
rule.predicate = (0, decode_ts_1.decodePredicate)(bytes);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
unreadablePredicateRuleIds.push(rule.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
unreadablePredicateRuleIds.push(rule.id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
rules.push(rule);
|
|
210
|
+
}
|
|
211
|
+
return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* An `AccountRuleReader` over a live RPC server.
|
|
215
|
+
*
|
|
216
|
+
* The two OZ getters are read-only simulations: the source account is
|
|
217
|
+
* constructed locally because a simulation never checks its sequence number,
|
|
218
|
+
* and asking the network for a random key would 404.
|
|
219
|
+
*
|
|
220
|
+
* The stored document is fetched as a ledger entry rather than a contract
|
|
221
|
+
* call, because the interpreter publishes no getter for it.
|
|
222
|
+
*/
|
|
223
|
+
function accountRuleReaderFromServer(server, networkPassphrase) {
|
|
224
|
+
async function simulateCall(contract, method, ...args) {
|
|
225
|
+
const account = new stellar_sdk_1.Account(stellar_sdk_1.Keypair.random().publicKey(), '0');
|
|
226
|
+
const tx = new stellar_sdk_1.TransactionBuilder(account, { fee: stellar_sdk_1.BASE_FEE, networkPassphrase })
|
|
227
|
+
.addOperation(new stellar_sdk_1.Contract(contract).call(method, ...args))
|
|
228
|
+
.setTimeout(30)
|
|
229
|
+
.build();
|
|
230
|
+
const sim = await server.simulateTransaction(tx);
|
|
231
|
+
if (stellar_sdk_1.rpc.Api.isSimulationError(sim))
|
|
232
|
+
return undefined;
|
|
233
|
+
return sim.result?.retval;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
async getContextRuleCount(smartAccount) {
|
|
237
|
+
const val = await simulateCall(smartAccount, 'get_context_rules_count');
|
|
238
|
+
return u32Of(val) ?? 0;
|
|
239
|
+
},
|
|
240
|
+
async getContextRule(smartAccount, ruleId) {
|
|
241
|
+
return simulateCall(smartAccount, 'get_context_rule', stellar_sdk_1.xdr.ScVal.scvU32(ruleId));
|
|
242
|
+
},
|
|
243
|
+
async getStoredDoc(interpreter, smartAccount, ruleId) {
|
|
244
|
+
const key = docLedgerKey(interpreter, smartAccount, ruleId);
|
|
245
|
+
const res = await server.getLedgerEntries(key);
|
|
246
|
+
const entry = res.entries?.[0]?.val;
|
|
247
|
+
if (!entry || entry.switch() !== stellar_sdk_1.xdr.LedgerEntryType.contractData())
|
|
248
|
+
return undefined;
|
|
249
|
+
return entry.contractData().val();
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -60,6 +60,15 @@ function expectSymbol(v, what) {
|
|
|
60
60
|
throw malformed(`${what} is not a symbol`);
|
|
61
61
|
return v.sym().toString();
|
|
62
62
|
}
|
|
63
|
+
/** Strict i128, returned as a decimal string. Strict on purpose: the Rust
|
|
64
|
+
* decoder refuses a u32 in an i128 slot rather than widening it, so
|
|
65
|
+
* accepting one here would let a predicate decode off chain and be refused
|
|
66
|
+
* on chain. */
|
|
67
|
+
function expectI128(v, what) {
|
|
68
|
+
if (!v || v.switch() !== stellar_sdk_1.xdr.ScValType.scvI128())
|
|
69
|
+
throw malformed(`${what} is not an i128`);
|
|
70
|
+
return (0, stellar_sdk_1.scValToBigInt)(v).toString();
|
|
71
|
+
}
|
|
63
72
|
/** Arity check with the same intent as the Rust `check_arity`: a selector with
|
|
64
73
|
* the wrong element count is malformed, not silently truncated. */
|
|
65
74
|
function arity(items, n, selector) {
|
|
@@ -89,6 +98,14 @@ function decodeSelectorLeaf(items, sym) {
|
|
|
89
98
|
element: expectU32(items[2], 'call_arg_field element'),
|
|
90
99
|
field: expectSymbol(items[3], 'call_arg_field field'),
|
|
91
100
|
};
|
|
101
|
+
case 'call_arg_scaled':
|
|
102
|
+
arity(items, 4, sym);
|
|
103
|
+
return {
|
|
104
|
+
kind: 'call_arg_scaled',
|
|
105
|
+
index: expectU32(items[1], 'call_arg_scaled index'),
|
|
106
|
+
num: expectI128(items[2], 'call_arg_scaled num'),
|
|
107
|
+
den: expectI128(items[3], 'call_arg_scaled den'),
|
|
108
|
+
};
|
|
92
109
|
default:
|
|
93
110
|
// Deliberately NOT a literal_vec fallback - see the header note.
|
|
94
111
|
throw malformed(`unknown selector symbol '${sym}'`);
|
|
@@ -125,7 +142,8 @@ function decodeNode(v) {
|
|
|
125
142
|
if (op === null)
|
|
126
143
|
throw malformed('node does not start with an operator symbol');
|
|
127
144
|
switch (op) {
|
|
128
|
-
case 'and':
|
|
145
|
+
case 'and':
|
|
146
|
+
case 'or': {
|
|
129
147
|
arity(items, 2, op);
|
|
130
148
|
const children = expectVec(items[1], `${op} children`).map(decodeNode);
|
|
131
149
|
if (children.length === 0)
|
|
@@ -133,7 +151,10 @@ function decodeNode(v) {
|
|
|
133
151
|
return { op, children };
|
|
134
152
|
}
|
|
135
153
|
case 'eq':
|
|
154
|
+
case 'lt':
|
|
136
155
|
case 'lte':
|
|
156
|
+
case 'gt':
|
|
157
|
+
case 'gte':
|
|
137
158
|
arity(items, 3, op);
|
|
138
159
|
return {
|
|
139
160
|
op,
|
|
@@ -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':
|
|
@@ -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
|
}
|