@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
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
import { Account, Address, BASE_FEE, Contract, Keypair, rpc, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
|
|
23
|
+
import { decodePredicate } from "../predicate/decode.js";
|
|
24
|
+
/** `storage.rs` - the third element of the persistent doc key tuple. */
|
|
25
|
+
export const K_DOC = 1;
|
|
26
|
+
/** Persistent-storage key for a rule's stored document:
|
|
27
|
+
* `(account, rule_id, K_DOC)`. */
|
|
28
|
+
export function docKeyScVal(smartAccount, ruleId) {
|
|
29
|
+
return xdr.ScVal.scvVec([
|
|
30
|
+
new Address(smartAccount).toScVal(),
|
|
31
|
+
xdr.ScVal.scvU32(ruleId),
|
|
32
|
+
xdr.ScVal.scvU32(K_DOC),
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
/** Ledger key for the interpreter's persistent entry holding that document. */
|
|
36
|
+
export function docLedgerKey(interpreter, smartAccount, ruleId) {
|
|
37
|
+
return xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
|
|
38
|
+
contract: new Address(interpreter).toScAddress(),
|
|
39
|
+
key: docKeyScVal(smartAccount, ruleId),
|
|
40
|
+
durability: xdr.ContractDataDurability.persistent(),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
// ---- ScVal helpers -----
|
|
44
|
+
/** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
|
|
45
|
+
* by field-name symbol. Returns undefined when the field is absent so a
|
|
46
|
+
* caller can distinguish "not there" from "there and empty". */
|
|
47
|
+
function mapField(v, name) {
|
|
48
|
+
if (v.switch() !== xdr.ScValType.scvMap())
|
|
49
|
+
return undefined;
|
|
50
|
+
for (const entry of v.map() ?? []) {
|
|
51
|
+
const key = entry.key();
|
|
52
|
+
if (key.switch() === xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
|
|
53
|
+
return entry.val();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function u32Of(v) {
|
|
59
|
+
return v?.switch() === xdr.ScValType.scvU32() ? v.u32() : undefined;
|
|
60
|
+
}
|
|
61
|
+
function addressOf(v) {
|
|
62
|
+
if (!v || v.switch() !== xdr.ScValType.scvAddress())
|
|
63
|
+
return undefined;
|
|
64
|
+
return Address.fromScAddress(v.address()).toString();
|
|
65
|
+
}
|
|
66
|
+
/** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
|
|
67
|
+
function enumVariant(v) {
|
|
68
|
+
if (!v || v.switch() !== xdr.ScValType.scvVec())
|
|
69
|
+
return undefined;
|
|
70
|
+
const items = v.vec() ?? [];
|
|
71
|
+
const head = items[0];
|
|
72
|
+
if (!head || head.switch() !== xdr.ScValType.scvSymbol())
|
|
73
|
+
return undefined;
|
|
74
|
+
return { tag: head.sym().toString(), args: items.slice(1) };
|
|
75
|
+
}
|
|
76
|
+
// ---- decoders -----
|
|
77
|
+
/** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
|
|
78
|
+
* is the widest reading and therefore the safe one: it makes the rule look
|
|
79
|
+
* like it could serve any call, so overlap is over-reported, never missed. */
|
|
80
|
+
export function decodeContextType(v) {
|
|
81
|
+
const variant = enumVariant(v);
|
|
82
|
+
if (!variant)
|
|
83
|
+
return { kind: 'default' };
|
|
84
|
+
if (variant.tag === 'CallContract') {
|
|
85
|
+
const addr = addressOf(variant.args[0]);
|
|
86
|
+
return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' };
|
|
87
|
+
}
|
|
88
|
+
if (variant.tag === 'CreateContract') {
|
|
89
|
+
const arg = variant.args[0];
|
|
90
|
+
const hash = arg?.switch() === xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : '';
|
|
91
|
+
return { kind: 'create_contract', wasmHash: hash };
|
|
92
|
+
}
|
|
93
|
+
return { kind: 'default' };
|
|
94
|
+
}
|
|
95
|
+
/** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
|
|
96
|
+
export function decodeSigner(v) {
|
|
97
|
+
const variant = enumVariant(v);
|
|
98
|
+
if (!variant)
|
|
99
|
+
return undefined;
|
|
100
|
+
if (variant.tag === 'Delegated') {
|
|
101
|
+
const addr = addressOf(variant.args[0]);
|
|
102
|
+
return addr ? { kind: 'delegated', address: addr } : undefined;
|
|
103
|
+
}
|
|
104
|
+
if (variant.tag === 'External') {
|
|
105
|
+
const verifier = addressOf(variant.args[0]);
|
|
106
|
+
const keyArg = variant.args[1];
|
|
107
|
+
const keyBytes = keyArg?.switch() === xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : '';
|
|
108
|
+
return verifier ? { kind: 'external', verifier, keyBytes } : undefined;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
|
|
113
|
+
* `predicate` is filled in separately from the ledger entry. */
|
|
114
|
+
export function decodeContextRule(v) {
|
|
115
|
+
const id = u32Of(mapField(v, 'id'));
|
|
116
|
+
if (id === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
const signersVal = mapField(v, 'signers');
|
|
119
|
+
const signers = [];
|
|
120
|
+
if (signersVal?.switch() === xdr.ScValType.scvVec()) {
|
|
121
|
+
for (const s of signersVal.vec() ?? []) {
|
|
122
|
+
const decoded = decodeSigner(s);
|
|
123
|
+
if (decoded)
|
|
124
|
+
signers.push(decoded);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const policiesVal = mapField(v, 'policies');
|
|
128
|
+
const policyAddresses = [];
|
|
129
|
+
if (policiesVal?.switch() === xdr.ScValType.scvVec()) {
|
|
130
|
+
for (const p of policiesVal.vec() ?? []) {
|
|
131
|
+
const addr = addressOf(p);
|
|
132
|
+
if (addr)
|
|
133
|
+
policyAddresses.push(addr);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
id,
|
|
138
|
+
contextType: decodeContextType(mapField(v, 'context_type')),
|
|
139
|
+
signers,
|
|
140
|
+
policyAddresses,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** The interpreter's `StoredDoc { predicate_bytes }`. */
|
|
144
|
+
export function decodeStoredPredicateBytes(v) {
|
|
145
|
+
const field = mapField(v, 'predicate_bytes');
|
|
146
|
+
if (!field || field.switch() !== xdr.ScValType.scvBytes())
|
|
147
|
+
return undefined;
|
|
148
|
+
return field.bytes();
|
|
149
|
+
}
|
|
150
|
+
/** How far the id scan will probe before giving up. OZ imposes no per-account
|
|
151
|
+
* rule cap, so there is no exact bound to derive; this one is far above any
|
|
152
|
+
* realistic account and keeps a malformed `Count` from spinning forever. */
|
|
153
|
+
export const MAX_RULE_ID_SCAN = 512;
|
|
154
|
+
/**
|
|
155
|
+
* Every context rule on the account, with predicates filled in for the rules
|
|
156
|
+
* our interpreter polices.
|
|
157
|
+
*
|
|
158
|
+
* Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
|
|
159
|
+
* decrements `Count` on removal without ever reusing an id, so after any
|
|
160
|
+
* removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
|
|
161
|
+
* would silently skip live rules at higher ids, and a skipped rule is a missed
|
|
162
|
+
* overlap - the one error that reports safety which does not exist. Instead the
|
|
163
|
+
* scan walks ids upward until it has accounted for `Count` live rules.
|
|
164
|
+
*
|
|
165
|
+
* A rule whose predicate cannot be read is deliberately left without one. That
|
|
166
|
+
* demotes it to the `foreign` class, so the scan reports it as opaque instead
|
|
167
|
+
* of assuming it is narrow.
|
|
168
|
+
*/
|
|
169
|
+
export async function collectObservedRules(args) {
|
|
170
|
+
const count = await args.reader.getContextRuleCount(args.smartAccount);
|
|
171
|
+
const limit = args.maxRuleIdScan ?? MAX_RULE_ID_SCAN;
|
|
172
|
+
const rules = [];
|
|
173
|
+
const unreadablePredicateRuleIds = [];
|
|
174
|
+
let id = 0;
|
|
175
|
+
while (rules.length < count && id < limit) {
|
|
176
|
+
const raw = await args.reader.getContextRule(args.smartAccount, id);
|
|
177
|
+
id++;
|
|
178
|
+
if (!raw)
|
|
179
|
+
continue;
|
|
180
|
+
const rule = decodeContextRule(raw);
|
|
181
|
+
if (!rule)
|
|
182
|
+
continue;
|
|
183
|
+
if (rule.policyAddresses.includes(args.interpreterAddress)) {
|
|
184
|
+
const doc = await args.reader.getStoredDoc(args.interpreterAddress, args.smartAccount, rule.id);
|
|
185
|
+
const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined;
|
|
186
|
+
if (bytes) {
|
|
187
|
+
try {
|
|
188
|
+
rule.predicate = decodePredicate(bytes);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
unreadablePredicateRuleIds.push(rule.id);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
unreadablePredicateRuleIds.push(rule.id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
rules.push(rule);
|
|
199
|
+
}
|
|
200
|
+
return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* An `AccountRuleReader` over a live RPC server.
|
|
204
|
+
*
|
|
205
|
+
* The two OZ getters are read-only simulations: the source account is
|
|
206
|
+
* constructed locally because a simulation never checks its sequence number,
|
|
207
|
+
* and asking the network for a random key would 404.
|
|
208
|
+
*
|
|
209
|
+
* The stored document is fetched as a ledger entry rather than a contract
|
|
210
|
+
* call, because the interpreter publishes no getter for it.
|
|
211
|
+
*/
|
|
212
|
+
export function accountRuleReaderFromServer(server, networkPassphrase) {
|
|
213
|
+
async function simulateCall(contract, method, ...args) {
|
|
214
|
+
const account = new Account(Keypair.random().publicKey(), '0');
|
|
215
|
+
const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
|
|
216
|
+
.addOperation(new Contract(contract).call(method, ...args))
|
|
217
|
+
.setTimeout(30)
|
|
218
|
+
.build();
|
|
219
|
+
const sim = await server.simulateTransaction(tx);
|
|
220
|
+
if (rpc.Api.isSimulationError(sim))
|
|
221
|
+
return undefined;
|
|
222
|
+
return sim.result?.retval;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
async getContextRuleCount(smartAccount) {
|
|
226
|
+
const val = await simulateCall(smartAccount, 'get_context_rules_count');
|
|
227
|
+
return u32Of(val) ?? 0;
|
|
228
|
+
},
|
|
229
|
+
async getContextRule(smartAccount, ruleId) {
|
|
230
|
+
return simulateCall(smartAccount, 'get_context_rule', xdr.ScVal.scvU32(ruleId));
|
|
231
|
+
},
|
|
232
|
+
async getStoredDoc(interpreter, smartAccount, ruleId) {
|
|
233
|
+
const key = docLedgerKey(interpreter, smartAccount, ruleId);
|
|
234
|
+
const res = await server.getLedgerEntries(key);
|
|
235
|
+
const entry = res.entries?.[0]?.val;
|
|
236
|
+
if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData())
|
|
237
|
+
return undefined;
|
|
238
|
+
return entry.contractData().val();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/dist/predicate/decode.js
CHANGED
|
@@ -55,6 +55,15 @@ function expectSymbol(v, what) {
|
|
|
55
55
|
throw malformed(`${what} is not a symbol`);
|
|
56
56
|
return v.sym().toString();
|
|
57
57
|
}
|
|
58
|
+
/** Strict i128, returned as a decimal string. Strict on purpose: the Rust
|
|
59
|
+
* decoder refuses a u32 in an i128 slot rather than widening it, so
|
|
60
|
+
* accepting one here would let a predicate decode off chain and be refused
|
|
61
|
+
* on chain. */
|
|
62
|
+
function expectI128(v, what) {
|
|
63
|
+
if (!v || v.switch() !== xdr.ScValType.scvI128())
|
|
64
|
+
throw malformed(`${what} is not an i128`);
|
|
65
|
+
return scValToBigInt(v).toString();
|
|
66
|
+
}
|
|
58
67
|
/** Arity check with the same intent as the Rust `check_arity`: a selector with
|
|
59
68
|
* the wrong element count is malformed, not silently truncated. */
|
|
60
69
|
function arity(items, n, selector) {
|
|
@@ -84,6 +93,14 @@ function decodeSelectorLeaf(items, sym) {
|
|
|
84
93
|
element: expectU32(items[2], 'call_arg_field element'),
|
|
85
94
|
field: expectSymbol(items[3], 'call_arg_field field'),
|
|
86
95
|
};
|
|
96
|
+
case 'call_arg_scaled':
|
|
97
|
+
arity(items, 4, sym);
|
|
98
|
+
return {
|
|
99
|
+
kind: 'call_arg_scaled',
|
|
100
|
+
index: expectU32(items[1], 'call_arg_scaled index'),
|
|
101
|
+
num: expectI128(items[2], 'call_arg_scaled num'),
|
|
102
|
+
den: expectI128(items[3], 'call_arg_scaled den'),
|
|
103
|
+
};
|
|
87
104
|
default:
|
|
88
105
|
// Deliberately NOT a literal_vec fallback - see the header note.
|
|
89
106
|
throw malformed(`unknown selector symbol '${sym}'`);
|
|
@@ -120,7 +137,8 @@ export function decodeNode(v) {
|
|
|
120
137
|
if (op === null)
|
|
121
138
|
throw malformed('node does not start with an operator symbol');
|
|
122
139
|
switch (op) {
|
|
123
|
-
case 'and':
|
|
140
|
+
case 'and':
|
|
141
|
+
case 'or': {
|
|
124
142
|
arity(items, 2, op);
|
|
125
143
|
const children = expectVec(items[1], `${op} children`).map(decodeNode);
|
|
126
144
|
if (children.length === 0)
|
|
@@ -128,7 +146,10 @@ export function decodeNode(v) {
|
|
|
128
146
|
return { op, children };
|
|
129
147
|
}
|
|
130
148
|
case 'eq':
|
|
149
|
+
case 'lt':
|
|
131
150
|
case 'lte':
|
|
151
|
+
case 'gt':
|
|
152
|
+
case 'gte':
|
|
132
153
|
arity(items, 3, op);
|
|
133
154
|
return {
|
|
134
155
|
op,
|
package/dist/predicate/encode.js
CHANGED
|
@@ -80,7 +80,8 @@ function computeStats(node) {
|
|
|
80
80
|
}
|
|
81
81
|
function walk(node, inCounts, counters) {
|
|
82
82
|
switch (node.op) {
|
|
83
|
-
case 'and':
|
|
83
|
+
case 'and':
|
|
84
|
+
case 'or': {
|
|
84
85
|
if (node.children.length === 0) {
|
|
85
86
|
throw capError('MALFORMED_PREDICATE', `\`${node.op}\` with no children: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed`);
|
|
86
87
|
}
|
|
@@ -95,7 +96,10 @@ function walk(node, inCounts, counters) {
|
|
|
95
96
|
return { depth: maxChildDepth + 1, leaves: totalLeaves };
|
|
96
97
|
}
|
|
97
98
|
case 'eq':
|
|
98
|
-
case '
|
|
99
|
+
case 'lt':
|
|
100
|
+
case 'lte':
|
|
101
|
+
case 'gt':
|
|
102
|
+
case 'gte': {
|
|
99
103
|
collectSelector(node.left, counters);
|
|
100
104
|
collectSelector(node.right, counters);
|
|
101
105
|
return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) };
|
|
@@ -143,14 +147,20 @@ function collectSelector(leaf, counters) {
|
|
|
143
147
|
}
|
|
144
148
|
function encodeNode(node) {
|
|
145
149
|
switch (node.op) {
|
|
146
|
-
case 'and':
|
|
150
|
+
case 'and':
|
|
151
|
+
case 'or': {
|
|
147
152
|
const encoded = node.children.map(encodeNode);
|
|
148
|
-
// sort children by their canonical XDR bytes ascending.
|
|
153
|
+
// sort children by their canonical XDR bytes ascending. Both operators
|
|
154
|
+
// are commutative for the permit decision, so sorting costs no meaning
|
|
155
|
+
// and buys a stable hash for logically-identical predicates.
|
|
149
156
|
const sorted = sortByCanonicalBytes(encoded);
|
|
150
157
|
return xdr.ScVal.scvVec([symbol(node.op), xdr.ScVal.scvVec(sorted)]);
|
|
151
158
|
}
|
|
152
159
|
case 'eq':
|
|
153
|
-
case '
|
|
160
|
+
case 'lt':
|
|
161
|
+
case 'lte':
|
|
162
|
+
case 'gt':
|
|
163
|
+
case 'gte': {
|
|
154
164
|
return xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)]);
|
|
155
165
|
}
|
|
156
166
|
case 'in': {
|
|
@@ -181,6 +191,15 @@ function encodeLeaf(leaf) {
|
|
|
181
191
|
xdr.ScVal.scvU32(leaf.element),
|
|
182
192
|
xdr.ScVal.scvSymbol(leaf.field),
|
|
183
193
|
]);
|
|
194
|
+
case 'call_arg_scaled':
|
|
195
|
+
// num/den are i128 on the wire. The Rust decoder type-checks both
|
|
196
|
+
// slots, so a u32 here would be refused rather than widened.
|
|
197
|
+
return xdr.ScVal.scvVec([
|
|
198
|
+
symbol('call_arg_scaled'),
|
|
199
|
+
xdr.ScVal.scvU32(leaf.index),
|
|
200
|
+
scvI128FromDecimal(leaf.num),
|
|
201
|
+
scvI128FromDecimal(leaf.den),
|
|
202
|
+
]);
|
|
184
203
|
case 'literal_address':
|
|
185
204
|
return scvAddressFromStrkey(leaf.value);
|
|
186
205
|
case 'literal_i128':
|
|
@@ -266,6 +285,30 @@ function validateLeafValues(node) {
|
|
|
266
285
|
throw malformed(`call_arg_field.element out of u32 range at ${path}`);
|
|
267
286
|
}
|
|
268
287
|
return;
|
|
288
|
+
case 'call_arg_scaled': {
|
|
289
|
+
if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
|
|
290
|
+
throw malformed(`call_arg_scaled.index out of u32 range at ${path}`);
|
|
291
|
+
}
|
|
292
|
+
// Mirror of the contract's install gate (214). Without it the TS
|
|
293
|
+
// self-verify would green-light a ratio the chain refuses, which is
|
|
294
|
+
// exactly the divergence this validator exists to prevent.
|
|
295
|
+
let num;
|
|
296
|
+
let den;
|
|
297
|
+
try {
|
|
298
|
+
num = BigInt(leaf.num);
|
|
299
|
+
den = BigInt(leaf.den);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
throw malformed(`call_arg_scaled num/den must be i128 decimal strings at ${path}`);
|
|
303
|
+
}
|
|
304
|
+
if (den === 0n) {
|
|
305
|
+
throw malformed(`call_arg_scaled.den is zero at ${path}: the contract refuses it at install (INVALID_SCALED_RATIO)`);
|
|
306
|
+
}
|
|
307
|
+
if (num <= 0n || den < 0n) {
|
|
308
|
+
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)`);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
269
312
|
case 'call_contract':
|
|
270
313
|
case 'call_fn':
|
|
271
314
|
case 'literal_address':
|
|
@@ -276,12 +319,16 @@ function validateLeafValues(node) {
|
|
|
276
319
|
function walkNode(n, path) {
|
|
277
320
|
switch (n.op) {
|
|
278
321
|
case 'and':
|
|
322
|
+
case 'or':
|
|
279
323
|
n.children.forEach((c, i) => {
|
|
280
324
|
walkNode(c, `${path}.children[${i}]`);
|
|
281
325
|
});
|
|
282
326
|
return;
|
|
283
327
|
case 'eq':
|
|
328
|
+
case 'lt':
|
|
284
329
|
case 'lte':
|
|
330
|
+
case 'gt':
|
|
331
|
+
case 'gte':
|
|
285
332
|
walkLeaf(n.left, `${path}.left`);
|
|
286
333
|
walkLeaf(n.right, `${path}.right`);
|
|
287
334
|
return;
|
|
@@ -17,9 +17,13 @@ export function jsonToAst(value) {
|
|
|
17
17
|
const v = value;
|
|
18
18
|
switch (v.op) {
|
|
19
19
|
case 'and':
|
|
20
|
-
|
|
20
|
+
case 'or':
|
|
21
|
+
return { op: v.op, children: arrayOf(v.children, jsonToAst) };
|
|
21
22
|
case 'eq':
|
|
23
|
+
case 'lt':
|
|
22
24
|
case 'lte':
|
|
25
|
+
case 'gt':
|
|
26
|
+
case 'gte':
|
|
23
27
|
return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) };
|
|
24
28
|
case 'in':
|
|
25
29
|
return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) };
|
|
@@ -44,6 +48,15 @@ function jsonToLeaf(value) {
|
|
|
44
48
|
return { kind: 'call_arg', index: numberField(v, 'index') };
|
|
45
49
|
case 'call_arg_len':
|
|
46
50
|
return { kind: 'call_arg_len', index: numberField(v, 'index') };
|
|
51
|
+
case 'call_arg_scaled':
|
|
52
|
+
// num/den stay decimal STRINGS: an i128 ratio does not survive a JS
|
|
53
|
+
// number, and silently rounding one would change the floor.
|
|
54
|
+
return {
|
|
55
|
+
kind: 'call_arg_scaled',
|
|
56
|
+
index: numberField(v, 'index'),
|
|
57
|
+
num: stringField(v, 'num'),
|
|
58
|
+
den: stringField(v, 'den'),
|
|
59
|
+
};
|
|
47
60
|
case 'literal_address':
|
|
48
61
|
return { kind: 'literal_address', value: stringField(v, 'value') };
|
|
49
62
|
case 'literal_i128':
|
package/dist/record/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolResponse } from '../errors.ts';
|
|
2
|
+
import { type SpecFetcher } from '../registry/on-chain-spec.ts';
|
|
2
3
|
import type { Network, RecordedTransaction } from '../types.ts';
|
|
3
4
|
import { type RpcFetcher } from './rpc.ts';
|
|
4
5
|
/** Public input shape. The brief pins:
|
|
@@ -27,6 +28,15 @@ export interface RecordInput {
|
|
|
27
28
|
* automatically; tests can pass a deterministic stub. */
|
|
28
29
|
crossNetworkFetcher?: RpcFetcher;
|
|
29
30
|
confidenceOverride?: number;
|
|
31
|
+
/** Read a contract's own interface off chain when the compiled-in registry
|
|
32
|
+
* does not recognise it. Default ON: the registry covers the protocols we
|
|
33
|
+
* pinned by hand, and refusing everything else reported `no-abi` for
|
|
34
|
+
* contracts that publish a full typed spec. Set false to record against
|
|
35
|
+
* the registry alone (no extra RPC). */
|
|
36
|
+
resolveContractSpecs?: boolean;
|
|
37
|
+
/** Test seam for the spec lookup. Unset in production, where it is built
|
|
38
|
+
* from the network's pinned RPC URL. */
|
|
39
|
+
specFetcher?: SpecFetcher;
|
|
30
40
|
}
|
|
31
41
|
export type RecordResult = ToolResponse<RecordedTransaction>;
|
|
32
42
|
export declare function recordTransaction(input: RecordInput): Promise<RecordResult>;
|
package/dist/record/index.js
CHANGED
|
@@ -16,11 +16,40 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Returns ToolResponse<RecordedTransaction> per the canonical envelope
|
|
18
18
|
// defined in src/errors.ts.
|
|
19
|
+
import { Networks } from '@stellar/stellar-sdk';
|
|
20
|
+
import { resolveContractsByOnChainSpec, specFetcherFromRpc, } from "../registry/on-chain-spec.js";
|
|
19
21
|
import { contractEventsToOnChainEvents, DecodeError, decodeEnvelope, decodeEnvelopeXdr, transactionEventsToOnChainEvents, } from "./decode.js";
|
|
20
22
|
import { buildLowConfidenceQuestion, computeParseConfidence, isBelowThreshold, } from "./freshness.js";
|
|
21
23
|
import { extractTokenMovements } from "./movements.js";
|
|
22
|
-
import { createRpcServer } from "./rpc.js";
|
|
24
|
+
import { createRpcServer, PUBLIC_RPC_URLS } from "./rpc.js";
|
|
23
25
|
import { validateAgainstEvents } from "./validate.js";
|
|
26
|
+
/** Second pass over the contracts the compiled-in registry did not recognise.
|
|
27
|
+
*
|
|
28
|
+
* Each candidate's own interface is read off chain and every call it received
|
|
29
|
+
* is checked against it; the ones that verify are fed back through the decoder
|
|
30
|
+
* as known. Re-decoding rather than patching the first result keeps ONE code
|
|
31
|
+
* path computing parseConfidence - a hand-adjusted count here would be a
|
|
32
|
+
* second implementation of the gate, free to drift from the real one.
|
|
33
|
+
*
|
|
34
|
+
* Only ever ADDS recognition. A missing spec, an unreachable RPC or a call the
|
|
35
|
+
* interface does not describe all leave the recording exactly as it was. */
|
|
36
|
+
async function resolveByOnChainSpec(input, decoded, redecode) {
|
|
37
|
+
if (input.resolveContractSpecs === false)
|
|
38
|
+
return decoded;
|
|
39
|
+
if (decoded.unknownContracts.length === 0)
|
|
40
|
+
return decoded;
|
|
41
|
+
const fetcher = input.specFetcher ??
|
|
42
|
+
specFetcherFromRpc(PUBLIC_RPC_URLS[input.network], input.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET);
|
|
43
|
+
let resolved;
|
|
44
|
+
try {
|
|
45
|
+
resolved = await resolveContractsByOnChainSpec(decoded.invocations, decoded.unknownContracts.map((u) => u.contract), fetcher);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A lookup failure must not fail the recording that already succeeded.
|
|
49
|
+
return decoded;
|
|
50
|
+
}
|
|
51
|
+
return resolved.size === 0 ? decoded : redecode(resolved);
|
|
52
|
+
}
|
|
24
53
|
export async function recordTransaction(input) {
|
|
25
54
|
if (!input.network) {
|
|
26
55
|
return err('RECORDING_FAILED', 'network required', false);
|
|
@@ -76,6 +105,7 @@ export async function recordTransaction(input) {
|
|
|
76
105
|
return err('RECORDING_FAILED', e.message, false);
|
|
77
106
|
throw e;
|
|
78
107
|
}
|
|
108
|
+
decoded = await resolveByOnChainSpec(input, decoded, (known) => decodeEnvelope(fetched.envelopeXdr, events, [], fetched.ledger, known, input.network));
|
|
79
109
|
return finish(input.network, decoded, input.confidenceOverride);
|
|
80
110
|
}
|
|
81
111
|
// XDR mode has no raw on-chain events, so the events-based cross-check is
|
|
@@ -93,6 +123,7 @@ export async function recordTransaction(input) {
|
|
|
93
123
|
return err('RECORDING_FAILED', e.message, false);
|
|
94
124
|
return err('RECORDING_FAILED', `failed to decode base64 envelope XDR: ${e.message}`, false);
|
|
95
125
|
}
|
|
126
|
+
decoded = await resolveByOnChainSpec(input, decoded, (known) => decodeEnvelopeXdr(xdrStr, [], [], 0, known, input.network));
|
|
96
127
|
return finish(input.network, decoded, input.confidenceOverride);
|
|
97
128
|
}
|
|
98
129
|
function combineEvents(raw) {
|
package/dist/record/rpc.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export interface SorobanTxResponse {
|
|
|
14
14
|
events: rpc.Api.TransactionEvents;
|
|
15
15
|
}
|
|
16
16
|
export type RpcFetcher = (hash: string) => Promise<SorobanTxResponse | null>;
|
|
17
|
+
/** Exported so the on-chain spec lookup reads from the SAME endpoint the
|
|
18
|
+
* recorder fetched the transaction from. Two different endpoints could
|
|
19
|
+
* disagree about what a contract is. */
|
|
20
|
+
export declare const PUBLIC_RPC_URLS: Record<Network, string>;
|
|
17
21
|
/** Build a fetcher backed by the public Soroban RPC for the given network.
|
|
18
22
|
* Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
|
|
19
23
|
export declare function createRpcServer(network: Network): RpcFetcher;
|
package/dist/record/rpc.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// Unit tests inject a fetcher so they never hit the network; the integration test
|
|
5
5
|
// uses the real Soroban RPC through `createRpcServer`.
|
|
6
6
|
import { rpc } from '@stellar/stellar-sdk';
|
|
7
|
-
|
|
7
|
+
/** Exported so the on-chain spec lookup reads from the SAME endpoint the
|
|
8
|
+
* recorder fetched the transaction from. Two different endpoints could
|
|
9
|
+
* disagree about what a contract is. */
|
|
10
|
+
export const PUBLIC_RPC_URLS = {
|
|
8
11
|
testnet: 'https://soroban-testnet.stellar.org',
|
|
9
12
|
// The brief pins testnet; mainnet is left to the caller via injection. We keep
|
|
10
13
|
// a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ScVal } from '../types.ts';
|
|
2
|
-
import type { ProtocolId } from './protocols.ts';
|
|
2
|
+
import type { AbiArg, ProtocolId } from './protocols.ts';
|
|
3
3
|
export interface IdentifiedProtocol {
|
|
4
4
|
protocol: ProtocolId;
|
|
5
5
|
fn: string;
|
|
@@ -9,3 +9,12 @@ export interface IdentifiedProtocol {
|
|
|
9
9
|
* off, or unknown method on a pinned address). The caller MUST keep the
|
|
10
10
|
* null path fail-closed. */
|
|
11
11
|
export declare function identifyProtocol(contract: string, method: string, args: ScVal[], network?: 'mainnet' | 'testnet'): IdentifiedProtocol | null;
|
|
12
|
+
/** Compare the decoded args against the ABI signature. Returns true only when
|
|
13
|
+
* the arg count matches AND every arg's ScVal subset type matches the ABI's
|
|
14
|
+
* declared type. `other` is intentionally NOT a valid ABI match - it means
|
|
15
|
+
* the decoder couldn't classify the value, which is exactly the signal
|
|
16
|
+
* fail-closed should refuse. */
|
|
17
|
+
/** Exported so the on-chain-spec path checks a call the SAME way a pinned
|
|
18
|
+
* protocol does. Reimplementing it there would let the two drift, and a
|
|
19
|
+
* looser copy would be the fail-OPEN direction. */
|
|
20
|
+
export declare function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean;
|
|
@@ -88,7 +88,10 @@ export function identifyProtocol(contract, method, args, network) {
|
|
|
88
88
|
* declared type. `other` is intentionally NOT a valid ABI match - it means
|
|
89
89
|
* the decoder couldn't classify the value, which is exactly the signal
|
|
90
90
|
* fail-closed should refuse. */
|
|
91
|
-
|
|
91
|
+
/** Exported so the on-chain-spec path checks a call the SAME way a pinned
|
|
92
|
+
* protocol does. Reimplementing it there would let the two drift, and a
|
|
93
|
+
* looser copy would be the fail-OPEN direction. */
|
|
94
|
+
export function argsMatchAbi(expected, actual) {
|
|
92
95
|
if (expected.length !== actual.length)
|
|
93
96
|
return false;
|
|
94
97
|
for (let i = 0; i < expected.length; i += 1) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { contract as sdkContract } from '@stellar/stellar-sdk';
|
|
2
|
+
import type { ContractInvocation } from '../types.ts';
|
|
3
|
+
import type { AbiArgType, ProtocolAbi } from './protocols.ts';
|
|
4
|
+
/** Map an XDR spec type to the ScVal subset vocabulary the matcher uses.
|
|
5
|
+
*
|
|
6
|
+
* Returns null for a type the recorder's ScVal subset cannot represent. That
|
|
7
|
+
* is deliberate: an argument we cannot type is an argument we cannot check,
|
|
8
|
+
* and claiming a match on it would be the fail-OPEN direction. A function
|
|
9
|
+
* with any such argument is dropped from the derived ABI, so a call to it
|
|
10
|
+
* stays unrecognised rather than being waved through. */
|
|
11
|
+
export declare function abiTypeFromSpecType(specTypeName: string): AbiArgType | null;
|
|
12
|
+
/** The RPC surface this module needs. Narrowed to one method so a test can
|
|
13
|
+
* supply a stub without standing up a server, and so the recorder's existing
|
|
14
|
+
* RPC client can be passed straight in. */
|
|
15
|
+
export interface SpecFetcher {
|
|
16
|
+
contractSpec(contractId: string): Promise<sdkContract.Spec | null>;
|
|
17
|
+
}
|
|
18
|
+
/** Build a `SpecFetcher` over the SDK's contract client. */
|
|
19
|
+
export declare function specFetcherFromRpc(rpcUrl: string, networkPassphrase: string): SpecFetcher;
|
|
20
|
+
/** Convert a fetched spec into the ABI shape `identifyProtocol` already
|
|
21
|
+
* matches against. Functions with an argument outside the ScVal subset are
|
|
22
|
+
* OMITTED rather than partially typed. */
|
|
23
|
+
export declare function abiFromSpec(spec: sdkContract.Spec): ProtocolAbi;
|
|
24
|
+
/** Fetch and convert in one step. Returns null when no usable interface was
|
|
25
|
+
* obtained, which the caller must treat as "unrecognised". */
|
|
26
|
+
export declare function fetchContractAbi(contractId: string, fetcher: SpecFetcher): Promise<ProtocolAbi | null>;
|
|
27
|
+
/** Contracts whose EVERY recorded call matches their own published interface.
|
|
28
|
+
*
|
|
29
|
+
* All-or-nothing per contract, deliberately. A contract where one call
|
|
30
|
+
* verifies and another does not is a contract we do not understand, and
|
|
31
|
+
* marking it recognised would raise confidence on the strength of the call
|
|
32
|
+
* we happened to check. The unverified call is the one that matters.
|
|
33
|
+
*
|
|
34
|
+
* A contract with no fetchable spec, or a call naming a function absent from
|
|
35
|
+
* it, simply stays unknown - this only ever ADDS recognition, so a failure
|
|
36
|
+
* here degrades to today's behaviour rather than to a wrong answer. */
|
|
37
|
+
export declare function resolveContractsByOnChainSpec(invocations: ReadonlyArray<ContractInvocation>, candidates: ReadonlyArray<string>, fetcher: SpecFetcher): Promise<Set<string>>;
|