@crediolabs/policy-synth 1.2.0 → 1.3.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.
@@ -27,12 +27,13 @@ import {
27
27
  Contract,
28
28
  Keypair,
29
29
  rpc,
30
+ scValToNative,
30
31
  TransactionBuilder,
31
32
  xdr,
32
33
  } from '@stellar/stellar-sdk'
33
34
  import { decodePredicate } from '../predicate/decode.ts'
34
35
  import type { SignerDraft } from '../types.ts'
35
- import type { ContextType, ObservedRule } from './authority-overlap.ts'
36
+ import type { ContextType, ObservedRule, SpendCap } from './authority-overlap.ts'
36
37
 
37
38
  /** `storage.rs` - the third element of the persistent doc key tuple. */
38
39
  export const K_DOC = 1
@@ -173,6 +174,49 @@ export function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined {
173
174
  return field.bytes()
174
175
  }
175
176
 
177
+ /** Persistent-storage key for a rule's spend-cap data:
178
+ * `SpendingLimitStorageKey::AccountContext(account, rule_id)`, which the host
179
+ * encodes as an enum variant - the symbol first, then the payload. */
180
+ export function spendCapKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal {
181
+ return xdr.ScVal.scvVec([
182
+ xdr.ScVal.scvSymbol('AccountContext'),
183
+ new Address(smartAccount).toScVal(),
184
+ xdr.ScVal.scvU32(ruleId),
185
+ ])
186
+ }
187
+
188
+ /** Ledger key for the OZ spend cap's own persistent entry. The policy exposes
189
+ * `get_spending_limit_data`, but that PANICS when nothing is installed, and a
190
+ * panic is indistinguishable from an RPC fault at the call site. Reading the
191
+ * entry lets "no cap here" come back as an absence instead. */
192
+ export function spendCapLedgerKey(
193
+ spendingLimit: string,
194
+ smartAccount: string,
195
+ ruleId: number
196
+ ): xdr.LedgerKey {
197
+ return xdr.LedgerKey.contractData(
198
+ new xdr.LedgerKeyContractData({
199
+ contract: new Address(spendingLimit).toScAddress(),
200
+ key: spendCapKeyScVal(smartAccount, ruleId),
201
+ durability: xdr.ContractDataDurability.persistent(),
202
+ })
203
+ )
204
+ }
205
+
206
+ /** `SpendingLimitData`, of which only the two installed parameters matter here.
207
+ * The running total and the history are deliberately ignored: they say what
208
+ * has been spent so far, which changes every call, while the scan is about
209
+ * what the rule PERMITS. */
210
+ export function decodeSpendCap(v: xdr.ScVal): SpendCap | undefined {
211
+ const periodLedgers = u32Of(mapField(v, 'period_ledgers'))
212
+ const limit = mapField(v, 'spending_limit')
213
+ if (periodLedgers === undefined || !limit) return undefined
214
+ if (limit.switch() !== xdr.ScValType.scvI128()) return undefined
215
+ const amount = scValToNative(limit)
216
+ if (typeof amount !== 'bigint') return undefined
217
+ return { amount: amount.toString(), periodLedgers }
218
+ }
219
+
176
220
  // ---- collection -----
177
221
 
178
222
  /** The three reads the scan needs. Kept as an interface so the collection
@@ -189,6 +233,14 @@ export interface AccountRuleReader {
189
233
  smartAccount: string,
190
234
  ruleId: number
191
235
  ): Promise<xdr.ScVal | undefined>
236
+ /** The OZ spend cap's persistent entry for this rule, read as a ledger entry.
237
+ * Optional so an existing reader keeps working: without it the scan simply
238
+ * reports no cap parameters, which is the same as it behaved before. */
239
+ getSpendCapData?(
240
+ spendingLimit: string,
241
+ smartAccount: string,
242
+ ruleId: number
243
+ ): Promise<xdr.ScVal | undefined>
192
244
  }
193
245
 
194
246
  /** How far the id scan will probe before giving up. OZ imposes no per-account
@@ -227,6 +279,10 @@ export async function collectObservedRules(args: {
227
279
  reader: AccountRuleReader
228
280
  smartAccount: string
229
281
  interpreterAddress: string
282
+ /** The pinned OZ spend cap. Supplying it fills in the PARAMETERS of a
283
+ * neighbour's cap; whether one is attached at all is decided from the
284
+ * rule's policy addresses and does not depend on this read succeeding. */
285
+ spendingLimitAddress?: string
230
286
  maxRuleIdScan?: number
231
287
  }): Promise<CollectedRules> {
232
288
  const count = await args.reader.getContextRuleCount(args.smartAccount)
@@ -259,6 +315,16 @@ export async function collectObservedRules(args: {
259
315
  unreadablePredicateRuleIds.push(rule.id)
260
316
  }
261
317
  }
318
+
319
+ const spendCapAddress = args.spendingLimitAddress
320
+ if (spendCapAddress !== undefined && rule.policyAddresses.includes(spendCapAddress)) {
321
+ const data = await args.reader.getSpendCapData?.(spendCapAddress, args.smartAccount, rule.id)
322
+ const cap = data ? decodeSpendCap(data) : undefined
323
+ // An unreadable cap is left absent rather than guessed at. The rule still
324
+ // counts as capped, because attachment came from its policy addresses,
325
+ // so failing this read weakens the REPORT and never the refusal.
326
+ if (cap) rule.spendCap = cap
327
+ }
262
328
  rules.push(rule)
263
329
  }
264
330
 
@@ -309,5 +375,12 @@ export function accountRuleReaderFromServer(
309
375
  if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
310
376
  return entry.contractData().val()
311
377
  },
378
+ async getSpendCapData(spendingLimit, smartAccount, ruleId) {
379
+ const key = spendCapLedgerKey(spendingLimit, smartAccount, ruleId)
380
+ const res = await server.getLedgerEntries(key)
381
+ const entry = res.entries?.[0]?.val
382
+ if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
383
+ return entry.contractData().val()
384
+ },
312
385
  }
313
386
  }
package/src/run/index.ts CHANGED
@@ -240,9 +240,12 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
240
240
  /** The refusal message for an install the cross-rule scan proves cannot bind,
241
241
  * or `undefined` when the install may proceed.
242
242
  *
243
- * Only `bypass` refuses. That class means the neighbouring rule carries NO
244
- * policy, so a shared signer names it and the new predicate never runs - a
245
- * proof, from data already in hand, that the rule constrains nothing.
243
+ * Only `bypass` refuses, and it covers two proofs. Either the neighbouring
244
+ * rule carries NO policy, so a shared signer names it and the new predicate
245
+ * never runs; or the install carries a rolling total and a fully recognised
246
+ * neighbour serves the same calls without one, so the total is not a bound on
247
+ * the key. Both are proofs from data already in hand.
248
+ *
246
249
  * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
247
250
  * advisory: it may well be tighter, and refusing on "cannot decode" would
248
251
  * block installs on a guess. A `null` scan is NOT CHECKED, which is not
@@ -258,7 +261,12 @@ export function authorityBypassRefusal(
258
261
  const proven = scan.filter((o) => o.severity === 'bypass')
259
262
  if (proven.length === 0) return undefined
260
263
  const ids = proven.map((o) => o.ruleId).join(', ')
261
- return `install_policy: ${proven[0]?.advice ?? ''} This rule would install cleanly and constrain nothing, so it is refused (rule ${ids}); remove the shared signer from that rule, attach a policy to it, or set \`allowAuthorityOverlap: true\` to install anyway.`
264
+ // A cap bypass leaves the predicate working, so "constrains nothing" would
265
+ // overstate it and send the caller looking for the wrong defect.
266
+ const consequence = proven.every((o) => o.capBypass === true)
267
+ ? 'This rule would install cleanly and its rolling total would not hold'
268
+ : 'This rule would install cleanly and constrain nothing'
269
+ return `install_policy: ${proven[0]?.advice ?? ''} ${consequence}, so it is refused (rule ${ids}); set \`allowAuthorityOverlap: true\` to install anyway.`
262
270
  }
263
271
 
264
272
  export async function runInstallPolicy(
@@ -475,8 +483,24 @@ export async function runInstallPolicy(
475
483
  contextType: rule.contextRuleType,
476
484
  signers: rule.signers,
477
485
  predicate: decodePredicate(encodedPredicate),
486
+ ...(input.spendingLimit !== undefined
487
+ ? {
488
+ spendCap: {
489
+ amount: input.spendingLimit.amount,
490
+ periodLedgers: input.spendingLimit.periodLedgers,
491
+ },
492
+ }
493
+ : {}),
478
494
  },
479
495
  existing: observed,
496
+ // Both addresses are pinned, so a neighbour's policies can be
497
+ // named rather than merely counted - which is what lets the scan
498
+ // say a rule has no spend cap instead of that it has something
499
+ // unreadable.
500
+ knownPolicies: {
501
+ interpreter: expectedInterpreter,
502
+ spendingLimit: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
503
+ },
480
504
  })
481
505
  // A `bypass` overlap is not a warning, it is a proof that this rule cannot
482
506
  // bind the key it names: the neighbour carries NO policy, so the signer
@@ -940,6 +964,7 @@ async function resolveExistingRules(
940
964
  reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
941
965
  smartAccount: input.smartAccount,
942
966
  interpreterAddress,
967
+ spendingLimitAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
943
968
  })
944
969
  if (collected.incomplete) return null
945
970
  return collected.rules
@@ -381,6 +381,15 @@ export const ObservedRuleSchema = z.object({
381
381
  signers: z.array(SignerDraftSchema),
382
382
  policyAddresses: z.array(z.string()),
383
383
  predicate: PredicateNodeSchema.optional(),
384
+ /** Parameters of an OZ spend cap already on this rule. Reporting only:
385
+ * whether a neighbour is capped is read from `policyAddresses`, so omitting
386
+ * this never turns a capped rule into an uncapped one. */
387
+ spendCap: z
388
+ .object({
389
+ amount: z.string().regex(/^[0-9]+$/),
390
+ periodLedgers: z.number().int().positive().max(U32_MAX),
391
+ })
392
+ .optional(),
384
393
  })
385
394
 
386
395
  const ContextRuleDraftSchema = z