@crediolabs/policy-synth 1.0.0 → 1.1.1
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-add-context-rule.js +48 -16
- package/dist/install/build-install-policy.d.ts +41 -0
- package/dist/install/build-install-policy.js +51 -5
- package/dist/predicate/encode.d.ts +12 -0
- package/dist/predicate/encode.js +5 -1
- package/dist/run/index.d.ts +14 -5
- package/dist/run/index.js +263 -14
- package/dist/run/schemas.d.ts +2279 -476
- package/dist/run/schemas.js +192 -26
- package/dist/synth/lower.d.ts +6 -2
- package/dist/synth/lower.js +21 -8
- package/dist/synth/synthesize-from-recording.js +1 -1
- package/dist/types.d.ts +18 -1
- package/dist-cjs/install/build-add-context-rule.js +48 -16
- package/dist-cjs/install/build-install-policy.d.ts +41 -0
- package/dist-cjs/install/build-install-policy.js +52 -5
- package/dist-cjs/predicate/encode.d.ts +12 -0
- package/dist-cjs/predicate/encode.js +5 -0
- package/dist-cjs/run/index.d.ts +14 -5
- package/dist-cjs/run/index.js +263 -13
- package/dist-cjs/run/schemas.d.ts +2279 -476
- package/dist-cjs/run/schemas.js +193 -27
- package/dist-cjs/synth/lower.d.ts +6 -2
- package/dist-cjs/synth/lower.js +21 -8
- package/dist-cjs/synth/synthesize-from-recording.js +1 -1
- package/dist-cjs/types.d.ts +18 -1
- package/package.json +1 -1
- package/src/install/build-add-context-rule.ts +65 -21
- package/src/install/build-install-policy.ts +100 -12
- package/src/predicate/encode.ts +5 -1
- package/src/run/index.ts +280 -23
- package/src/run/schemas.ts +229 -43
- package/src/synth/lower.ts +22 -8
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +25 -6
package/src/run/schemas.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// imports them here so its tool-shape bindings stay in step; the CLI imports
|
|
16
16
|
// them here so it can build the same args envelope the MCP transport builds.
|
|
17
17
|
|
|
18
|
+
import { StrKey } from '@stellar/stellar-sdk'
|
|
18
19
|
import { z } from 'zod'
|
|
19
20
|
import { isStellarAddress } from '../synth/address.ts'
|
|
20
21
|
|
|
@@ -153,23 +154,43 @@ export const InterpreterOptionsSchema = z.object({
|
|
|
153
154
|
installNonce: z.number().int().positive().optional(),
|
|
154
155
|
})
|
|
155
156
|
|
|
156
|
-
export const SynthesizePolicyInputSchema = z
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
157
|
+
export const SynthesizePolicyInputSchema = z
|
|
158
|
+
.object({
|
|
159
|
+
source: z.literal('recording'),
|
|
160
|
+
// Two ways to name the recording, because an MCP client has no variable to
|
|
161
|
+
// pass by reference. `recordedTx` is the full RecordedTransaction, which a
|
|
162
|
+
// programmatic caller can hand straight over from `record_transaction`. An
|
|
163
|
+
// agent cannot: it sees that output as text and has to retype it, and the
|
|
164
|
+
// payload is thousands of characters and a dozen levels deep, with exact
|
|
165
|
+
// i128 strings that do not survive the round trip. `transactionHash` lets the
|
|
166
|
+
// agent carry a 64-character handle instead and have the server re-record.
|
|
167
|
+
//
|
|
168
|
+
// Re-recording rather than caching keeps the server stateless (see
|
|
169
|
+
// build-install-policy.ts). Recording is deterministic for a settled
|
|
170
|
+
// transaction, so the second read returns the same thing as the first.
|
|
171
|
+
recordedTx: RecordedTransactionSchema.optional(),
|
|
172
|
+
transactionHash: z
|
|
173
|
+
.string()
|
|
174
|
+
.regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
|
|
175
|
+
.optional(),
|
|
176
|
+
network: NetworkSchema,
|
|
177
|
+
userResponses: ComposeUserResponsesSchema.optional(),
|
|
178
|
+
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
179
|
+
interpreter: InterpreterOptionsSchema.optional(),
|
|
180
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
181
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
182
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
183
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
184
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
185
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
186
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
187
|
+
// explain.
|
|
188
|
+
explain: z.boolean().optional(),
|
|
189
|
+
})
|
|
190
|
+
.refine((v) => v.recordedTx !== undefined || v.transactionHash !== undefined, {
|
|
191
|
+
message:
|
|
192
|
+
'supply either `recordedTx` (the full recording) or `transactionHash` (and the server will record it)',
|
|
193
|
+
})
|
|
173
194
|
|
|
174
195
|
export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>
|
|
175
196
|
|
|
@@ -271,11 +292,44 @@ export const PredicateNodeSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
|
271
292
|
// take the same input. A null predicate used to mean "OZ built-in policies
|
|
272
293
|
// only"; that backend is gone, so every policy carries a predicate and there is
|
|
273
294
|
// nothing to simulate without one.
|
|
274
|
-
export const SimulatePolicyInputSchema = z
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
export const SimulatePolicyInputSchema = z
|
|
296
|
+
.object({
|
|
297
|
+
// Same two ways in as `synthesize_policy`, for the same reason. The tree
|
|
298
|
+
// is only returned under `explain`, so a caller who did not ask for it has
|
|
299
|
+
// nothing to pass here and skips the check entirely - which is the one
|
|
300
|
+
// step that must not be skippable by accident. `transactionHash` re-records and
|
|
301
|
+
// re-synthesizes, so the predicate checked is the predicate that was built.
|
|
302
|
+
predicate: PredicateNodeSchema.optional(),
|
|
303
|
+
/** The canonical encoding `declare_policy` and `synthesize_policy` both
|
|
304
|
+
* return. A DECLARED policy has no recording behind it, so re-synthesizing
|
|
305
|
+
* from a hash would check a different predicate than the one declared -
|
|
306
|
+
* and the tree is the shape callers mistype. One opaque string is the
|
|
307
|
+
* handle that path was missing. */
|
|
308
|
+
encodedPredicate: z.string().optional(),
|
|
309
|
+
permitTx: RecordedTransactionSchema.optional(),
|
|
310
|
+
transactionHash: z
|
|
311
|
+
.string()
|
|
312
|
+
.regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
|
|
313
|
+
.optional(),
|
|
314
|
+
network: NetworkSchema.optional(),
|
|
315
|
+
/** Needed only with `transactionHash`: lowering a recording to an interpreter
|
|
316
|
+
* predicate is scoped to the account it will be installed on, and the
|
|
317
|
+
* self-call gate is defined against it. */
|
|
318
|
+
smartAccount: z.string().optional(),
|
|
319
|
+
userResponses: ComposeUserResponsesSchema.optional(),
|
|
320
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
|
|
321
|
+
})
|
|
322
|
+
// Two halves, each satisfiable on its own terms: something to check, and a
|
|
323
|
+
// call to check it against. `transactionHash` alone answers both.
|
|
324
|
+
.refine(
|
|
325
|
+
(v) =>
|
|
326
|
+
v.transactionHash !== undefined ||
|
|
327
|
+
((v.predicate !== undefined || v.encodedPredicate !== undefined) && v.permitTx !== undefined),
|
|
328
|
+
{
|
|
329
|
+
message:
|
|
330
|
+
'supply `transactionHash` (and the server will record and synthesize), or a predicate (`predicate` tree or `encodedPredicate` string) together with `permitTx` or `transactionHash`',
|
|
331
|
+
}
|
|
332
|
+
)
|
|
279
333
|
export type SimulatePolicyInput = z.infer<typeof SimulatePolicyInputSchema>
|
|
280
334
|
|
|
281
335
|
export const VerifyPolicyInputSchema = SimulatePolicyInputSchema
|
|
@@ -350,13 +404,26 @@ const ContextRuleDraftSchema = z
|
|
|
350
404
|
])
|
|
351
405
|
)
|
|
352
406
|
.max(MAX_SIGNERS_PER_RULE),
|
|
407
|
+
/** Policies on one rule compose as ALL-OF, so an interpreter predicate and
|
|
408
|
+
* an OpenZeppelin built-in can sit together and both must permit. That
|
|
409
|
+
* pairing is what expresses a rolling total: the predicate bounds each
|
|
410
|
+
* call, the built-in bounds the sum across calls. */
|
|
353
411
|
policies: z
|
|
354
412
|
.array(
|
|
355
|
-
z.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
413
|
+
z.discriminatedUnion('kind', [
|
|
414
|
+
z.object({
|
|
415
|
+
kind: z.literal('interpreter'),
|
|
416
|
+
interpreterAddress: z.string(),
|
|
417
|
+
predicateBlobBase64: z.string().min(1),
|
|
418
|
+
}),
|
|
419
|
+
z.object({
|
|
420
|
+
kind: z.literal('spending_limit'),
|
|
421
|
+
policyAddress: z.string(),
|
|
422
|
+
/** LEDGERS, not seconds. */
|
|
423
|
+
periodLedgers: z.number().int().positive().max(U32_MAX),
|
|
424
|
+
spendingLimit: z.string().regex(/^[0-9]+$/),
|
|
425
|
+
}),
|
|
426
|
+
])
|
|
360
427
|
)
|
|
361
428
|
.max(MAX_POLICIES_PER_RULE),
|
|
362
429
|
})
|
|
@@ -429,6 +496,11 @@ export const RPC_URL_BY_NETWORK: Record<Network, string> = {
|
|
|
429
496
|
* Provenance detail in `docs/audit/README.md` finding 7. */
|
|
430
497
|
export type OzBuiltinPolicy = 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
|
|
431
498
|
|
|
499
|
+
/** The upstream tag the deployed policy instances were built from. Exported so
|
|
500
|
+
* `scripts/upstream-drift-check.ts` can compare it against the latest upstream
|
|
501
|
+
* release; a tag recorded only in prose cannot be checked by anything. */
|
|
502
|
+
export const PINNED_OZ_STELLAR_CONTRACTS_TAG = 'v0.7.2'
|
|
503
|
+
|
|
432
504
|
/** Instance addresses per network. Exported so consumers import the pin
|
|
433
505
|
* instead of copying a literal - a copied address is how a testnet id ends up
|
|
434
506
|
* being queried against mainnet, which returns `Error(Storage, MissingValue)`
|
|
@@ -479,6 +551,29 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
|
|
|
479
551
|
const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
|
|
480
552
|
const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
|
|
481
553
|
|
|
554
|
+
// The regexes above check SHAPE only. A wrong-but-well-formed address - the
|
|
555
|
+
// classic case being one an agent reproduced from memory - passes them and then
|
|
556
|
+
// fails the SDK's StrKey decoder deep inside the build, where the throw is
|
|
557
|
+
// caught by the tool envelope and reported as a bare "invalid checksum" naming
|
|
558
|
+
// no field. A caller holding several addresses then cannot tell which one is
|
|
559
|
+
// wrong. Validating the checksum HERE keeps the field name attached.
|
|
560
|
+
const contractAddress = (field: string) =>
|
|
561
|
+
z
|
|
562
|
+
.string()
|
|
563
|
+
.regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
|
|
564
|
+
.refine(
|
|
565
|
+
StrKey.isValidContract,
|
|
566
|
+
`${field} is not a valid contract address: the checksum does not match, so this address does not exist`
|
|
567
|
+
)
|
|
568
|
+
const accountAddress = (field: string) =>
|
|
569
|
+
z
|
|
570
|
+
.string()
|
|
571
|
+
.regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
|
|
572
|
+
.refine(
|
|
573
|
+
StrKey.isValidEd25519PublicKey,
|
|
574
|
+
`${field} is not a valid account address: the checksum does not match, so this address does not exist`
|
|
575
|
+
)
|
|
576
|
+
|
|
482
577
|
// ===== declare_policy =====
|
|
483
578
|
//
|
|
484
579
|
// The declarative front-end: the constraint stated outright, with no
|
|
@@ -542,14 +637,10 @@ export const InstallPolicyInputSchema = z
|
|
|
542
637
|
* result says so rather than reporting "no overlaps found". */
|
|
543
638
|
existingRules: z.array(ObservedRuleSchema).optional(),
|
|
544
639
|
/** The smart account contract address (C...) that will receive the rule. */
|
|
545
|
-
smartAccount:
|
|
546
|
-
.string()
|
|
547
|
-
.regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
|
|
640
|
+
smartAccount: contractAddress('smartAccount'),
|
|
548
641
|
/** The signer that authorises the install (G... wallet). Used only for
|
|
549
642
|
* sequence number + auth nonce simulation; never persisted, never signed. */
|
|
550
|
-
sourceAccount:
|
|
551
|
-
.string()
|
|
552
|
-
.regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
|
|
643
|
+
sourceAccount: accountAddress('sourceAccount'),
|
|
553
644
|
/** Target network for the install. Selects which interpreter pin and
|
|
554
645
|
* which RPC URL are valid by default. Defaults to `testnet` so the
|
|
555
646
|
* pre-mainnet callers keep working: they were always pointing at
|
|
@@ -558,10 +649,70 @@ export const InstallPolicyInputSchema = z
|
|
|
558
649
|
* A caller that targets mainnet MUST set this to `mainnet` (the
|
|
559
650
|
* pin and RPC pin do not move by themselves). */
|
|
560
651
|
network: NetworkSchema.optional(),
|
|
561
|
-
/** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
652
|
+
/** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape.
|
|
653
|
+
*
|
|
654
|
+
* Optional because of `fromHash` below. An agent cannot reliably retype the
|
|
655
|
+
* `contextRule` that `synthesize_policy` returned - it is nested, and the
|
|
656
|
+
* observed failures were exactly that: `validUntilLedger` sent as a string,
|
|
657
|
+
* `signers` as "", `policies` as an object instead of an array. Supplying
|
|
658
|
+
* `fromHash` instead lets the server rebuild the same rule it just
|
|
659
|
+
* produced, rather than asking the caller to transcribe it. */
|
|
660
|
+
rule: ContextRuleDraftSchema.optional(),
|
|
661
|
+
/** Build the rule here instead of receiving it: record this transaction,
|
|
662
|
+
* synthesize against `smartAccount`, and install the result. The
|
|
663
|
+
* agent-friendly counterpart to `rule`, and the same handle
|
|
664
|
+
* `synthesize_policy` accepts. */
|
|
665
|
+
fromHash: z
|
|
666
|
+
.object({
|
|
667
|
+
transactionHash: z
|
|
668
|
+
.string()
|
|
669
|
+
.regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters'),
|
|
670
|
+
/** The keys this rule governs. Synthesis cannot choose them: it reads a
|
|
671
|
+
* transaction, and which keys a rule binds is the caller's security
|
|
672
|
+
* decision, not an inference from one recording. Naming a key here
|
|
673
|
+
* attaches it as a delegated signer. A rule with no signer is refused
|
|
674
|
+
* on chain, so this is required in practice; the `rule` form remains
|
|
675
|
+
* the way to attach an external (verifier + key bytes) signer. */
|
|
676
|
+
signers: z
|
|
677
|
+
.array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
|
|
678
|
+
.max(MAX_SIGNERS_PER_RULE)
|
|
679
|
+
.optional(),
|
|
680
|
+
userResponses: ComposeUserResponsesSchema.optional(),
|
|
681
|
+
})
|
|
682
|
+
.optional(),
|
|
683
|
+
/** Install a predicate the caller ALREADY holds - the base64 string
|
|
684
|
+
* `declare_policy` returns.
|
|
685
|
+
*
|
|
686
|
+
* Without this there is no route from `declare_policy` to here:
|
|
687
|
+
* `fromHash` re-synthesizes from a recording and would discard the
|
|
688
|
+
* declared predicate, and `rule` means hand-building a draft that the
|
|
689
|
+
* tool boundary types as `unknown`, so the caller is guessing. An agent
|
|
690
|
+
* asked to do that invented a requirement to deploy a signer contract,
|
|
691
|
+
* which is not a thing - a delegated signer is a plain account address.
|
|
692
|
+
*
|
|
693
|
+
* The context rule type is taken FROM the predicate: if it pins a
|
|
694
|
+
* contract, the rule is scoped to that contract. One source of truth, so
|
|
695
|
+
* the rule's scope cannot drift from what the predicate actually checks. */
|
|
696
|
+
fromPredicate: z
|
|
697
|
+
.object({
|
|
698
|
+
encodedPredicate: z.string().min(1),
|
|
699
|
+
/** The keys this rule governs, as plain Stellar account addresses. */
|
|
700
|
+
signers: z
|
|
701
|
+
.array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
|
|
702
|
+
.min(1)
|
|
703
|
+
.max(MAX_SIGNERS_PER_RULE),
|
|
704
|
+
name: z.string().min(1).optional(),
|
|
705
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
|
|
706
|
+
})
|
|
707
|
+
.optional(),
|
|
708
|
+
/** Per-rule install nonce. Defaults to 1, which is the only correct value
|
|
709
|
+
* here: this tool builds `add_context_rule`, the account assigns a NEW
|
|
710
|
+
* rule id, and the interpreter has no stored nonce for a rule that does
|
|
711
|
+
* not exist yet. Required, it was undiscoverable - an agent has no way to
|
|
712
|
+
* read it, and asking cost a round trip on a value the server already
|
|
713
|
+
* knows. Supply it only to re-install over an existing rule, where the
|
|
714
|
+
* interpreter wants `stored_nonce + 1`. */
|
|
715
|
+
installNonce: z.number().int().positive().optional(),
|
|
565
716
|
/** Optional RPC URL override. Defaults to the pinned RPC for the
|
|
566
717
|
* selected `network` (testnet by default, mainnet when
|
|
567
718
|
* `network: 'mainnet'`); the override is refused unless
|
|
@@ -573,6 +724,38 @@ export const InstallPolicyInputSchema = z
|
|
|
573
724
|
* network because the caller's auth-digest binds to whatever the
|
|
574
725
|
* RPC returned. */
|
|
575
726
|
allowUnpinnedRpcUrl: z.boolean().optional(),
|
|
727
|
+
/** Attach an OpenZeppelin `spending_limit` beside the predicate, giving the
|
|
728
|
+
* rule a ROLLING TOTAL as well as a per-call bound. Both must permit,
|
|
729
|
+
* because policies on one rule compose as all-of.
|
|
730
|
+
*
|
|
731
|
+
* This is the only way to express "N per day": the interpreter is handed
|
|
732
|
+
* one call and keeps no state, so a predicate cannot add up spending
|
|
733
|
+
* across calls. Composes with all three ways of naming the rule.
|
|
734
|
+
*
|
|
735
|
+
* The primitive meters the third argument of a call named exactly
|
|
736
|
+
* `transfer` and requires a `call_contract` rule scope, so the rule must
|
|
737
|
+
* be pinned to the token whose transfers it meters. */
|
|
738
|
+
spendingLimit: z
|
|
739
|
+
.object({
|
|
740
|
+
/** Rolling total in the token's smallest unit. */
|
|
741
|
+
amount: z
|
|
742
|
+
.string()
|
|
743
|
+
.regex(/^[0-9]+$/, 'amount must be a base-10 integer in the smallest unit'),
|
|
744
|
+
/** Window length in LEDGERS. Stellar closes one in roughly five
|
|
745
|
+
* seconds, so a period in seconds is an approximation of this. */
|
|
746
|
+
periodLedgers: z.number().int().positive().max(U32_MAX),
|
|
747
|
+
})
|
|
748
|
+
.optional(),
|
|
749
|
+
/** Opt-in to installing a rule that bounds no amount, when the recording
|
|
750
|
+
* behind `fromHash` showed a spend.
|
|
751
|
+
*
|
|
752
|
+
* Default-deny, because the failure is silent and reads as success: such
|
|
753
|
+
* a rule installs cleanly, verifies cleanly - a missing constraint
|
|
754
|
+
* generates no deny case to fail - and caps nothing. That combination
|
|
755
|
+
* reached the chain once already. A rule with no spend to bound is
|
|
756
|
+
* unaffected; only the case the synthesizer explicitly flagged is
|
|
757
|
+
* refused. */
|
|
758
|
+
allowUnboundedAmount: z.boolean().optional(),
|
|
576
759
|
/** Opt-in to pointing the rule's interpreter policy at any address
|
|
577
760
|
* other than the pinned interpreter for the selected network.
|
|
578
761
|
* Default-deny: a caller that controls the interpreter can permit
|
|
@@ -582,6 +765,13 @@ export const InstallPolicyInputSchema = z
|
|
|
582
765
|
/** Base fee in stroops; defaults to BASE_FEE (100). */
|
|
583
766
|
baseFee: z.number().int().positive().optional(),
|
|
584
767
|
})
|
|
768
|
+
.refine(
|
|
769
|
+
(v) => v.rule !== undefined || v.fromHash !== undefined || v.fromPredicate !== undefined,
|
|
770
|
+
{
|
|
771
|
+
message:
|
|
772
|
+
'name the rule one of three ways: `fromHash` (server records, synthesizes and installs), `fromPredicate` (a predicate you already hold, plus the keys it governs), or `rule` (the full ContextRuleDraft, for programmatic callers)',
|
|
773
|
+
}
|
|
774
|
+
)
|
|
585
775
|
.refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
|
|
586
776
|
message: 'smartAccount and sourceAccount are required',
|
|
587
777
|
})
|
|
@@ -590,16 +780,12 @@ export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
|
|
|
590
780
|
export const RevokePolicyInputSchema = z
|
|
591
781
|
.object({
|
|
592
782
|
/** The smart account contract address (C...). */
|
|
593
|
-
smartAccount:
|
|
594
|
-
.string()
|
|
595
|
-
.regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
|
|
783
|
+
smartAccount: contractAddress('smartAccount'),
|
|
596
784
|
/** The wallet that will sign the removal. The ACCOUNT decides whether it
|
|
597
785
|
* accepts that signer; this schema does not assert a rule it cannot
|
|
598
786
|
* verify, since the account's source is not in this repo. Proven on
|
|
599
787
|
* testnet: the account's deployer can revoke. */
|
|
600
|
-
sourceAccount:
|
|
601
|
-
.string()
|
|
602
|
-
.regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
|
|
788
|
+
sourceAccount: accountAddress('sourceAccount'),
|
|
603
789
|
/** Target network for the revoke. Same `testnet`-default as install,
|
|
604
790
|
* so pre-mainnet callers keep working without an explicit flag. */
|
|
605
791
|
network: NetworkSchema.optional(),
|
package/src/synth/lower.ts
CHANGED
|
@@ -32,12 +32,20 @@ export interface IntentFacts {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Lower a recorded transaction to the canonical IntentFacts. Pure (no
|
|
35
|
-
* randomness, no clock); same `RecordedTransaction` -> byte-identical facts.
|
|
36
|
-
|
|
35
|
+
* randomness, no clock); same `RecordedTransaction` -> byte-identical facts.
|
|
36
|
+
*
|
|
37
|
+
* `governedAccount` is the smart account the policy will be installed on, when
|
|
38
|
+
* one is known. It spends from itself while a wallet submits the transaction,
|
|
39
|
+
* so it counts as a spender alongside the source account. */
|
|
40
|
+
export function lower(tx: RecordedTransaction, governedAccount?: string): IntentFacts {
|
|
37
41
|
const invocations = tx.invocations
|
|
38
42
|
const callTargets = uniqueOrdered(invocations.map((i) => i.contract))
|
|
39
43
|
const functionsByContract = groupFunctionsByContract(invocations)
|
|
40
|
-
const
|
|
44
|
+
const spenders =
|
|
45
|
+
governedAccount !== undefined && governedAccount !== tx.sourceAccount
|
|
46
|
+
? [tx.sourceAccount, governedAccount]
|
|
47
|
+
: [tx.sourceAccount]
|
|
48
|
+
const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, spenders)
|
|
41
49
|
const allowedPaths = extractPathsByContract(invocations)
|
|
42
50
|
const sharedRouter = inferSharedRouter(invocations)
|
|
43
51
|
|
|
@@ -76,16 +84,22 @@ function groupFunctionsByContract(invocations: ContractInvocation[]): Record<str
|
|
|
76
84
|
return out
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
/** Sum outgoing TokenMovement amounts per token,
|
|
80
|
-
* BigInt throughout; never lossy. Movements whose `from`
|
|
81
|
-
*
|
|
87
|
+
/** Sum outgoing TokenMovement amounts per token, across every spender.
|
|
88
|
+
* BigInt throughout; never lossy. Movements whose `from` is none of the
|
|
89
|
+
* spenders are NOT counted (incoming yield, refund, etc.).
|
|
90
|
+
*
|
|
91
|
+
* A smart account spends from ITSELF while a wallet submits the transaction,
|
|
92
|
+
* so matching the source account alone missed the entire treasury case: the
|
|
93
|
+
* flow read as incoming-only, no amount bound was required, and a rule that
|
|
94
|
+
* capped nothing installed with every check green. The account a policy
|
|
95
|
+
* governs is a spender in its own right. */
|
|
82
96
|
function aggregateOutgoingSpend(
|
|
83
97
|
movements: TokenMovement[],
|
|
84
|
-
|
|
98
|
+
spenders: readonly string[]
|
|
85
99
|
): Record<string, string> {
|
|
86
100
|
const totals = new Map<string, bigint>()
|
|
87
101
|
for (const m of movements) {
|
|
88
|
-
if (m.from
|
|
102
|
+
if (!spenders.includes(m.from)) continue
|
|
89
103
|
const current = totals.get(m.token) ?? 0n
|
|
90
104
|
totals.set(m.token, current + BigInt(m.amount))
|
|
91
105
|
}
|
package/src/types.ts
CHANGED
|
@@ -94,12 +94,31 @@ export type SignerDraft =
|
|
|
94
94
|
| { kind: 'delegated'; address: string }
|
|
95
95
|
| { kind: 'external'; verifier: string; keyBytes: string }
|
|
96
96
|
|
|
97
|
-
/** Reference to one policy attached to a context rule.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
/** Reference to one policy attached to a context rule.
|
|
98
|
+
*
|
|
99
|
+
* Policies on one rule compose as ALL-OF, so a rule may carry our interpreter
|
|
100
|
+
* AND an OpenZeppelin built-in, and both must permit. That is what expresses a
|
|
101
|
+
* rolling total: the interpreter bounds each call, the built-in bounds the sum
|
|
102
|
+
* across calls - state the interpreter deliberately does not keep. */
|
|
103
|
+
export type PolicyRef =
|
|
104
|
+
| {
|
|
105
|
+
kind: 'interpreter'
|
|
106
|
+
interpreterAddress: string
|
|
107
|
+
predicateBlobBase64: string
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
/** OpenZeppelin's `spending_limit`: a rolling total over a window of
|
|
111
|
+
* ledgers. It meters the third argument of a call named exactly
|
|
112
|
+
* `transfer` and refuses any rule scope other than `CallContract`. */
|
|
113
|
+
kind: 'spending_limit'
|
|
114
|
+
policyAddress: string
|
|
115
|
+
/** Window length in LEDGERS, not seconds. Stellar closes a ledger in
|
|
116
|
+
* roughly five seconds, so a period given in seconds is an
|
|
117
|
+
* approximation of this number and should be reported as one. */
|
|
118
|
+
periodLedgers: number
|
|
119
|
+
/** i128 as a base-10 string, in the token's smallest unit. */
|
|
120
|
+
spendingLimit: string
|
|
121
|
+
}
|
|
103
122
|
|
|
104
123
|
/** Grammar version baked into the interpreter wasm, mirroring `SELF_VERSION` in
|
|
105
124
|
* `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
|