@crediolabs/policy-synth 0.1.11 → 0.1.13
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.js +21 -0
- package/dist/adapters/oz/adapter.js +4 -0
- package/dist/install/build-install-policy.d.ts +218 -0
- package/dist/install/build-install-policy.js +427 -0
- package/dist/install/get-interpreter-info.d.ts +29 -0
- package/dist/install/get-interpreter-info.js +41 -0
- package/dist/ir/types.d.ts +19 -0
- package/dist/predicate/index.d.ts +1 -1
- package/dist/predicate/index.js +1 -1
- package/dist/review-card/builder.js +32 -0
- package/dist/review-card/cross-check.js +20 -0
- package/dist/run/index.d.ts +49 -4
- package/dist/run/index.js +324 -4
- package/dist/run/schemas.d.ts +1500 -4
- package/dist/run/schemas.js +288 -0
- package/dist/synth/compose-from-recording.d.ts +14 -0
- package/dist/synth/compose-from-recording.js +41 -2
- package/dist-cjs/adapters/interpreter/adapter.js +21 -0
- package/dist-cjs/adapters/oz/adapter.js +4 -0
- package/dist-cjs/install/build-install-policy.d.ts +218 -0
- package/dist-cjs/install/build-install-policy.js +431 -0
- package/dist-cjs/install/get-interpreter-info.d.ts +29 -0
- package/dist-cjs/install/get-interpreter-info.js +44 -0
- package/dist-cjs/ir/types.d.ts +19 -0
- package/dist-cjs/predicate/index.d.ts +1 -1
- package/dist-cjs/predicate/index.js +3 -3
- package/dist-cjs/review-card/builder.js +32 -0
- package/dist-cjs/review-card/cross-check.js +20 -0
- package/dist-cjs/run/index.d.ts +49 -4
- package/dist-cjs/run/index.js +339 -3
- package/dist-cjs/run/schemas.d.ts +1500 -4
- package/dist-cjs/run/schemas.js +289 -1
- package/dist-cjs/synth/compose-from-recording.d.ts +14 -0
- package/dist-cjs/synth/compose-from-recording.js +41 -2
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +21 -0
- package/src/adapters/oz/adapter.ts +4 -0
- package/src/install/build-install-policy.ts +686 -0
- package/src/install/get-interpreter-info.ts +68 -0
- package/src/ir/types.ts +19 -0
- package/src/predicate/index.ts +1 -1
- package/src/review-card/builder.ts +27 -0
- package/src/review-card/cross-check.ts +25 -1
- package/src/run/index.ts +405 -5
- package/src/run/schemas.ts +326 -0
- package/src/synth/compose-from-recording.ts +55 -0
- package/src/synth/evaluate.ts +0 -1
- package/src/synth/permit-context.ts +0 -1
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// src/install/build-install-policy.ts - builds the unsigned Soroban
|
|
2
|
+
// transaction XDR for `account.add_context_rule(...)` and
|
|
3
|
+
// `account.remove_context_rule(...)`.
|
|
4
|
+
//
|
|
5
|
+
// The MCP server is stateless and holds no key material (server.ts:10-12),
|
|
6
|
+
// so this module NEVER signs. The caller (the wallet / CLI / SDK consumer)
|
|
7
|
+
// wraps the returned XDR in a transaction envelope and signs that envelope
|
|
8
|
+
// with their wallet; the wallet signature IS the user-confirmation step.
|
|
9
|
+
//
|
|
10
|
+
// We deliberately depart from the spec in plans/phase-04 which calls for a
|
|
11
|
+
// two-call `install_policy`/`confirm_install` pair backed by a host-signed
|
|
12
|
+
// short-TTL `action_id`. That contract requires (a) a stateful store and
|
|
13
|
+
// (b) key material the server does not have, so we ship the simpler ONE-CALL
|
|
14
|
+
// shape. The wallet signature covers the change.
|
|
15
|
+
//
|
|
16
|
+
// `buildInstallPolicyXdr` returns CALL 1 ONLY. Call 2 (interpreter.install)
|
|
17
|
+
// needs the rule id the account assigns in call 1, so it cannot be pre-built
|
|
18
|
+
// and is documented in the response envelope under `followUp`.
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { Address, BASE_FEE, Contract, Keypair, Operation, rpc, scValToBigInt, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
|
|
21
|
+
import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from "./build-add-context-rule.js";
|
|
22
|
+
/** Convert a real rpc.Server into the InstallRpcClient surface. The
|
|
23
|
+
* `getContractVersion` lookup uses `simulateTransaction` against
|
|
24
|
+
* `contract.call('grammar_version')` and decodes the returned u32.
|
|
25
|
+
*
|
|
26
|
+
* The passphrase is a REQUIRED argument rather than something read off the
|
|
27
|
+
* server: `rpc.Server` does not carry one, so reaching for
|
|
28
|
+
* `server.networkPassphrase` yielded a non-string and every version probe
|
|
29
|
+
* died with "Invalid passphrase provided to Transaction". The caller knows
|
|
30
|
+
* which network it dialled; make it say so. */
|
|
31
|
+
export function rpcClientFromServer(server, networkPassphrase) {
|
|
32
|
+
return {
|
|
33
|
+
getAccount: (address) => server.getAccount(address),
|
|
34
|
+
simulateTransaction: (tx) => server.simulateTransaction(tx),
|
|
35
|
+
getLatestLedger: () => server.getLatestLedger(),
|
|
36
|
+
async getContractVersion(address) {
|
|
37
|
+
// The source account is constructed locally rather than fetched. This is
|
|
38
|
+
// a read-only simulation, so the sequence number is never checked, and a
|
|
39
|
+
// random key does NOT exist on chain - asking the network for it returns
|
|
40
|
+
// 404 and the version probe fails for a reason that has nothing to do
|
|
41
|
+
// with the contract being probed.
|
|
42
|
+
const account = new Account(Keypair.random().publicKey(), '0');
|
|
43
|
+
const tx = new TransactionBuilder(account, {
|
|
44
|
+
fee: BASE_FEE,
|
|
45
|
+
networkPassphrase,
|
|
46
|
+
})
|
|
47
|
+
.addOperation(new Contract(address).call('grammar_version'))
|
|
48
|
+
.setTimeout(30)
|
|
49
|
+
.build();
|
|
50
|
+
const sim = await server.simulateTransaction(tx);
|
|
51
|
+
if (rpc.Api.isSimulationError(sim)) {
|
|
52
|
+
throw new Error(`getContractVersion: simulateTransaction failed: ${sim.error}`);
|
|
53
|
+
}
|
|
54
|
+
if (!sim.result?.retval) {
|
|
55
|
+
throw new Error('getContractVersion: grammar_version() returned no value');
|
|
56
|
+
}
|
|
57
|
+
const native = scValToBigInt(sim.result.retval);
|
|
58
|
+
if (typeof native !== 'bigint') {
|
|
59
|
+
throw new Error('getContractVersion: grammar_version() did not return an integer');
|
|
60
|
+
}
|
|
61
|
+
return Number(native);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const FOLLOWUP_HINT = 'after this tx confirms, read the rule id via account.get_context_rules_count()-1 (or get the receipt events), then call runInstallPolicy a second time with the interpreter as the target and the rule id in context_rule_ids - OR hand-build the install() call directly with the OZ auth pattern.';
|
|
66
|
+
/** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
|
|
67
|
+
* The output XDR is signed by the wallet, not by us. */
|
|
68
|
+
export async function buildInstallPolicyXdr(args) {
|
|
69
|
+
// 1. Pure arg encoding first - fail closed on any limit / shape problem
|
|
70
|
+
// BEFORE we burn a network round-trip on a malformed call.
|
|
71
|
+
const callArgs = buildAddContextRuleArgs({
|
|
72
|
+
contextRuleType: args.rule.contextRuleType,
|
|
73
|
+
name: args.rule.name,
|
|
74
|
+
validUntilLedger: args.rule.validUntilLedger,
|
|
75
|
+
signers: args.rule.signers,
|
|
76
|
+
policies: args.rule.policies.map(adaptPolicyRef),
|
|
77
|
+
}, {
|
|
78
|
+
signers: args.rule.signers,
|
|
79
|
+
policies: args.rule.policies.map(adaptPolicyRef),
|
|
80
|
+
installNonce: args.installNonce,
|
|
81
|
+
encodedPredicate: args.encodedPredicate,
|
|
82
|
+
predicateHash: args.predicateHash,
|
|
83
|
+
...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
|
|
84
|
+
...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
|
|
85
|
+
});
|
|
86
|
+
// 2. Network pass: source account sequence + latest ledger.
|
|
87
|
+
const source = await args.rpc.getAccount(args.sourceAccount);
|
|
88
|
+
const latest = await args.rpc.getLatestLedger();
|
|
89
|
+
const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
|
|
90
|
+
// 3. Build the unsigned transaction envelope with the bare host call.
|
|
91
|
+
const op = Operation.invokeHostFunction({
|
|
92
|
+
func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
|
|
93
|
+
contractAddress: new Address(args.smartAccount).toScAddress(),
|
|
94
|
+
functionName: 'add_context_rule',
|
|
95
|
+
args: [...callArgs],
|
|
96
|
+
})),
|
|
97
|
+
auth: [],
|
|
98
|
+
});
|
|
99
|
+
const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
|
|
100
|
+
const tx = buildUnsignedTx({
|
|
101
|
+
sourceAccount: args.sourceAccount,
|
|
102
|
+
sequence: source.sequenceNumber(),
|
|
103
|
+
fee: baseFee,
|
|
104
|
+
networkPassphrase: args.networkPassphrase,
|
|
105
|
+
op,
|
|
106
|
+
});
|
|
107
|
+
// 4. Simulation pass. Returns the auth nonce + rootInvocation the wallet
|
|
108
|
+
// signature binds to. We do NOT add the AuthPayload here - the wallet
|
|
109
|
+
// injects auth entries using its own __check_auth flow.
|
|
110
|
+
const recorded = await args.rpc.simulateTransaction(tx);
|
|
111
|
+
if (rpc.Api.isSimulationError(recorded)) {
|
|
112
|
+
// Short, stable reason. The full `simulateTransaction` error (which
|
|
113
|
+
// carries host + URL detail) stays in the SDK's own logs - never
|
|
114
|
+
// reflected back into a user-facing message where it would
|
|
115
|
+
// reconnoitre the RPC.
|
|
116
|
+
throw new Error('install_policy: simulateTransaction failed');
|
|
117
|
+
}
|
|
118
|
+
const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
|
|
119
|
+
Address.fromScAddress(entry.credentials().address().address()).toString() ===
|
|
120
|
+
args.smartAccount);
|
|
121
|
+
if (!original) {
|
|
122
|
+
throw new Error(`install_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
|
|
123
|
+
}
|
|
124
|
+
// 5. Decode the structured description FROM the unsigned XDR. The human
|
|
125
|
+
// approval binds to the bytes the wallet will sign; deriving this from
|
|
126
|
+
// the input args would re-describe the caller's intent rather than the
|
|
127
|
+
// transaction. Re-parse the envelope we just built so the description
|
|
128
|
+
// reflects what the wallet actually signs, not what we thought it would.
|
|
129
|
+
const describes = decodeInstallCallDescribes(tx, op, args.installNonce);
|
|
130
|
+
return {
|
|
131
|
+
unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
|
|
132
|
+
smartAccount: args.smartAccount,
|
|
133
|
+
sourceAccount: args.sourceAccount,
|
|
134
|
+
call: { contract: args.smartAccount, fn: 'add_context_rule' },
|
|
135
|
+
followUp: {
|
|
136
|
+
contract: 'interpreter',
|
|
137
|
+
fn: 'install',
|
|
138
|
+
requiresRuleIdFromCallOne: true,
|
|
139
|
+
hint: FOLLOWUP_HINT,
|
|
140
|
+
},
|
|
141
|
+
describes,
|
|
142
|
+
authNonce: original.credentials().address().nonce().toString(),
|
|
143
|
+
authValidUntilLedger: validUntilLedger,
|
|
144
|
+
rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Build an unsigned XDR for `account.remove_context_rule(ruleId)`. The
|
|
148
|
+
* smart account itself handles uninstalling each attached policy (calling
|
|
149
|
+
* `interpreter.uninstall` for the interpreter policy); this builder only
|
|
150
|
+
* emits the account-side removal call.
|
|
151
|
+
*
|
|
152
|
+
* Who may authorise it is the ACCOUNT's decision, and the account's source is
|
|
153
|
+
* not in this repo. The interpreter's own `uninstall` is master-gated, but
|
|
154
|
+
* that is a different entry point from this one, so do not restate it as the
|
|
155
|
+
* rule for this call. What is proven on testnet is that the account's
|
|
156
|
+
* deployer can revoke. This builder does not pre-check the signer: it would
|
|
157
|
+
* be guessing at a contract it cannot read, and the chain is the authority. */
|
|
158
|
+
export async function buildRevokePolicyXdr(args) {
|
|
159
|
+
const source = await args.rpc.getAccount(args.sourceAccount);
|
|
160
|
+
const latest = await args.rpc.getLatestLedger();
|
|
161
|
+
const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
|
|
162
|
+
const op = Operation.invokeHostFunction({
|
|
163
|
+
func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
|
|
164
|
+
contractAddress: new Address(args.smartAccount).toScAddress(),
|
|
165
|
+
functionName: 'remove_context_rule',
|
|
166
|
+
args: [xdr.ScVal.scvU32(args.ruleId)],
|
|
167
|
+
})),
|
|
168
|
+
auth: [],
|
|
169
|
+
});
|
|
170
|
+
const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
|
|
171
|
+
const tx = buildUnsignedTx({
|
|
172
|
+
sourceAccount: args.sourceAccount,
|
|
173
|
+
sequence: source.sequenceNumber(),
|
|
174
|
+
fee: baseFee,
|
|
175
|
+
networkPassphrase: args.networkPassphrase,
|
|
176
|
+
op,
|
|
177
|
+
});
|
|
178
|
+
const recorded = await args.rpc.simulateTransaction(tx);
|
|
179
|
+
if (rpc.Api.isSimulationError(recorded)) {
|
|
180
|
+
// Short, stable reason. The full `simulateTransaction` error (which
|
|
181
|
+
// carries host + URL detail) stays in the SDK's own logs - never
|
|
182
|
+
// reflected back into a user-facing message where it would
|
|
183
|
+
// reconnoitre the RPC.
|
|
184
|
+
throw new Error('revoke_policy: simulateTransaction failed');
|
|
185
|
+
}
|
|
186
|
+
const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
|
|
187
|
+
Address.fromScAddress(entry.credentials().address().address()).toString() ===
|
|
188
|
+
args.smartAccount);
|
|
189
|
+
if (!original) {
|
|
190
|
+
throw new Error(`revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
|
|
194
|
+
smartAccount: args.smartAccount,
|
|
195
|
+
sourceAccount: args.sourceAccount,
|
|
196
|
+
call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
|
|
197
|
+
authNonce: original.credentials().address().nonce().toString(),
|
|
198
|
+
authValidUntilLedger: validUntilLedger,
|
|
199
|
+
rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** ~25 minutes at 5s/ledger. */
|
|
203
|
+
const DEFAULT_AUTH_VALID_LEDGERS = 300;
|
|
204
|
+
// ---- internals ----
|
|
205
|
+
/** Adapter: turn the install-policy wire `PolicyRef` shape into the core
|
|
206
|
+
* `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
|
|
207
|
+
* schema hand-rolled + flat (the strict union would need a recursive
|
|
208
|
+
* schema) while delegating to the proven encoder for the actual bytes. */
|
|
209
|
+
function adaptPolicyRef(p) {
|
|
210
|
+
if (p.kind === 'interpreter') {
|
|
211
|
+
return {
|
|
212
|
+
kind: 'interpreter',
|
|
213
|
+
interpreterAddress: p.interpreterAddress,
|
|
214
|
+
predicateBlobBase64: p.predicateBlobBase64,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
kind: 'oz_builtin',
|
|
219
|
+
instanceAddress: p.instanceAddress,
|
|
220
|
+
primitive: p.primitive,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Build an unsigned Soroban transaction envelope. The `sequence` is
|
|
224
|
+
* whatever `getAccount().sequenceNumber()` returned (a string of digits
|
|
225
|
+
* the SDK accepts). No signing. The returned Transaction is what
|
|
226
|
+
* `simulateTransaction` accepts. */
|
|
227
|
+
function buildUnsignedTx(args) {
|
|
228
|
+
const account = new Account(args.sourceAccount, args.sequence);
|
|
229
|
+
const tx = new TransactionBuilder(account, {
|
|
230
|
+
fee: args.fee,
|
|
231
|
+
networkPassphrase: args.networkPassphrase,
|
|
232
|
+
})
|
|
233
|
+
.addOperation(args.op)
|
|
234
|
+
.setTimeout(0)
|
|
235
|
+
.build();
|
|
236
|
+
// `TransactionBuilder.build()` returns a Transaction whose envelope
|
|
237
|
+
// has empty signatures. The wallet re-reads the unsigned XDR, appends
|
|
238
|
+
// its signature, and broadcasts. We do NOT sign here.
|
|
239
|
+
return tx;
|
|
240
|
+
}
|
|
241
|
+
/** Decode the install call's structured fields directly out of the
|
|
242
|
+
* built (transaction, operation) pair. The bytes are the source of
|
|
243
|
+
* truth: a human reviewing the install wants to see what the wallet
|
|
244
|
+
* will actually sign, not what we think it will. The decode is
|
|
245
|
+
* deliberately strict - any shape we did not build ourselves throws
|
|
246
|
+
* a ToolError, since that would mean the XDR was tampered with (or
|
|
247
|
+
* the encoder changed and this descriptor lags).
|
|
248
|
+
*
|
|
249
|
+
* `expectedInstallNonce` is a fallback for the rare case where the
|
|
250
|
+
* install has no interpreter policy (no `install_nonce` to read); the
|
|
251
|
+
* caller already knows it. */
|
|
252
|
+
function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
|
|
253
|
+
const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
|
|
254
|
+
if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
|
|
255
|
+
throw new Error('install_policy: built op is not an invokeHostFunction(invokeContract(...)) call');
|
|
256
|
+
}
|
|
257
|
+
const invokeArgs = hostFn.invokeContract();
|
|
258
|
+
if (!invokeArgs) {
|
|
259
|
+
throw new Error('install_policy: built op has no InvokeContractArgs payload');
|
|
260
|
+
}
|
|
261
|
+
const targetContract = Address.fromScAddress(invokeArgs.contractAddress()).toString();
|
|
262
|
+
const fnName = invokeArgs.functionName().toString();
|
|
263
|
+
if (fnName !== 'add_context_rule') {
|
|
264
|
+
throw new Error(`install_policy: built op fn name is "${fnName}", expected "add_context_rule"`);
|
|
265
|
+
}
|
|
266
|
+
const scvArgs = invokeArgs.args();
|
|
267
|
+
if (scvArgs.length !== 5) {
|
|
268
|
+
throw new Error(`install_policy: built op has ${scvArgs.length} args, expected 5 (context_type, name, valid_until, signers, policies)`);
|
|
269
|
+
}
|
|
270
|
+
const contextType = scvArgs[0];
|
|
271
|
+
const nameScv = scvArgs[1];
|
|
272
|
+
const validUntilScv = scvArgs[2];
|
|
273
|
+
const signersScv = scvArgs[3];
|
|
274
|
+
const policiesScv = scvArgs[4];
|
|
275
|
+
// The length check above pins these as defined; the local references
|
|
276
|
+
// satisfy noUncheckedIndexedAccess on the accessors below.
|
|
277
|
+
if (!nameScv || !validUntilScv || !signersScv || !policiesScv || !contextType) {
|
|
278
|
+
throw new Error('install_policy: built op args include an undefined slot despite length check');
|
|
279
|
+
}
|
|
280
|
+
// name
|
|
281
|
+
if (nameScv.switch().name !== 'scvString') {
|
|
282
|
+
throw new Error('install_policy: args[1] (name) is not an ScVal::String');
|
|
283
|
+
}
|
|
284
|
+
const ruleName = nameScv.str().toString();
|
|
285
|
+
// valid_until (Option<u32> -> void when absent)
|
|
286
|
+
let validUntilLedger;
|
|
287
|
+
if (validUntilScv.switch().name === 'scvVoid') {
|
|
288
|
+
validUntilLedger = null;
|
|
289
|
+
}
|
|
290
|
+
else if (validUntilScv.switch().name === 'scvU32') {
|
|
291
|
+
validUntilLedger = validUntilScv.u32();
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
throw new Error('install_policy: args[2] (valid_until) is neither void nor u32');
|
|
295
|
+
}
|
|
296
|
+
// signers (Vec<Vec<Symbol, Address[, Bytes]>>)
|
|
297
|
+
if (signersScv.switch().name !== 'scvVec') {
|
|
298
|
+
throw new Error('install_policy: args[3] (signers) is not an ScVal::Vec');
|
|
299
|
+
}
|
|
300
|
+
const signers = [];
|
|
301
|
+
for (const signerScv of signersScv.vec() ?? []) {
|
|
302
|
+
if (signerScv.switch().name !== 'scvVec') {
|
|
303
|
+
throw new Error('install_policy: a signer entry is not an ScVal::Vec');
|
|
304
|
+
}
|
|
305
|
+
const tuple = signerScv.vec() ?? [];
|
|
306
|
+
const tag = tuple[0]?.sym().toString();
|
|
307
|
+
const inner = tuple[1];
|
|
308
|
+
if (tag === 'Delegated') {
|
|
309
|
+
if (!inner || inner.switch().name !== 'scvAddress') {
|
|
310
|
+
throw new Error('install_policy: a Delegated signer is missing its Address');
|
|
311
|
+
}
|
|
312
|
+
signers.push({
|
|
313
|
+
kind: 'delegated',
|
|
314
|
+
address: Address.fromScAddress(inner.address()).toString(),
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (tag === 'External') {
|
|
319
|
+
if (!inner || inner.switch().name !== 'scvAddress') {
|
|
320
|
+
throw new Error('install_policy: an External signer is missing its verifier Address');
|
|
321
|
+
}
|
|
322
|
+
signers.push({
|
|
323
|
+
kind: 'external',
|
|
324
|
+
verifier: Address.fromScAddress(inner.address()).toString(),
|
|
325
|
+
});
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
throw new Error(`install_policy: signer tag "${tag ?? '<missing>'}" is not Delegated|External`);
|
|
329
|
+
}
|
|
330
|
+
// policies (Map<Address, Val>). Re-derive an interpreter-vs-OZ primitive
|
|
331
|
+
// classification by inspecting the value's map keys (PolicyInstallParams
|
|
332
|
+
// starts with `grammar_version`/`install_nonce`/`predicate`; OZ primitive
|
|
333
|
+
// params start with `spending_limit`/`threshold`/`period_ledgers`).
|
|
334
|
+
if (policiesScv.switch().name !== 'scvMap') {
|
|
335
|
+
throw new Error('install_policy: args[4] (policies) is not an ScVal::Map');
|
|
336
|
+
}
|
|
337
|
+
const policies = [];
|
|
338
|
+
let observedInstallNonce = null;
|
|
339
|
+
for (const entry of policiesScv.map() ?? []) {
|
|
340
|
+
const key = entry.key();
|
|
341
|
+
if (key.switch().name !== 'scvAddress') {
|
|
342
|
+
throw new Error('install_policy: a policies map entry has a non-Address key');
|
|
343
|
+
}
|
|
344
|
+
const address = Address.fromScAddress(key.address()).toString();
|
|
345
|
+
const val = entry.val();
|
|
346
|
+
if (val.switch().name !== 'scvMap') {
|
|
347
|
+
throw new Error(`install_policy: policies[${address}] value is not an ScVal::Map (got ${val.switch().name})`);
|
|
348
|
+
}
|
|
349
|
+
const fields = new Map();
|
|
350
|
+
for (const inner of val.map() ?? []) {
|
|
351
|
+
const fk = inner.key();
|
|
352
|
+
if (fk.switch().name !== 'scvSymbol') {
|
|
353
|
+
throw new Error(`install_policy: policies[${address}] field key is not an ScVal::Symbol`);
|
|
354
|
+
}
|
|
355
|
+
fields.set(fk.sym().toString(), inner.val());
|
|
356
|
+
}
|
|
357
|
+
// Interpreter policy fields carry grammar_version/install_nonce/predicate.
|
|
358
|
+
// OZ primitives carry spending_limit/period_ledgers/threshold/signers.
|
|
359
|
+
if (fields.has('predicate') || fields.has('grammar_version') || fields.has('install_nonce')) {
|
|
360
|
+
const installNonceScv = fields.get('install_nonce');
|
|
361
|
+
if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
|
|
362
|
+
throw new Error(`install_policy: interpreter policy ${address} is missing a u32 install_nonce`);
|
|
363
|
+
}
|
|
364
|
+
const installNonce = installNonceScv.u32();
|
|
365
|
+
const predicateScv = fields.get('predicate');
|
|
366
|
+
if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
|
|
367
|
+
throw new Error(`install_policy: interpreter policy ${address} is missing its bytes predicate`);
|
|
368
|
+
}
|
|
369
|
+
const predicateBytes = Buffer.from(predicateScv.bytes());
|
|
370
|
+
const predicateSha256OfEmbeddedBytes = createHash('sha256')
|
|
371
|
+
.update(predicateBytes)
|
|
372
|
+
.digest('hex');
|
|
373
|
+
const predicateHashScv = fields.get('predicate_hash');
|
|
374
|
+
const predicateHash = predicateHashScv && predicateHashScv.switch().name === 'scvBytes'
|
|
375
|
+
? Buffer.from(predicateHashScv.bytes()).toString('hex')
|
|
376
|
+
: '';
|
|
377
|
+
policies.push({
|
|
378
|
+
kind: 'interpreter',
|
|
379
|
+
address,
|
|
380
|
+
installNonce,
|
|
381
|
+
predicateHash,
|
|
382
|
+
predicateSha256OfEmbeddedBytes,
|
|
383
|
+
});
|
|
384
|
+
observedInstallNonce = installNonce;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
// OZ built-in primitive. Distinguish by the parameter shape we
|
|
388
|
+
// emitted; matching one of the three primitives exactly pins the
|
|
389
|
+
// kind we built.
|
|
390
|
+
if (fields.has('spending_limit') && fields.has('period_ledgers')) {
|
|
391
|
+
policies.push({ kind: 'oz_builtin', address, primitive: 'spending_limit' });
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (fields.has('threshold') && fields.has('signer_weights')) {
|
|
395
|
+
policies.push({ kind: 'oz_builtin', address, primitive: 'weighted_threshold' });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (fields.has('threshold')) {
|
|
399
|
+
policies.push({ kind: 'oz_builtin', address, primitive: 'simple_threshold' });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
|
|
403
|
+
}
|
|
404
|
+
// `observedInstallNonce` is the nonce baked into whichever interpreter
|
|
405
|
+
// policy is present; when none, fall back to the caller-supplied value.
|
|
406
|
+
const installNonce = observedInstallNonce ?? expectedInstallNonce;
|
|
407
|
+
// Touch `tx` so the linter does not flag it as unused - the parameter
|
|
408
|
+
// documents that the caller built this Transaction; we re-parse the
|
|
409
|
+
// operation off it as a sanity check.
|
|
410
|
+
void tx;
|
|
411
|
+
return {
|
|
412
|
+
targetContract,
|
|
413
|
+
fnName: 'add_context_rule',
|
|
414
|
+
ruleName,
|
|
415
|
+
validUntilLedger,
|
|
416
|
+
signers,
|
|
417
|
+
policies,
|
|
418
|
+
installNonce,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/** Re-export so the run-layer does not need to import from
|
|
422
|
+
* build-add-context-rule.ts (keeps the install/ -> install/ dependency
|
|
423
|
+
* direction intact). */
|
|
424
|
+
export { Contract, DEFAULT_GRAMMAR_VERSION };
|
|
425
|
+
// Local re-import to avoid pulling the class from the SDK module path
|
|
426
|
+
// at the top of the file (avoids the unused-import lint).
|
|
427
|
+
import { Account } from '@stellar/stellar-sdk';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Network } from '../types.ts';
|
|
2
|
+
export interface InterpreterInfo {
|
|
3
|
+
/** Pinned interpreter contract address. */
|
|
4
|
+
pinnedAddress: string;
|
|
5
|
+
/** Pinned grammar version (matches SELF_VERSION in version.rs). */
|
|
6
|
+
pinnedGrammarVersion: number;
|
|
7
|
+
/** Pinned wasm sha256 (hex). */
|
|
8
|
+
pinnedWasmHash: string;
|
|
9
|
+
/** Network this pin applies to (the address + hash are network-scoped). */
|
|
10
|
+
network: Network;
|
|
11
|
+
/** Present only when the caller supplied a live `deployedGrammarVersion`. */
|
|
12
|
+
deployedGrammarVersion?: number;
|
|
13
|
+
/** True when `deployedGrammarVersion` matches `pinnedGrammarVersion`.
|
|
14
|
+
* Absent when no live verification was performed. */
|
|
15
|
+
liveMatchesPin?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Build the interpreter-info response. When `deployedGrammarVersion` is
|
|
18
|
+
* supplied (after a live RPC `grammar_version()` call by the run layer),
|
|
19
|
+
* compares it to the pin and sets `liveMatchesPin`. When absent, returns
|
|
20
|
+
* the pin alone. */
|
|
21
|
+
export declare function getInterpreterInfo(args: {
|
|
22
|
+
pinnedAddress: string;
|
|
23
|
+
pinnedGrammarVersion: number;
|
|
24
|
+
pinnedWasmHash: string;
|
|
25
|
+
network: Network;
|
|
26
|
+
/** When supplied, the u32 returned by the live contract's
|
|
27
|
+
* `grammar_version()` RPC call. */
|
|
28
|
+
deployedGrammarVersion?: number;
|
|
29
|
+
}): InterpreterInfo;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/install/get-interpreter-info.ts - read-only fingerprint lookup for
|
|
2
|
+
// the interpreter contract.
|
|
3
|
+
//
|
|
4
|
+
// Returns the pinned deployment fingerprint + (optionally) compares a
|
|
5
|
+
// caller-supplied live `grammar_version` against the pin. No fabricated
|
|
6
|
+
// audit field; a real deployed-contract check is worth more than a fake
|
|
7
|
+
// reference.
|
|
8
|
+
//
|
|
9
|
+
// Per design decision 5: phase-04's "audit #44" is aspirational and has no
|
|
10
|
+
// source-of-truth in the repo. Returning a fabricated audit id would be a
|
|
11
|
+
// lie on a security surface. The honest outputs are:
|
|
12
|
+
// - the pinned address (DEPLOYMENTS.md)
|
|
13
|
+
// - the pinned grammar version (SELF_VERSION in version.rs)
|
|
14
|
+
// - the pinned wasm sha256 (DEPLOYMENTS.md)
|
|
15
|
+
// - an OPTIONAL `deployedGrammarVersion` returned by a live `grammar_version()`
|
|
16
|
+
// RPC call, with a `liveMatchesPin` boolean the caller can dispatch on. A
|
|
17
|
+
// mismatch means the deployed wasm is NOT the pinned artifact - the caller
|
|
18
|
+
// should refuse to install until they redeploy.
|
|
19
|
+
//
|
|
20
|
+
// The RPC plumbing lives in the run layer (`run/index.ts`); this module
|
|
21
|
+
// stays pure so it is testable without a network.
|
|
22
|
+
/** Build the interpreter-info response. When `deployedGrammarVersion` is
|
|
23
|
+
* supplied (after a live RPC `grammar_version()` call by the run layer),
|
|
24
|
+
* compares it to the pin and sets `liveMatchesPin`. When absent, returns
|
|
25
|
+
* the pin alone. */
|
|
26
|
+
export function getInterpreterInfo(args) {
|
|
27
|
+
const info = {
|
|
28
|
+
pinnedAddress: args.pinnedAddress,
|
|
29
|
+
pinnedGrammarVersion: args.pinnedGrammarVersion,
|
|
30
|
+
pinnedWasmHash: args.pinnedWasmHash,
|
|
31
|
+
network: args.network,
|
|
32
|
+
};
|
|
33
|
+
if (typeof args.deployedGrammarVersion === 'number') {
|
|
34
|
+
return {
|
|
35
|
+
...info,
|
|
36
|
+
deployedGrammarVersion: args.deployedGrammarVersion,
|
|
37
|
+
liveMatchesPin: args.deployedGrammarVersion === args.pinnedGrammarVersion,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return info;
|
|
41
|
+
}
|
package/dist/ir/types.d.ts
CHANGED
|
@@ -85,6 +85,25 @@ export type IRCondition = {
|
|
|
85
85
|
op: 'eq_seq';
|
|
86
86
|
selector: IRSelector;
|
|
87
87
|
values: string[];
|
|
88
|
+
}
|
|
89
|
+
/** A swap's output floor, expressed against its own input: the output arg
|
|
90
|
+
* must be at least `inArgIndex * num / den`.
|
|
91
|
+
*
|
|
92
|
+
* It is its own construct because `compare` bounds a selector against a
|
|
93
|
+
* CONSTANT, and a slippage floor has no constant to bound against - the
|
|
94
|
+
* acceptable output depends on the input of the same call. Encoding the
|
|
95
|
+
* recorded output as a constant would pin a policy to one trade size.
|
|
96
|
+
*
|
|
97
|
+
* `num/den` is the caller's minimum acceptable output per unit of input.
|
|
98
|
+
* It is never derived from the recording: the recorded rate is a price at
|
|
99
|
+
* one moment, and freezing it as policy would deny normal trades later.
|
|
100
|
+
* Adapters that cannot express it (the OZ built-ins) flag it `uncovered`. */
|
|
101
|
+
| {
|
|
102
|
+
op: 'slippage_floor';
|
|
103
|
+
outArgIndex: number;
|
|
104
|
+
inArgIndex: number;
|
|
105
|
+
num: string;
|
|
106
|
+
den: string;
|
|
88
107
|
};
|
|
89
108
|
export interface IRPolicyRule {
|
|
90
109
|
/** NEAR-V2 roles whitelist (empty = any; owner exempt). */
|
package/dist/predicate/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/predicate/index.ts - re-export the canonical predicate encoder and the
|
|
2
2
|
// untrusted-JSON parser that feeds it. A consumer accepting a hand-written
|
|
3
3
|
// policy needs both halves.
|
|
4
|
-
export { encodePredicate } from "./encode.js";
|
|
5
4
|
export { decodeLeaf, decodeNode, decodePredicate } from "./decode.js";
|
|
5
|
+
export { encodePredicate } from "./encode.js";
|
|
6
6
|
export { jsonToAst } from "./from-json.js";
|
|
@@ -192,6 +192,38 @@ function renderComparison(node) {
|
|
|
192
192
|
const allowed = node.op === 'lt' ? right.value : right.value + 1;
|
|
193
193
|
return `At most ${allowed} calls per ${left.windowSecs} seconds`;
|
|
194
194
|
}
|
|
195
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
196
|
+
//
|
|
197
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
198
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
199
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
200
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
201
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
202
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
203
|
+
// silently understated the policy.
|
|
204
|
+
//
|
|
205
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
206
|
+
// reads as a path rather than as a comparison.
|
|
207
|
+
if (left.kind === 'call_arg') {
|
|
208
|
+
const head = `arg[${left.index}]`;
|
|
209
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
210
|
+
if (right.kind === 'literal_i128')
|
|
211
|
+
return `${head} ${sep} ${right.value}`;
|
|
212
|
+
if (right.kind === 'literal_u64')
|
|
213
|
+
return `${head} ${sep} ${right.value}`;
|
|
214
|
+
if (right.kind === 'literal_u32')
|
|
215
|
+
return `${head} ${sep} ${right.value}`;
|
|
216
|
+
if (right.kind === 'literal_address')
|
|
217
|
+
return `${head} ${sep} ${right.value}`;
|
|
218
|
+
if (right.kind === 'literal_symbol')
|
|
219
|
+
return `${head} ${sep} ${right.value}`;
|
|
220
|
+
if (right.kind === 'literal_bytes')
|
|
221
|
+
return `${head} ${sep} ${right.value}`;
|
|
222
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
223
|
+
if (right.kind === 'call_arg_scaled') {
|
|
224
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
195
227
|
// amount <= v -> Amount <= v
|
|
196
228
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
197
229
|
return `Amount <= ${right.value}`;
|
|
@@ -112,6 +112,26 @@ function pushComparison(left, right, op, out) {
|
|
|
112
112
|
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
116
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
117
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
118
|
+
if (left.kind === 'call_arg') {
|
|
119
|
+
const head = `arg[${left.index}]`;
|
|
120
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
121
|
+
if (right.kind === 'literal_i128' ||
|
|
122
|
+
right.kind === 'literal_u64' ||
|
|
123
|
+
right.kind === 'literal_u32' ||
|
|
124
|
+
right.kind === 'literal_address' ||
|
|
125
|
+
right.kind === 'literal_symbol' ||
|
|
126
|
+
right.kind === 'literal_bytes') {
|
|
127
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (right.kind === 'call_arg_scaled') {
|
|
131
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
115
135
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
116
136
|
out.push(`Amount <= ${right.value}`);
|
|
117
137
|
return;
|