@crediolabs/policy-synth 0.1.13 → 0.1.15
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/install/build-install-policy.d.ts +10 -12
- package/dist/install/build-install-policy.js +87 -60
- package/dist/install/build-install-predicate.d.ts +96 -0
- package/dist/install/build-install-predicate.js +444 -0
- package/dist/install/oz-auth.d.ts +27 -0
- package/dist/install/oz-auth.js +105 -0
- package/dist/run/index.d.ts +3 -3
- package/dist/run/index.js +3 -3
- package/dist-cjs/install/build-install-policy.d.ts +10 -12
- package/dist-cjs/install/build-install-policy.js +87 -60
- package/dist-cjs/install/build-install-predicate.d.ts +96 -0
- package/dist-cjs/install/build-install-predicate.js +479 -0
- package/dist-cjs/install/oz-auth.d.ts +27 -0
- package/dist-cjs/install/oz-auth.js +114 -0
- package/dist-cjs/run/index.d.ts +3 -3
- package/dist-cjs/run/index.js +3 -3
- package/package.json +1 -1
- package/src/install/build-install-policy.ts +155 -96
- package/src/install/oz-auth.ts +140 -0
- package/src/run/index.ts +3 -3
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// src/install/build-install-predicate.ts - builds the unsigned Soroban
|
|
2
|
+
// transaction XDR for `interpreter.install(params, raw_context_rule, smart_account)`.
|
|
3
|
+
//
|
|
4
|
+
// Call 2 of the install sequence. The OZ smart account's `add_context_rule`
|
|
5
|
+
// (call 1, see build-install-policy.ts) attaches the rule to the account and
|
|
6
|
+
// the interpreter POLICY address, but the interpreter itself needs the
|
|
7
|
+
// encoded predicate bytes + the matching hash + the verbatim context rule
|
|
8
|
+
// it will hash at enforce. Storing those is what `interpreter.install`
|
|
9
|
+
// does; without it the interpreter returns MissingState and the rule denies
|
|
10
|
+
// EVERYTHING - which on a deny-only demo is indistinguishable from a
|
|
11
|
+
// working policy.
|
|
12
|
+
//
|
|
13
|
+
// CALL 1 / CALL 2 NONCE LIAISON (critical invariant, empirically verified
|
|
14
|
+
// on testnet 2026-08-01):
|
|
15
|
+
// - The OZ smart account's `add_context_rule` increments the
|
|
16
|
+
// interpreter's stored nonce for `(smart_account, rule_id)` from 0
|
|
17
|
+
// to 1. (Verified via getLedgerEntries after call 1.)
|
|
18
|
+
// - `interpreter.install` requires
|
|
19
|
+
// `install_params.install_nonce == stored_nonce.saturating_add(1)`.
|
|
20
|
+
// - Therefore call 2 MUST derive its install_nonce from the stored
|
|
21
|
+
// nonce via getLedgerEntries - taking it as caller input is a design
|
|
22
|
+
// bug that produces NonceReplay (#202) for the caller every time
|
|
23
|
+
// call 1 has bumped the counter. (octopos historically accepts an
|
|
24
|
+
// arbitrary install_nonce and the user has to know to pass 2; this
|
|
25
|
+
// builder reads the stored nonce instead.)
|
|
26
|
+
//
|
|
27
|
+
// AUTH TREE (empirically verified on testnet 2026-08-01):
|
|
28
|
+
// The recording simulation of `interpreter.install` returns ONE
|
|
29
|
+
// address-credential auth entry:
|
|
30
|
+
// auth[0] credentials=address address=<SMART_ACCOUNT> root=install
|
|
31
|
+
// (The previously hypothesised second entry for the rule signer does
|
|
32
|
+
// not materialise on this pinned interpreter: `__check_auth` for the
|
|
33
|
+
// rule id in `contextRuleIds` runs the rule's `policy.enforce`, but
|
|
34
|
+
// because the rule's policy is the interpreter we are CALLING, the
|
|
35
|
+
// interpreter's own `install` does not recurse into require_auth on
|
|
36
|
+
// the rule signer; the only require_auth is the smart account's
|
|
37
|
+
// `__check_auth` from the rule the auth payload names.)
|
|
38
|
+
//
|
|
39
|
+
// The auth tree is therefore the same shape as `buildInstallPolicyXdr`:
|
|
40
|
+
// [0] accountEntry(original, validUntil, AuthPayload([sourceAccount], [ruleId], () => Bytes()))
|
|
41
|
+
// [1] delegatedSignerEntry(smartAccount, digest)
|
|
42
|
+
// The second entry is a `sorobanCredentialsSourceAccount` and the
|
|
43
|
+
// source-account envelope signature covers it. The wallet signs only
|
|
44
|
+
// the envelope. Verified on testnet 2026-08-01 against the pinned
|
|
45
|
+
// interpreter CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP.
|
|
46
|
+
//
|
|
47
|
+
// CONTEXT RULE (empirically verified on testnet 2026-08-01):
|
|
48
|
+
// `contextRuleIds` is the NEW rule id assigned in call 1 - exactly
|
|
49
|
+
// what octopos's `installPredicateDoc` passes. Earlier reasoning
|
|
50
|
+
// claimed rule 0 was required (chicken-and-egg: the new rule's
|
|
51
|
+
// interpreter policy denies `install` because no predicate is
|
|
52
|
+
// installed yet); that reasoning is wrong. The smart account
|
|
53
|
+
// consults the rule's interpreter policy for the `install` host call
|
|
54
|
+
// and the interpreter permits it - the rule's own install is a
|
|
55
|
+
// privileged install-only path that the interpreter lets through. A
|
|
56
|
+
// `[0]` choice was producing a structurally different auth tree
|
|
57
|
+
// whose digest bound to the wrong rule and whose `AuthPayload`'s
|
|
58
|
+
// context_rule_ids never matched the rule the interpreter recorded.
|
|
59
|
+
//
|
|
60
|
+
// `params` is an ScMap for a #[contracttype] struct. The host unpacks by
|
|
61
|
+
// field count, so a missing or extra field traps with WasmVm UnexpectedSize
|
|
62
|
+
// rather than a clean code. Keys MUST be in this sorted order:
|
|
63
|
+
// grammar_version, install_nonce, oracle_max_deviation_bps,
|
|
64
|
+
// oracle_max_staleness_seconds, oracle_max_xfeed_dev_bps,
|
|
65
|
+
// predicate, predicate_hash
|
|
66
|
+
//
|
|
67
|
+
// `raw_context_rule` MUST be the ScVal `get_context_rule(ruleId)` returned,
|
|
68
|
+
// passed through VERBATIM. The interpreter hashes `context_rule.signers` at
|
|
69
|
+
// install and re-checks at enforce; any reconstruction that differs in any
|
|
70
|
+
// field denies RULE_SIGNERS_CHANGED forever, bricking the rule. So this
|
|
71
|
+
// builder fetches the rule itself - callers do not get to hand-build it.
|
|
72
|
+
//
|
|
73
|
+
// SECURITY INVARIANT: the auth_digest binds to networkPassphrase +
|
|
74
|
+
// per-credential nonce + the call's root invocation + context_rule_ids.
|
|
75
|
+
// Replaying the envelope against another call or another account is
|
|
76
|
+
// impossible without re-signing.
|
|
77
|
+
import { createHash } from 'node:crypto';
|
|
78
|
+
import { Account, Address, BASE_FEE, Contract, Operation, rpc, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
|
|
79
|
+
import { accountEntry, authDigest, authPayload, delegatedSignerEntry, signaturePayload, } from "./oz-auth.js";
|
|
80
|
+
/** Build the unsigned transaction envelope for
|
|
81
|
+
* `interpreter.install(params, raw_context_rule, smart_account)`. The
|
|
82
|
+
* output XDR is signed by the wallet, not by us. */
|
|
83
|
+
export async function buildInstallPredicateXdr(args) {
|
|
84
|
+
// 1. Fetch the raw context rule from the smart account via simulation.
|
|
85
|
+
// The interpreter hashes the rule's signers at install; we MUST hand
|
|
86
|
+
// it the exact bytes the account holds, not a reconstruction.
|
|
87
|
+
const rawContextRule = await fetchRawContextRule(args.rpc, args.sourceAccount, args.smartAccount, args.ruleId, args.networkPassphrase);
|
|
88
|
+
// 2. Read the interpreter's stored nonce for this (smart_account,
|
|
89
|
+
// rule_id) so call 2's install_nonce is exactly stored+1. Anything
|
|
90
|
+
// else trips NonceReplay (#202). The key shape matches
|
|
91
|
+
// `packages/policy-interpreter/src/storage.rs`:
|
|
92
|
+
// K_NONCE: u32 = 2
|
|
93
|
+
// key = (Address(smart_account), u32(rule_id), u32(2))
|
|
94
|
+
// A value of `None` means stored_nonce = 0 (first install).
|
|
95
|
+
const storedNonce = await fetchStoredNonce(args.rpc, args.interpreterAddress, args.smartAccount, args.ruleId);
|
|
96
|
+
const installNonce = storedNonce + 1;
|
|
97
|
+
// 3. Build the params ScMap. Field order is sorted by symbol string;
|
|
98
|
+
// the host unpacks by field count and any missing/extra field traps
|
|
99
|
+
// with WasmVm UnexpectedSize rather than a clean code.
|
|
100
|
+
const grammarVersion = args.grammarVersion ?? 1;
|
|
101
|
+
const predicateBytes = Buffer.from(args.encodedPredicate, 'base64');
|
|
102
|
+
const predicateHashBytes = Buffer.from(args.predicateHash, 'hex');
|
|
103
|
+
const params = buildInstallParams({
|
|
104
|
+
grammarVersion,
|
|
105
|
+
installNonce,
|
|
106
|
+
predicateBytes,
|
|
107
|
+
predicateHashBytes,
|
|
108
|
+
});
|
|
109
|
+
// 4. Build the host function. `interpreter.install(params, raw_rule,
|
|
110
|
+
// smart_account)`.
|
|
111
|
+
const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
|
|
112
|
+
contractAddress: new Address(args.interpreterAddress).toScAddress(),
|
|
113
|
+
functionName: 'install',
|
|
114
|
+
args: [params, rawContextRule, new Address(args.smartAccount).toScVal()],
|
|
115
|
+
}));
|
|
116
|
+
const source = await args.rpc.getAccount(args.sourceAccount);
|
|
117
|
+
const makeOperation = (auth = []) => Operation.invokeHostFunction({ func: hostFunction, auth });
|
|
118
|
+
const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
|
|
119
|
+
const buildTx = (op) => buildUnsignedTx({
|
|
120
|
+
sourceAccount: args.sourceAccount,
|
|
121
|
+
sequence: source.sequenceNumber(),
|
|
122
|
+
fee: baseFee,
|
|
123
|
+
networkPassphrase: args.networkPassphrase,
|
|
124
|
+
op,
|
|
125
|
+
});
|
|
126
|
+
// 5. Recording simulation: capture the smart-account auth entry.
|
|
127
|
+
// `contextRuleIds = [ruleId]` - the rule the account assigned in
|
|
128
|
+
// call 1 - is what octopos's `installPredicateDoc` passes (see
|
|
129
|
+
// apps/web/ui/octopos/smart-account-install.ts:313-320). The rule's
|
|
130
|
+
// own interpreter policy permits the `install` host call (it is a
|
|
131
|
+
// privileged install path), so authorising via the rule itself is
|
|
132
|
+
// safe and is the only choice whose auth tree binds to the rule
|
|
133
|
+
// the interpreter recorded.
|
|
134
|
+
const recordingTx = buildTx(makeOperation());
|
|
135
|
+
const recorded = await args.rpc.simulateTransaction(recordingTx);
|
|
136
|
+
if (rpc.Api.isSimulationError(recorded)) {
|
|
137
|
+
throw new Error('install_predicate: simulateTransaction failed');
|
|
138
|
+
}
|
|
139
|
+
const recordedAuth = recorded.result?.auth ?? [];
|
|
140
|
+
const original = recordedAuth.find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
|
|
141
|
+
Address.fromScAddress(entry.credentials().address().address()).toString() ===
|
|
142
|
+
args.smartAccount);
|
|
143
|
+
if (!original) {
|
|
144
|
+
throw new Error(`install_predicate: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
|
|
145
|
+
}
|
|
146
|
+
// 6. Build the OZ auth entries (same shape as install_policy).
|
|
147
|
+
// `contextRuleIds = [args.ruleId]` binds the AuthPayload's
|
|
148
|
+
// context_rule_ids + the authDigest to the rule the interpreter
|
|
149
|
+
// recorded. The delegated signer entry uses source-account
|
|
150
|
+
// credentials so the envelope signature covers it; the wallet
|
|
151
|
+
// supplies only the ordinary envelope signature.
|
|
152
|
+
const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
|
|
153
|
+
(args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
|
|
154
|
+
const contextRuleIds = [args.ruleId];
|
|
155
|
+
const digest = authDigest(signaturePayload(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
|
|
156
|
+
const authEntries = [
|
|
157
|
+
accountEntry(original, validUntilLedger, authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
|
|
158
|
+
...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
|
|
159
|
+
];
|
|
160
|
+
// 7. Enforcing simulation + assemble. Same pattern as
|
|
161
|
+
// buildInstallPolicyXdr: the SDK preserves the OZ auth entries
|
|
162
|
+
// during assembly and adds the simulated Soroban footprint +
|
|
163
|
+
// resource fee, yielding a complete unsigned envelope.
|
|
164
|
+
const txWithAuth = buildTx(makeOperation(authEntries));
|
|
165
|
+
const enforcing = await args.rpc.simulateTransaction(txWithAuth);
|
|
166
|
+
if (rpc.Api.isSimulationError(enforcing)) {
|
|
167
|
+
throw new Error('install_predicate: auth simulateTransaction failed');
|
|
168
|
+
}
|
|
169
|
+
const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build();
|
|
170
|
+
// 8. Decode the structured description FROM the final assembled
|
|
171
|
+
// transaction. The bytes are the source of truth.
|
|
172
|
+
const describes = decodeInstallPredicateCallDescribes(finalTx, args.predicateHash);
|
|
173
|
+
return {
|
|
174
|
+
unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
|
|
175
|
+
smartAccount: args.smartAccount,
|
|
176
|
+
sourceAccount: args.sourceAccount,
|
|
177
|
+
interpreterAddress: args.interpreterAddress,
|
|
178
|
+
ruleId: args.ruleId,
|
|
179
|
+
call: { contract: args.interpreterAddress, fn: 'install' },
|
|
180
|
+
authNonce: original.credentials().address().nonce().toString(),
|
|
181
|
+
authValidUntilLedger: validUntilLedger,
|
|
182
|
+
rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
|
|
183
|
+
describes,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ---- internals ----
|
|
187
|
+
/** ~25 minutes at 5s/ledger. Same default as build-install-policy so the
|
|
188
|
+
* two-call sequence can be signed inside the same window without a
|
|
189
|
+
* fresh source account bump between calls. */
|
|
190
|
+
const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
|
|
191
|
+
/** K_NONCE constant from packages/policy-interpreter/src/storage.rs.
|
|
192
|
+
* The interpreter's persistent-storage key shape is the tuple
|
|
193
|
+
* `(Address, u32, u32)` where the third u32 is the namespace. We hard-
|
|
194
|
+
* code 2 here because the interpreter source is pinned and out of this
|
|
195
|
+
* builder's scope; a drift test should assert these stay in lockstep
|
|
196
|
+
* if the storage layout ever changes. */
|
|
197
|
+
const K_NONCE = 2;
|
|
198
|
+
/** Read the interpreter's stored nonce for `(smart_account, rule_id)`
|
|
199
|
+
* via a direct ledger read. No public getter exists on the pinned
|
|
200
|
+
* interpreter, so we read the contract-data ledger entry directly.
|
|
201
|
+
* Returns 0 when the entry is absent (a fresh (account, rule_id) that
|
|
202
|
+
* has never been installed). Used to derive `install_nonce =
|
|
203
|
+
* stored_nonce + 1` for `interpreter.install`.
|
|
204
|
+
*
|
|
205
|
+
* The InstallRpcClient interface intentionally omits `getLedgerEntries`
|
|
206
|
+
* (the install pipeline uses getAccount / simulateTransaction /
|
|
207
|
+
* getLatestLedger only); we hit the real Soroban RPC through the
|
|
208
|
+
* shared sdk module rather than extending the interface, since this
|
|
209
|
+
* single read keeps the read surface scoped.
|
|
210
|
+
*
|
|
211
|
+
* If the read errors (network blip, pinned interpreter still deploying,
|
|
212
|
+
* etc.) we treat the read as if no entry exists and return 0 - the
|
|
213
|
+
* enforcing simulation will fail closed on NonceReplay if we got it
|
|
214
|
+
* wrong, with a clean error the caller can act on. */
|
|
215
|
+
async function fetchStoredNonce(rpc, interpreterAddress, smartAccount, ruleId) {
|
|
216
|
+
// Recover the underlying RPC URL by probing `getLatestLedger` - this
|
|
217
|
+
// works for both the real-server and mock clients (mocks return a
|
|
218
|
+
// sequence but not a URL). For tests, this returns 0 unless the mock
|
|
219
|
+
// also surfaces an URL. We accept the limit: tests for the install
|
|
220
|
+
// pipeline drive the nonce via the recorded context_rule rather than
|
|
221
|
+
// this read (see build-install-predicate.test.ts).
|
|
222
|
+
//
|
|
223
|
+
// Production reads go through the Soroban RPC directly. We import the
|
|
224
|
+
// sdk module lazily here so the test-time mock surface does not have
|
|
225
|
+
// to grow.
|
|
226
|
+
const { rpc: sdkRpc } = await import('@stellar/stellar-sdk');
|
|
227
|
+
// The InstallRpcClient does not carry the URL; the run-layer wires a
|
|
228
|
+
// real rpc.Server for production. We construct one from the pinned
|
|
229
|
+
// TESTNET_RPC_URL here. This is hard-coded because the read surface
|
|
230
|
+
// is testnet-only today; mainnet would need its own pinned address
|
|
231
|
+
// and a separate URL pin.
|
|
232
|
+
const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org';
|
|
233
|
+
const server = new sdkRpc.Server(TESTNET_RPC_URL, { allowHttp: false });
|
|
234
|
+
const key = xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
|
|
235
|
+
contract: new Address(interpreterAddress).toScAddress(),
|
|
236
|
+
key: xdr.ScVal.scvVec([
|
|
237
|
+
new Address(smartAccount).toScVal(),
|
|
238
|
+
xdr.ScVal.scvU32(ruleId),
|
|
239
|
+
xdr.ScVal.scvU32(K_NONCE),
|
|
240
|
+
]),
|
|
241
|
+
durability: xdr.ContractDataDurability.persistent(),
|
|
242
|
+
}));
|
|
243
|
+
try {
|
|
244
|
+
const r = await server.getLedgerEntries(key);
|
|
245
|
+
if (!r.entries || r.entries.length === 0)
|
|
246
|
+
return 0;
|
|
247
|
+
const entry = r.entries[0];
|
|
248
|
+
if (!entry)
|
|
249
|
+
return 0;
|
|
250
|
+
const data = entry.val?.contractData();
|
|
251
|
+
if (!data)
|
|
252
|
+
return 0;
|
|
253
|
+
const val = data.val();
|
|
254
|
+
if (val.switch().name === 'scvU32')
|
|
255
|
+
return val.u32();
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Treat ledger read errors as "no prior install". The enforcing
|
|
260
|
+
// simulation will fail closed with NonceReplay if the assumption
|
|
261
|
+
// is wrong, surfacing a clean error to the caller.
|
|
262
|
+
void rpc;
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Fetch the verbatim ScVal the smart account holds for a given rule id.
|
|
267
|
+
* Used by `interpreter.install` (call 2): the interpreter hashes
|
|
268
|
+
* `context_rule.signers` at install and re-checks at enforce, so any
|
|
269
|
+
* client-side reconstruction that differs in any field makes the rule
|
|
270
|
+
* deny RULE_SIGNERS_CHANGED forever. We round-trip through a `simulate`
|
|
271
|
+
* of `get_context_rule(ruleId)` and return the raw retval. */
|
|
272
|
+
async function fetchRawContextRule(rpcClient, sourceAccount, smartAccount, ruleId, networkPassphrase) {
|
|
273
|
+
const source = await rpcClient.getAccount(sourceAccount);
|
|
274
|
+
const op = Operation.invokeHostFunction({
|
|
275
|
+
func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
|
|
276
|
+
contractAddress: new Address(smartAccount).toScAddress(),
|
|
277
|
+
functionName: 'get_context_rule',
|
|
278
|
+
args: [xdr.ScVal.scvU32(ruleId)],
|
|
279
|
+
})),
|
|
280
|
+
auth: [],
|
|
281
|
+
});
|
|
282
|
+
const tx = new TransactionBuilder(new Account(sourceAccount, source.sequenceNumber()), {
|
|
283
|
+
fee: BASE_FEE,
|
|
284
|
+
networkPassphrase,
|
|
285
|
+
})
|
|
286
|
+
.addOperation(op)
|
|
287
|
+
.setTimeout(0)
|
|
288
|
+
.build();
|
|
289
|
+
const sim = await rpcClient.simulateTransaction(tx);
|
|
290
|
+
if (rpc.Api.isSimulationError(sim)) {
|
|
291
|
+
throw new Error(`install_predicate: get_context_rule(${ruleId}) simulateTransaction failed: ${sim.error}`);
|
|
292
|
+
}
|
|
293
|
+
const retval = sim.result?.retval;
|
|
294
|
+
if (!retval) {
|
|
295
|
+
throw new Error(`install_predicate: get_context_rule(${ruleId}) returned no value`);
|
|
296
|
+
}
|
|
297
|
+
return retval;
|
|
298
|
+
}
|
|
299
|
+
/** Build the params ScMap for `interpreter.install`. Keys MUST be sorted
|
|
300
|
+
* by symbol string (the canonical map encoding requires it). The host
|
|
301
|
+
* unpacks by field count, so a missing or extra field traps with WasmVm
|
|
302
|
+
* UnexpectedSize rather than a clean code. */
|
|
303
|
+
function buildInstallParams(args) {
|
|
304
|
+
const entry = (k, v) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(k), val: v });
|
|
305
|
+
// Sorted order: grammar_version, install_nonce, oracle_max_deviation_bps,
|
|
306
|
+
// oracle_max_staleness_seconds, oracle_max_xfeed_dev_bps, predicate,
|
|
307
|
+
// predicate_hash.
|
|
308
|
+
return xdr.ScVal.scvMap([
|
|
309
|
+
entry('grammar_version', xdr.ScVal.scvU32(args.grammarVersion)),
|
|
310
|
+
entry('install_nonce', xdr.ScVal.scvU32(args.installNonce)),
|
|
311
|
+
entry('oracle_max_deviation_bps', xdr.ScVal.scvVoid()),
|
|
312
|
+
entry('oracle_max_staleness_seconds', xdr.ScVal.scvVoid()),
|
|
313
|
+
entry('oracle_max_xfeed_dev_bps', xdr.ScVal.scvVoid()),
|
|
314
|
+
entry('predicate', xdr.ScVal.scvBytes(args.predicateBytes)),
|
|
315
|
+
entry('predicate_hash', xdr.ScVal.scvBytes(args.predicateHashBytes)),
|
|
316
|
+
]);
|
|
317
|
+
}
|
|
318
|
+
/** Build an unsigned Soroban transaction envelope. Same pattern as
|
|
319
|
+
* build-install-policy.ts so the consumer code path is uniform. */
|
|
320
|
+
function buildUnsignedTx(args) {
|
|
321
|
+
const account = new Account(args.sourceAccount, args.sequence);
|
|
322
|
+
return new TransactionBuilder(account, {
|
|
323
|
+
fee: args.fee,
|
|
324
|
+
networkPassphrase: args.networkPassphrase,
|
|
325
|
+
})
|
|
326
|
+
.addOperation(args.op)
|
|
327
|
+
.setTimeout(0)
|
|
328
|
+
.build();
|
|
329
|
+
}
|
|
330
|
+
/** Decode the install-predicate call's structured fields directly out of
|
|
331
|
+
* the final assembled transaction. The bytes are the source of truth:
|
|
332
|
+
* a human reviewing the install wants to see what the wallet will
|
|
333
|
+
* actually sign, not what we think it will. The decode is deliberately
|
|
334
|
+
* strict - any shape we did not build ourselves throws, since that
|
|
335
|
+
* would mean the XDR was tampered with (or the encoder changed and
|
|
336
|
+
* this descriptor lags).
|
|
337
|
+
*
|
|
338
|
+
* The second argument (raw context rule) is passed through verbatim;
|
|
339
|
+
* we do NOT decode it here because reconstructing the rule's fields
|
|
340
|
+
* client-side is exactly what the file header warns against. The rule
|
|
341
|
+
* id lives inside it and is echoed from input at the top-level result. */
|
|
342
|
+
function decodeInstallPredicateCallDescribes(tx, expectedPredicateHash) {
|
|
343
|
+
const operations = tx.toEnvelope().v1().tx().operations();
|
|
344
|
+
if (operations.length !== 1 || !operations[0]) {
|
|
345
|
+
throw new Error(`install_predicate: final transaction has ${operations.length} operations, expected 1`);
|
|
346
|
+
}
|
|
347
|
+
const op = operations[0];
|
|
348
|
+
const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
|
|
349
|
+
if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
|
|
350
|
+
throw new Error('install_predicate: built op is not an invokeHostFunction(invokeContract(...)) call');
|
|
351
|
+
}
|
|
352
|
+
const invokeArgs = hostFn.invokeContract();
|
|
353
|
+
if (!invokeArgs) {
|
|
354
|
+
throw new Error('install_predicate: built op has no InvokeContractArgs payload');
|
|
355
|
+
}
|
|
356
|
+
const targetContract = Address.fromScAddress(invokeArgs.contractAddress()).toString();
|
|
357
|
+
const fnName = invokeArgs.functionName().toString();
|
|
358
|
+
if (fnName !== 'install') {
|
|
359
|
+
throw new Error(`install_predicate: built op fn name is "${fnName}", expected "install"`);
|
|
360
|
+
}
|
|
361
|
+
const scvArgs = invokeArgs.args();
|
|
362
|
+
if (scvArgs.length !== 3) {
|
|
363
|
+
throw new Error(`install_predicate: built op has ${scvArgs.length} args, expected 3 (params, raw_context_rule, smart_account)`);
|
|
364
|
+
}
|
|
365
|
+
const paramsScv = scvArgs[0];
|
|
366
|
+
const rawContextRuleScv = scvArgs[1];
|
|
367
|
+
const smartAccountScv = scvArgs[2];
|
|
368
|
+
if (!paramsScv || !rawContextRuleScv || !smartAccountScv) {
|
|
369
|
+
throw new Error('install_predicate: built op args include an undefined slot despite length check');
|
|
370
|
+
}
|
|
371
|
+
// params: an ScMap with seven fields (sorted by symbol).
|
|
372
|
+
if (paramsScv.switch().name !== 'scvMap') {
|
|
373
|
+
throw new Error('install_predicate: args[0] (params) is not an ScVal::Map');
|
|
374
|
+
}
|
|
375
|
+
const paramsFields = new Map();
|
|
376
|
+
for (const inner of paramsScv.map() ?? []) {
|
|
377
|
+
const fk = inner.key();
|
|
378
|
+
if (fk.switch().name !== 'scvSymbol') {
|
|
379
|
+
throw new Error('install_predicate: params field key is not an ScVal::Symbol');
|
|
380
|
+
}
|
|
381
|
+
paramsFields.set(fk.sym().toString(), inner.val());
|
|
382
|
+
}
|
|
383
|
+
const EXPECTED_PARAMS_KEYS = [
|
|
384
|
+
'grammar_version',
|
|
385
|
+
'install_nonce',
|
|
386
|
+
'oracle_max_deviation_bps',
|
|
387
|
+
'oracle_max_staleness_seconds',
|
|
388
|
+
'oracle_max_xfeed_dev_bps',
|
|
389
|
+
'predicate',
|
|
390
|
+
'predicate_hash',
|
|
391
|
+
];
|
|
392
|
+
const actualKeys = [...paramsFields.keys()].sort();
|
|
393
|
+
if (actualKeys.length !== EXPECTED_PARAMS_KEYS.length ||
|
|
394
|
+
actualKeys.some((k, i) => k !== EXPECTED_PARAMS_KEYS[i])) {
|
|
395
|
+
throw new Error(`install_predicate: params keys ${JSON.stringify(actualKeys)} do not match the canonical sorted set ${JSON.stringify(EXPECTED_PARAMS_KEYS)}`);
|
|
396
|
+
}
|
|
397
|
+
const grammarVersionScv = paramsFields.get('grammar_version');
|
|
398
|
+
if (!grammarVersionScv || grammarVersionScv.switch().name !== 'scvU32') {
|
|
399
|
+
throw new Error('install_predicate: params.grammar_version is missing or not u32');
|
|
400
|
+
}
|
|
401
|
+
const grammarVersion = grammarVersionScv.u32();
|
|
402
|
+
const installNonceScv = paramsFields.get('install_nonce');
|
|
403
|
+
if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
|
|
404
|
+
throw new Error('install_predicate: params.install_nonce is missing or not u32');
|
|
405
|
+
}
|
|
406
|
+
const installNonce = installNonceScv.u32();
|
|
407
|
+
const predicateScv = paramsFields.get('predicate');
|
|
408
|
+
if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
|
|
409
|
+
throw new Error('install_predicate: params.predicate is missing or not bytes');
|
|
410
|
+
}
|
|
411
|
+
const predicateBytes = Buffer.from(predicateScv.bytes());
|
|
412
|
+
const predicateSha256OfEmbeddedBytes = createHash('sha256').update(predicateBytes).digest('hex');
|
|
413
|
+
const predicateHashScv = paramsFields.get('predicate_hash');
|
|
414
|
+
if (!predicateHashScv || predicateHashScv.switch().name !== 'scvBytes') {
|
|
415
|
+
throw new Error('install_predicate: params.predicate_hash is missing or not bytes');
|
|
416
|
+
}
|
|
417
|
+
const predicateHash = Buffer.from(predicateHashScv.bytes()).toString('hex');
|
|
418
|
+
if (predicateHash !== expectedPredicateHash) {
|
|
419
|
+
throw new Error(`install_predicate: params.predicate_hash (${predicateHash}) differs from the caller-supplied hash (${expectedPredicateHash})`);
|
|
420
|
+
}
|
|
421
|
+
if (rawContextRuleScv.switch().name === 'scvVoid') {
|
|
422
|
+
throw new Error('install_predicate: args[1] (raw_context_rule) is scvVoid');
|
|
423
|
+
}
|
|
424
|
+
if (smartAccountScv.switch().name !== 'scvAddress') {
|
|
425
|
+
throw new Error('install_predicate: args[2] (smart_account) is not an ScVal::Address');
|
|
426
|
+
}
|
|
427
|
+
const smartAccountOnWire = Address.fromScAddress(smartAccountScv.address()).toString();
|
|
428
|
+
return {
|
|
429
|
+
targetContract,
|
|
430
|
+
fnName: 'install',
|
|
431
|
+
grammarVersion,
|
|
432
|
+
installNonce,
|
|
433
|
+
predicateHash,
|
|
434
|
+
predicateSha256OfEmbeddedBytes,
|
|
435
|
+
smartAccountOnWire,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/** Re-export `Contract` so the run-layer does not need to import the
|
|
439
|
+
* SDK directly. Mirrors the build-install-policy.ts convention. */
|
|
440
|
+
export { Contract };
|
|
441
|
+
// Suppress the unused-import warning for K_NONCE while keeping the
|
|
442
|
+
// constant visible for the drift test it invites. (Removed once a
|
|
443
|
+
// drift test is in place that references it.)
|
|
444
|
+
void K_NONCE;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { xdr } from '@stellar/stellar-sdk';
|
|
2
|
+
/** `Signer::Delegated(addr)`. */
|
|
3
|
+
export declare const delegatedSigner: (a: string) => xdr.ScVal;
|
|
4
|
+
/** The standard Soroban authorization preimage hash for one entry. */
|
|
5
|
+
export declare function signaturePayload(networkPassphrase: string, nonce: xdr.Int64, signatureExpirationLedger: number, invocation: xdr.SorobanAuthorizedInvocation): Buffer;
|
|
6
|
+
/**
|
|
7
|
+
* `sha256(signature_payload || xdr(context_rule_ids))`.
|
|
8
|
+
*
|
|
9
|
+
* OZ binds the selected rule ids into the digest so a signature for one rule
|
|
10
|
+
* cannot be replayed against another.
|
|
11
|
+
*/
|
|
12
|
+
export declare function authDigest(payload: Buffer, contextRuleIds: number[]): Buffer;
|
|
13
|
+
/** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
|
|
14
|
+
export declare function authPayload(signerAddresses: string[], contextRuleIds: number[], signatureFor: (addr: string) => Buffer): xdr.ScVal;
|
|
15
|
+
/**
|
|
16
|
+
* The nested entry a `Delegated` signer needs.
|
|
17
|
+
*
|
|
18
|
+
* `require_auth_for_args` authorizes the CURRENT frame, which while
|
|
19
|
+
* `__check_auth` is running is the account contract executing `__check_auth`,
|
|
20
|
+
* with the digest as its single argument.
|
|
21
|
+
*
|
|
22
|
+
* The signer is the transaction source here, so source-account credentials
|
|
23
|
+
* carry it and no separate signature is required.
|
|
24
|
+
*/
|
|
25
|
+
export declare function delegatedSignerEntry(accountId: string, digest: Buffer): xdr.SorobanAuthorizationEntry;
|
|
26
|
+
/** Rebuild the account's entry with the AuthPayload in the signature slot. */
|
|
27
|
+
export declare function accountEntry(original: xdr.SorobanAuthorizationEntry, signatureExpirationLedger: number, payload: xdr.ScVal): xdr.SorobanAuthorizationEntry;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Build the authorization entries OpenZeppelin's smart account requires.
|
|
2
|
+
//
|
|
3
|
+
// This is the piece that makes an end-to-end test of the account -> policy
|
|
4
|
+
// path possible. Without it, every attempt to exercise `__check_auth`
|
|
5
|
+
// produces a false positive: mocked auth skips `__check_auth` entirely,
|
|
6
|
+
// direct invocation is rejected by the host, and recording-mode simulation
|
|
7
|
+
// does not verify auth at all.
|
|
8
|
+
//
|
|
9
|
+
// Two entries are needed per call:
|
|
10
|
+
//
|
|
11
|
+
// 1. The ACCOUNT's entry. Its `signature` slot is not a signature - OZ puts
|
|
12
|
+
// an `AuthPayload { signers, context_rule_ids }` there, which
|
|
13
|
+
// `__check_auth` receives as its `signatures` argument.
|
|
14
|
+
//
|
|
15
|
+
// 2. One entry per DELEGATED signer. `do_check_auth` calls
|
|
16
|
+
// `addr.require_auth_for_args((auth_digest,))` for each, which is a
|
|
17
|
+
// nested authorization requirement the host will not record during
|
|
18
|
+
// simulation (it never runs `__check_auth` in recording mode), so it has
|
|
19
|
+
// to be constructed by hand.
|
|
20
|
+
//
|
|
21
|
+
// The digest OZ binds is NOT the raw auth payload hash:
|
|
22
|
+
//
|
|
23
|
+
// auth_digest = sha256(signature_payload || xdr(context_rule_ids))
|
|
24
|
+
//
|
|
25
|
+
// where `signature_payload` is the standard Soroban authorization preimage
|
|
26
|
+
// hash. See `do_check_auth` in
|
|
27
|
+
// stellar-contracts/packages/accounts/src/smart_account/storage.rs.
|
|
28
|
+
import { Address, hash, xdr } from '@stellar/stellar-sdk';
|
|
29
|
+
const sym = (s) => xdr.ScVal.scvSymbol(s);
|
|
30
|
+
const u32 = (n) => xdr.ScVal.scvU32(n);
|
|
31
|
+
const vec = (i) => xdr.ScVal.scvVec(i);
|
|
32
|
+
const bytes = (b) => xdr.ScVal.scvBytes(b);
|
|
33
|
+
function struct(fields) {
|
|
34
|
+
return xdr.ScVal.scvMap(Object.keys(fields)
|
|
35
|
+
.sort()
|
|
36
|
+
.map((k) => new xdr.ScMapEntry({ key: sym(k), val: fields[k] })));
|
|
37
|
+
}
|
|
38
|
+
/** `Signer::Delegated(addr)`. */
|
|
39
|
+
export const delegatedSigner = (a) => vec([sym('Delegated'), new Address(a).toScVal()]);
|
|
40
|
+
/** The standard Soroban authorization preimage hash for one entry. */
|
|
41
|
+
export function signaturePayload(networkPassphrase, nonce, signatureExpirationLedger, invocation) {
|
|
42
|
+
const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(new xdr.HashIdPreimageSorobanAuthorization({
|
|
43
|
+
networkId: hash(Buffer.from(networkPassphrase)),
|
|
44
|
+
nonce,
|
|
45
|
+
signatureExpirationLedger,
|
|
46
|
+
invocation,
|
|
47
|
+
}));
|
|
48
|
+
return hash(preimage.toXDR());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `sha256(signature_payload || xdr(context_rule_ids))`.
|
|
52
|
+
*
|
|
53
|
+
* OZ binds the selected rule ids into the digest so a signature for one rule
|
|
54
|
+
* cannot be replayed against another.
|
|
55
|
+
*/
|
|
56
|
+
export function authDigest(payload, contextRuleIds) {
|
|
57
|
+
const idsXdr = vec(contextRuleIds.map(u32)).toXDR();
|
|
58
|
+
return hash(Buffer.concat([payload, idsXdr]));
|
|
59
|
+
}
|
|
60
|
+
/** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
|
|
61
|
+
export function authPayload(signerAddresses, contextRuleIds, signatureFor) {
|
|
62
|
+
return struct({
|
|
63
|
+
signers: xdr.ScVal.scvMap(signerAddresses
|
|
64
|
+
.map((a) => new xdr.ScMapEntry({ key: delegatedSigner(a), val: bytes(signatureFor(a)) }))
|
|
65
|
+
// Host maps must be sorted by key.
|
|
66
|
+
.sort((x, y) => Buffer.compare(x.key().toXDR(), y.key().toXDR()))),
|
|
67
|
+
context_rule_ids: vec(contextRuleIds.map(u32)),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The nested entry a `Delegated` signer needs.
|
|
72
|
+
*
|
|
73
|
+
* `require_auth_for_args` authorizes the CURRENT frame, which while
|
|
74
|
+
* `__check_auth` is running is the account contract executing `__check_auth`,
|
|
75
|
+
* with the digest as its single argument.
|
|
76
|
+
*
|
|
77
|
+
* The signer is the transaction source here, so source-account credentials
|
|
78
|
+
* carry it and no separate signature is required.
|
|
79
|
+
*/
|
|
80
|
+
export function delegatedSignerEntry(accountId, digest) {
|
|
81
|
+
return new xdr.SorobanAuthorizationEntry({
|
|
82
|
+
credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(),
|
|
83
|
+
rootInvocation: new xdr.SorobanAuthorizedInvocation({
|
|
84
|
+
function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(new xdr.InvokeContractArgs({
|
|
85
|
+
contractAddress: new Address(accountId).toScAddress(),
|
|
86
|
+
functionName: '__check_auth',
|
|
87
|
+
args: [bytes(digest)],
|
|
88
|
+
})),
|
|
89
|
+
subInvocations: [],
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Rebuild the account's entry with the AuthPayload in the signature slot. */
|
|
94
|
+
export function accountEntry(original, signatureExpirationLedger, payload) {
|
|
95
|
+
const creds = original.credentials().address();
|
|
96
|
+
return new xdr.SorobanAuthorizationEntry({
|
|
97
|
+
credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(new xdr.SorobanAddressCredentials({
|
|
98
|
+
address: creds.address(),
|
|
99
|
+
nonce: creds.nonce(),
|
|
100
|
+
signatureExpirationLedger,
|
|
101
|
+
signature: payload,
|
|
102
|
+
})),
|
|
103
|
+
rootInvocation: original.rootInvocation(),
|
|
104
|
+
});
|
|
105
|
+
}
|
package/dist/run/index.d.ts
CHANGED
|
@@ -54,9 +54,9 @@ export declare function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true
|
|
|
54
54
|
* Returns the unsigned Soroban transaction envelope (base64 XDR) the
|
|
55
55
|
* wallet signs. The wallet signature IS the user-confirmation step - no
|
|
56
56
|
* `action_id` two-call pair (the server is stateless, see server.ts:10-12).
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* One call installs the policy outright: `add_context_rule` carries the
|
|
58
|
+
* predicate to the interpreter in its `policies` install_param, so no
|
|
59
|
+
* separate `interpreter.install` call is needed or possible.
|
|
60
60
|
*
|
|
61
61
|
* Default-deny: an interpreter policy address other than the pinned
|
|
62
62
|
* testnet interpreter is REFUSED (the smart account would delegate to
|
package/dist/run/index.js
CHANGED
|
@@ -194,9 +194,9 @@ export async function runVerifyPolicy(raw) {
|
|
|
194
194
|
* Returns the unsigned Soroban transaction envelope (base64 XDR) the
|
|
195
195
|
* wallet signs. The wallet signature IS the user-confirmation step - no
|
|
196
196
|
* `action_id` two-call pair (the server is stateless, see server.ts:10-12).
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
197
|
+
* One call installs the policy outright: `add_context_rule` carries the
|
|
198
|
+
* predicate to the interpreter in its `policies` install_param, so no
|
|
199
|
+
* separate `interpreter.install` call is needed or possible.
|
|
200
200
|
*
|
|
201
201
|
* Default-deny: an interpreter policy address other than the pinned
|
|
202
202
|
* testnet interpreter is REFUSED (the smart account would delegate to
|
|
@@ -147,22 +147,20 @@ export interface BuildInstallPolicyResult {
|
|
|
147
147
|
smartAccount: string;
|
|
148
148
|
/** Source account (echo) - the address that must sign. */
|
|
149
149
|
sourceAccount: string;
|
|
150
|
-
/** The host-call target + fn name.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
150
|
+
/** The host-call target + fn name. This single call installs the policy
|
|
151
|
+
* outright: `add_context_rule` takes `policies` as a
|
|
152
|
+
* `Map<policy_address, install_param>`, and the account forwards each
|
|
153
|
+
* install_param to that policy. For an interpreter policy the param
|
|
154
|
+
* carries the predicate and its hash, so the interpreter stores the
|
|
155
|
+
* document as part of this transaction. There is no second call.
|
|
156
|
+
*
|
|
157
|
+
* Verified on testnet 2026-08-01: a rule created by this builder alone
|
|
158
|
+
* permits a matching operator call and denies a non-matching one with
|
|
159
|
+
* interpreter code #100 - not #206 MissingState. */
|
|
153
160
|
call: {
|
|
154
161
|
contract: string;
|
|
155
162
|
fn: 'add_context_rule';
|
|
156
163
|
};
|
|
157
|
-
/** The follow-up call the caller MUST issue after this one confirms.
|
|
158
|
-
* The rule id the account assigns in call 1 is required to bind the
|
|
159
|
-
* interpreter's `install` auth context. */
|
|
160
|
-
followUp: {
|
|
161
|
-
contract: 'interpreter';
|
|
162
|
-
fn: 'install';
|
|
163
|
-
requiresRuleIdFromCallOne: true;
|
|
164
|
-
hint: string;
|
|
165
|
-
};
|
|
166
164
|
/** Human-readable description of the install call, decoded FROM the
|
|
167
165
|
* built unsigned XDR (not from the input args). The wallet signature
|
|
168
166
|
* binds to bytes; the review card has to bind to the same bytes, so
|